From 60282430dcd7af48a246c6e5f9a609869982f94c Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Fri, 3 Jul 2026 13:40:20 +0100 Subject: [PATCH 001/105] Guard repository size --- .gitignore | 2 + README.md | 2 +- Scripts/check_repository_size.sh | 36 ++++++++++++++++ Scripts/lint.sh | 6 +++ Scripts/test_repository_size.sh | 34 ++++++++++++++++ codexbar.png | Bin 306204 -> 0 bytes docs/repository-size.md | 68 +++++++++++++++++++++++++++++++ 7 files changed, 147 insertions(+), 1 deletion(-) create mode 100755 Scripts/check_repository_size.sh create mode 100755 Scripts/test_repository_size.sh delete mode 100644 codexbar.png create mode 100644 docs/repository-size.md diff --git a/.gitignore b/.gitignore index f47250e1f2..fd7b62cf53 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ Codexbar.app/ # Release artifacts *.ipa *.dSYM* +*.xcarchive/ +*.xcresult/ *.zip *.delta *.dmg diff --git a/README.md b/README.md index 414da327ef..59005e5ffc 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ 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 diff --git a/Scripts/check_repository_size.sh b/Scripts/check_repository_size.sh new file mode 100755 index 0000000000..4a24e5a1d1 --- /dev/null +++ b/Scripts/check_repository_size.sh @@ -0,0 +1,36 @@ +#!/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 + +cd "$ROOT_DIR" + +while IFS= read -r -d '' path; do + [[ -f "$path" && ! -L "$path" ]] || continue + tracked_files=$((tracked_files + 1)) + + case "$path" in + *.app | *.app/* | *.dSYM | *.dSYM/* | *.xcarchive/* | *.xcresult/* | *.zip | *.delta | *.dmg | *.pkg | \ + *.tar.gz | *.tgz) + printf 'ERROR: generated artifact is tracked: %s\n' "$path" >&2 + failures=$((failures + 1)) + ;; + esac + + size=$(wc -c < "$path" | tr -d '[:space:]') + if ((size > MAX_BYTES)); then + printf 'ERROR: tracked file exceeds %d bytes: %s (%d bytes)\n' "$MAX_BYTES" "$path" "$size" >&2 + failures=$((failures + 1)) + fi +done < <(git ls-files -z) + +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/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/test_repository_size.sh b/Scripts/test_repository_size.sh new file mode 100755 index 0000000000..d30595d322 --- /dev/null +++ b/Scripts/test_repository_size.sh @@ -0,0 +1,34 @@ +#!/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 +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 +git -C "$TEMP_DIR" add untracked.bin +if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/large.log" 2>&1; then + printf 'ERROR: oversized tracked file was accepted.\n' >&2 + exit 1 +fi +grep -Fq 'tracked file exceeds 2097152 bytes: untracked.bin' "$TEMP_DIR/large.log" + +git -C "$TEMP_DIR" rm --cached --quiet untracked.bin +printf 'release artifact\n' > "$TEMP_DIR/CodexBar-0.0.0.zip" +git -C "$TEMP_DIR" add CodexBar-0.0.0.zip +if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/artifact.log" 2>&1; then + printf 'ERROR: tracked release artifact was accepted.\n' >&2 + exit 1 +fi +grep -Fq 'generated artifact is tracked: CodexBar-0.0.0.zip' "$TEMP_DIR/artifact.log" + +printf 'Repository size tests passed.\n' diff --git a/codexbar.png b/codexbar.png deleted file mode 100644 index feb52be2486992dd9c5a2011dcc072a96ab72c1b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 306204 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⩔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

#b|9h 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 z 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 YU!=yw9cip0AOHXW07*qoM6N<$f-G^cA^-pY diff --git a/docs/repository-size.md b/docs/repository-size.md new file mode 100644 index 0000000000..3c2bc877d0 --- /dev/null +++ b/docs/repository-size.md @@ -0,0 +1,68 @@ +--- +summary: "Repository size findings, prevention checks, and the maintainer-only history cleanup runbook." +read_when: + - Adding large assets or release artifacts + - Investigating clone size + - Planning a Git history rewrite +--- + +# Repository size + +The checked-out source tree is not the main cause of CodexBar's clone size. At commit `61ff9320`, tracked files total +22,292,801 bytes, while the packed Git object database is 103.91 MiB. `CHANGELOG.md` is 150,271 bytes in the current +tree and all of its historical blobs occupy about 0.55 MB compressed. + +Most of the packed history comes from files that are no longer present on `main`: + +| Historical path group | Approximate compressed bytes | +| --- | ---: | +| `screenshots/` | 39,023,716 | +| `Quotio/` | 23,141,837 | +| `CodexBar 2.app/` | 7,088,125 | +| `CodexBar-0.2.2.zip` | 2,296,323 | + +The largest single blob is the retired `Quotio/Resources/Proxy/cli-proxy-api-plus` executable: 49,202,546 bytes +before compression and about 15.8 MB in the pack. Deleting these files in later commits stopped further growth but +did not remove their blobs from existing history. + +## Prevention + +`Scripts/check_repository_size.sh` runs under `make check`. It rejects: + +- tracked files larger than 2 MiB; +- app bundles, dSYMs, archives, deltas, disk images, and installer packages. + +Publish release artifacts through GitHub Releases. Optimize required source images before committing them. If a +source asset genuinely must exceed the limit, discuss and review the limit change explicitly rather than bypassing +the check. + +For a smaller local checkout when full history is unnecessary, use one of: + +```bash +git clone --filter=blob:none https://github.com/steipete/CodexBar.git +git clone --depth=1 https://github.com/steipete/CodexBar.git +``` + +## One-time history cleanup + +A normal pull request cannot reduce the size of already reachable Git objects. Maintainers can recover most of the +avoidable clone weight only with a coordinated history rewrite. Candidate paths that are absent from current +`main` are: + +```text +screenshots/ +Quotio/Resources/Proxy/cli-proxy-api-plus +CodexBar 2.app/ +CodexBar_Dev.app/ +CodexBar-0.2.2.zip +CodexBar6-2.delta +CodexBar6-4.delta +``` + +Use `git filter-repo` on a fresh mirror, validate all branches and tags, and force-push only during an announced +maintenance window. This changes commit and tag IDs and requires contributors to re-clone or carefully rebase, so +the rewrite must not be performed from a normal contributor branch. + +An isolated mirror rehearsal using exactly the candidate paths above reduced the packed object database from +103.90 MiB to 41.99 MiB (59.6%) and passed `git fsck --full --no-dangling`. Treat this as a reproducible baseline, +not authorization to rewrite the canonical repository. From 72809b9122ae588f07eeced5d5f486c354deeb61 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 3 Jul 2026 13:57:18 -0700 Subject: [PATCH 002/105] fix: correct Poe provider logo and brand color The Poe logo used a garbled SVG path (a self-intersecting scribble inside a circle) instead of the actual Poe speech-bubble mark, and the brand color was green (#26AD61) rather than Poe's official purple. - Replace docs/logos/poe.svg with the correct Poe mark in #5D5CDE - Apply the same path fix to the app resource ProviderIcon-poe.svg - Update the Poe card --brand color in docs/index.html to #5D5CDE #5D5CDE is Poe's official primary brand color ("Poe purple"). Co-Authored-By: Claude Opus 4.8 --- Sources/CodexBar/Resources/ProviderIcon-poe.svg | 2 +- docs/index.html | 2 +- docs/logos/poe.svg | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/docs/index.html b/docs/index.html index 9a4517ef6c..c0bccbbae3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -332,7 +332,7 @@ -

Fixed

-
    -
  • MiniMax: recover detailed token-plan windows from the remains API when the coding-plan page only exposes coarse usage. Thanks @Yuxin-Qiao!
  • -
  • Cost history: remove the redundant tooltip from submenu-backed Cost rows. Thanks @Zihao-Qi!
  • -
  • Menu refresh: keep the menu open and show in-place progress when Refresh is clicked. Thanks @elijahfriedman!
  • -
  • Menu: align provider usage-card spacing with the Overview layout. Thanks @Zihao-Qi!
  • -
  • Memory pressure: avoid actor-isolation crashes when system callbacks arrive on a utility queue. Thanks @Zihao-Qi!
  • -
  • Menu: remove extra separators and spacing around Storage, Cost, and Subscription Utilization rows. Thanks @elijahfriedman!
  • -
  • Antigravity: show limits as unavailable when OAuth identifies the account but quota endpoints deny access. Thanks @Yuxin-Qiao!
  • -
-

View full changelog

-]]> - - 0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From f6732bd7607823dc64a681e27e70163cdc357c5e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 02:38:18 -0700 Subject: [PATCH 022/105] fix: validate repository size from index blobs (#1875) Validate tracked Git index blobs instead of mutable working-tree files, reject generated release artifacts, and preserve isolated Intel test coverage with realistic cold-build time budgets. Co-authored-by: JoeVenner --- .github/workflows/ci.yml | 5 ++-- CHANGELOG.md | 1 + Scripts/check_repository_size.sh | 39 ++++++++++++++++++++++++-------- Scripts/test_repository_size.sh | 27 ++++++++++++++++++++-- docs/repository-size.md | 3 ++- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96cd1630c1..2afaeda982 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: needs: changes if: ${{ needs.changes.outputs.macos-tests == 'true' }} runs-on: macos-15-intel - timeout-minutes: 45 + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -119,7 +119,8 @@ jobs: run: ./Scripts/lint.sh lint-macos - name: Swift Test - timeout-minutes: 40 + # 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 \ diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d0978a76..234e35015e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 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! ### Changed +- Repository: reject oversized tracked blobs and generated release/build artifacts during checks. Thanks @joeVenner! - Branding: replace the app and website icon with a usage-meter prompt mark that matches CodexBar's core UI. - Website: redesign codexbar.app around faster download, provider discovery, feature, CLI, and widget paths with responsive dark/light and localized layouts. Thanks @vyctorbrzezowski! - Architecture: accept a bounded opt-in adaptive refresh design with a deterministic 2–30-minute cadence and no behavioral telemetry. Thanks @hhh2210! diff --git a/Scripts/check_repository_size.sh b/Scripts/check_repository_size.sh index 449055e5df..b94da55225 100755 --- a/Scripts/check_repository_size.sh +++ b/Scripts/check_repository_size.sh @@ -5,10 +5,16 @@ 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 '' path; do +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 @@ -19,14 +25,29 @@ while IFS= read -r -d '' path; do ;; esac - [[ -f "$path" && ! -L "$path" ]] || continue - - size=$(wc -c < "$path") - if ((size > MAX_BYTES)); then - printf 'ERROR: tracked file exceeds %d bytes: %s (%d bytes)\n' "$MAX_BYTES" "$path" "$size" >&2 - failures=$((failures + 1)) - fi -done < <(git ls-files -z) + # 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 diff --git a/Scripts/test_repository_size.sh b/Scripts/test_repository_size.sh index 293a6a2275..fb760d636d 100755 --- a/Scripts/test_repository_size.sh +++ b/Scripts/test_repository_size.sh @@ -8,6 +8,9 @@ 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 @@ -20,13 +23,33 @@ 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: oversized tracked file was accepted.\n' >&2 + 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" @@ -47,7 +70,7 @@ for artifact in "${artifacts[@]}"; do 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 diff --git a/docs/repository-size.md b/docs/repository-size.md index e969adcc1d..7379201b20 100644 --- a/docs/repository-size.md +++ b/docs/repository-size.md @@ -27,7 +27,8 @@ did not remove their blobs from existing history. ## Prevention -`Scripts/check_repository_size.sh` runs under `make check`. It rejects: +`Scripts/check_repository_size.sh` runs under `make check`. It inspects the exact blobs in Git's index, not mutable +working-tree copies, and rejects: - tracked files larger than 2 MiB; - app bundles, dSYMs, Xcode results, IPAs, archives, deltas, disk images, and installer packages. From 143e0873c7bcac6fbcededb61f33f36ed74ffb7c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 10:48:35 +0100 Subject: [PATCH 023/105] fix: repair live Sparkle update verification --- Scripts/test_live_update.sh | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) 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." From 93cb1233b305452c2e134b79cd1eec9e232aee62 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 10:48:35 +0100 Subject: [PATCH 024/105] chore: start 0.38.2 development --- CHANGELOG.md | 14 +++++++++++--- version.env | 4 ++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb2b2287b1..48855d3bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,19 @@ # Changelog -## 0.38.1 — 2026-07-04 +## 0.38.2 — Unreleased ### Added - Kimi: show monthly subscription usage alongside weekly and five-hour limits with a short total budget for the optional membership request. Thanks @zhiyue! + +### Changed +- Repository: reject oversized tracked blobs and generated release/build artifacts during checks. Thanks @joeVenner! + +### Fixed +- 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. @@ -12,7 +22,6 @@ - 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! ### Changed -- Repository: reject oversized tracked blobs and generated release/build artifacts during checks. Thanks @joeVenner! - Branding: replace the app and website icon with a usage-meter prompt mark that matches CodexBar's core UI. - Website: redesign codexbar.app around faster download, provider discovery, feature, CLI, and widget paths with responsive dark/light and localized layouts. Thanks @vyctorbrzezowski! - Architecture: accept a bounded opt-in adaptive refresh design with a deterministic 2–30-minute cadence and no behavioral telemetry. Thanks @hhh2210! @@ -25,7 +34,6 @@ ### 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! -- Claude: make the "Avoid Keychain prompts" setting use the no-prompt policy instead of the experimental `security` CLI reader. Thanks @gmkbenjamin! - 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! diff --git a/version.env b/version.env index de060132a3..b181b2916c 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.38.1 -BUILD_NUMBER=95 +MARKETING_VERSION=0.38.2 +BUILD_NUMBER=96 From bc29d89ab48338ffd167a9b8348b394a769f246c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 11:07:01 +0100 Subject: [PATCH 025/105] feat: add Mistral balance metrics Co-authored-by: Trim --- CHANGELOG.md | 1 + README.md | 2 +- Sources/CodexBar/MenuCardView.swift | 32 +++- .../Resources/en.lproj/Localizable.strings | 2 +- .../StatusItemController+Animation.swift | 76 ++++++-- ...StatusItemController+IconObservation.swift | 12 +- ...tatusItemController+MenuPresentation.swift | 8 +- ...StatusItemController+SwitcherMetrics.swift | 15 +- .../Providers/Mistral/MistralModels.swift | 46 +++++ .../Mistral/MistralProviderDescriptor.swift | 48 ++++- .../Mistral/MistralUsageFetcher.swift | 66 ++++++- Tests/CodexBarTests/MenuCardModelTests.swift | 46 ----- .../MistralMenuCardModelTests.swift | 168 ++++++++++++++++++ .../MistralUsageParserTests.swift | 95 ++++++++++ .../CodexBarTests/MistralVibeUsageTests.swift | 86 +++++++++ .../ProvidersPaneCoverageTests.swift | 2 +- .../StatusItemAnimationSignatureTests.swift | 95 ++++++++++ .../StatusItemBalanceDisplayTests.swift | 74 +++++++- .../StatusItemControllerMenuTests.swift | 42 +++++ docs/providers.md | 8 +- 20 files changed, 823 insertions(+), 101 deletions(-) create mode 100644 Tests/CodexBarTests/MistralMenuCardModelTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 48855d3bd2..3913ac0eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - 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 - Repository: reject oversized tracked blobs and generated release/build artifacts during checks. Thanks @joeVenner! diff --git a/README.md b/README.md index 6539600bd0..8d93020288 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index c2c57a5a84..e0c8c7131c 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1115,6 +1115,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, @@ -1252,7 +1266,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 +1283,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 +1358,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( diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index be1c618939..486176c315 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -499,7 +499,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)"; diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 304e2a8ab8..77fc163f1e 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,6 +569,52 @@ extension StatusItemController { return String(format: "%.3f", value) } + 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?) -> Double? { // Derive the menu-bar credits fallback from the same Codex projection path the rendered // icon and menu use (`codexConsumerProjection` -> `menuBarFallback`), instead of a @@ -710,8 +752,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 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+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+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/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index 1470a76881..20e2821b20 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -156,6 +156,7 @@ public struct MistralUsageSnapshot: Codable, Sendable { public let totalCachedTokens: Int public let modelCount: Int public let daily: [MistralDailyUsageBucket] + public let credits: MistralCreditsSnapshot? public let startDate: Date? public let endDate: Date? public let updatedAt: Date @@ -169,6 +170,7 @@ public struct MistralUsageSnapshot: Codable, Sendable { totalCachedTokens: Int, modelCount: Int, daily: [MistralDailyUsageBucket] = [], + credits: MistralCreditsSnapshot? = nil, startDate: Date?, endDate: Date?, updatedAt: Date) @@ -181,11 +183,28 @@ public struct MistralUsageSnapshot: Codable, Sendable { self.totalCachedTokens = totalCachedTokens self.modelCount = modelCount self.daily = daily.sorted { $0.day < $1.day } + self.credits = credits self.startDate = startDate self.endDate = endDate self.updatedAt = updatedAt } + public func with(credits: MistralCreditsSnapshot?) -> MistralUsageSnapshot { + MistralUsageSnapshot( + totalCost: self.totalCost, + currency: self.currency, + currencySymbol: self.currencySymbol, + totalInputTokens: self.totalInputTokens, + totalOutputTokens: self.totalOutputTokens, + totalCachedTokens: self.totalCachedTokens, + modelCount: self.modelCount, + daily: self.daily, + credits: credits, + startDate: self.startDate, + endDate: self.endDate, + updatedAt: self.updatedAt) + } + public func toUsageSnapshot() -> UsageSnapshot { // Negative totalCost means a refund/credit adjustment; clamp to zero rather than // showing a confusing negative amount in the menu bar. @@ -248,3 +267,30 @@ public struct MistralUsageSnapshot: Codable, Sendable { updatedAt: self.updatedAt) } } + +public struct MistralCreditsSnapshot: Codable, Equatable, Sendable { + public let walletAmount: Double + public let creditNotesAmount: Double + public let ongoingUsageBalance: Double + public let currency: String + + public init( + walletAmount: Double, + creditNotesAmount: Double, + ongoingUsageBalance: Double, + currency: String) + { + self.walletAmount = walletAmount + self.creditNotesAmount = creditNotesAmount + self.ongoingUsageBalance = ongoingUsageBalance + self.currency = currency + } + + public var availableAmount: Double { + max(0, self.walletAmount + self.creditNotesAmount - self.ongoingUsageBalance) + } + + public var formattedAvailableAmount: String { + UsageFormatter.currencyString(self.availableAmount, currencyCode: self.currency) + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift index 0632c9fec6..a146ce31a0 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift @@ -9,7 +9,7 @@ public enum MistralProviderDescriptor { metadata: ProviderMetadata( id: .mistral, displayName: "Mistral", - sessionLabel: "Monthly", + sessionLabel: "Balance", weeklyLabel: "", opusLabel: nil, supportsOpus: false, @@ -78,26 +78,60 @@ struct MistralWebFetchStrategy: ProviderFetchStrategy { } } - private static func fetchUsageWithVibe( + static func fetchUsageWithVibe( cookieHeader: String, csrfToken: String?, - timeout: TimeInterval) async throws -> UsageSnapshot + timeout: TimeInterval, + transport: ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> UsageSnapshot { let deadline = Date().addingTimeInterval(timeout) let snapshot = try await MistralUsageFetcher.fetchUsage( cookieHeader: cookieHeader, csrfToken: csrfToken, - timeout: timeout) - let remaining = deadline.timeIntervalSinceNow + timeout: timeout, + transport: transport) + var remaining = deadline.timeIntervalSinceNow let vibeResult: MistralUsageFetcher.MistralVibeUsageResult? = if let csrfToken, remaining > 0 { try await Self.fetchOptionalVibeUsage( csrfToken: csrfToken, cookieHeader: cookieHeader, - timeout: min(remaining, 4)) + timeout: min(remaining, 4), + transport: transport) } else { nil } - return Self.attachVibeWindow(to: snapshot.toUsageSnapshot(), vibeResult: vibeResult) + remaining = deadline.timeIntervalSinceNow + let credits: MistralCreditsSnapshot? = if remaining > 0 { + try await Self.fetchOptionalCredits( + cookieHeader: cookieHeader, + csrfToken: csrfToken, + timeout: min(remaining, 4), + transport: transport) + } else { + nil + } + return Self.attachVibeWindow(to: snapshot.with(credits: credits).toUsageSnapshot(), vibeResult: vibeResult) + } + + static func fetchOptionalCredits( + cookieHeader: String, + csrfToken: String?, + timeout: TimeInterval, + transport: ProviderHTTPTransport = ProviderHTTPClient.shared) async throws + -> MistralCreditsSnapshot? + { + do { + return try await MistralUsageFetcher.fetchCredits( + cookieHeader: cookieHeader, + csrfToken: csrfToken, + timeout: timeout, + transport: transport) + } catch { + if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled { + throw CancellationError() + } + return nil + } } static func fetchOptionalVibeUsage( diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift index f28623df3f..812b83adc1 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -14,7 +14,8 @@ public enum MistralUsageFetcher { public static func fetchUsage( cookieHeader: String, csrfToken: String?, - timeout: TimeInterval = 15) async throws -> MistralUsageSnapshot + timeout: TimeInterval = 15, + transport: ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MistralUsageSnapshot { let now = Date() var calendar = Calendar(identifier: .gregorian) @@ -41,7 +42,7 @@ public enum MistralUsageFetcher { request.setValue(csrfToken, forHTTPHeaderField: "X-CSRFTOKEN") } - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await transport.response(for: request) let data = response.data switch response.statusCode { @@ -95,6 +96,38 @@ public enum MistralUsageFetcher { return try Self.parseVibeUsage(data: data) } + public static func fetchCredits( + cookieHeader: String, + csrfToken: String?, + timeout: TimeInterval = 4, + transport: ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MistralCreditsSnapshot + { + let url = self.baseURL.appendingPathComponent("/api/billing/credits") + var request = URLRequest(url: url, timeoutInterval: timeout) + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("https://admin.mistral.ai/organization/billing", forHTTPHeaderField: "Referer") + request.setValue("https://admin.mistral.ai", forHTTPHeaderField: "Origin") + if let csrfToken { + request.setValue(csrfToken, forHTTPHeaderField: "X-CSRFTOKEN") + } + + let response = try await transport.response(for: request) + let data = response.data + + switch response.statusCode { + case 200: + break + case 401, 403: + throw MistralUsageError.invalidCredentials + default: + let body = String(data: data.prefix(200), encoding: .utf8) ?? "" + throw MistralUsageError.apiError("HTTP \(response.statusCode): \(body)") + } + + return try Self.parseCredits(data: data) + } + static func parseVibeUsage(data: Data) throws -> MistralVibeUsageResult { let responses: [VibeUsageResponse] do { @@ -113,6 +146,21 @@ public enum MistralUsageFetcher { resetAt: json.resetAt.flatMap(Self.parseISO8601Date)) } + static func parseCredits(data: Data) throws -> MistralCreditsSnapshot { + let response: MistralCreditsResponse + do { + response = try JSONDecoder().decode(MistralCreditsResponse.self, from: data) + } catch { + throw MistralUsageError.parseFailed(error.localizedDescription) + } + + return MistralCreditsSnapshot( + walletAmount: response.walletAmount, + creditNotesAmount: response.creditNotesAmount ?? 0, + ongoingUsageBalance: response.ongoingUsageBalance ?? 0, + currency: response.currency) + } + static func vibeCookieHeader(csrfToken: String) throws -> String { try "csrftoken=\(self.validatedVibeCSRFToken(csrfToken))" } @@ -500,3 +548,17 @@ private struct VibeUsageResponse: Decodable { } } } + +private struct MistralCreditsResponse: Decodable { + let walletAmount: Double + let creditNotesAmount: Double? + let ongoingUsageBalance: Double? + let currency: String + + enum CodingKeys: String, CodingKey { + case currency + case walletAmount = "wallet_amount" + case creditNotesAmount = "credit_notes_amount" + case ongoingUsageBalance = "ongoing_usage_balance" + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index c0552cee2d..3dc706874f 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -1527,50 +1527,4 @@ struct MenuCardModelTests { #expect(primary.resetText == nil) #expect(primary.detailText == "10/100 credits") } - - @Test - func `mistral model surfaces monthly cost as primary detail text`() throws { - let now = Date() - let resetsAt = now.addingTimeInterval(3 * 24 * 60 * 60) - let identity = ProviderIdentitySnapshot( - providerID: .mistral, - accountEmail: nil, - accountOrganization: nil, - loginMethod: nil) - let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 0, - windowMinutes: nil, - resetsAt: resetsAt, - resetDescription: "€1.2345 this month"), - secondary: nil, - tertiary: nil, - updatedAt: now, - identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.mistral]) - - let model = UsageMenuCardView.Model.make(.init( - provider: .mistral, - metadata: metadata, - snapshot: snapshot, - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: nil, plan: nil), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: true, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - let primary = try #require(model.metrics.first) - #expect(primary.detailText == "€1.2345 this month") - #expect(primary.resetText?.hasPrefix("Resets") == true) - } } diff --git a/Tests/CodexBarTests/MistralMenuCardModelTests.swift b/Tests/CodexBarTests/MistralMenuCardModelTests.swift new file mode 100644 index 0000000000..5726839e69 --- /dev/null +++ b/Tests/CodexBarTests/MistralMenuCardModelTests.swift @@ -0,0 +1,168 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MistralMenuCardModelTests { + @Test + func `mistral credit balance renders like deepseek balance`() throws { + let now = Date() + let credits = MistralCreditsSnapshot( + walletAmount: 0, + creditNotesAmount: 0, + ongoingUsageBalance: 0, + currency: "USD") + let snapshot = MistralUsageSnapshot( + totalCost: 0, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + credits: credits, + startDate: nil, + endDate: nil, + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.title == "Balance") + #expect(primary.statusText == "$0.00") + #expect(primary.resetText == nil) + #expect(primary.detailText == nil) + } + + @Test + func `mistral credit balance renders separately from primary percent lane`() throws { + let now = Date() + let credits = MistralCreditsSnapshot( + walletAmount: 10, + creditNotesAmount: 2.5, + ongoingUsageBalance: 0, + currency: "USD") + let usage = MistralUsageSnapshot( + totalCost: 0, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + credits: credits, + startDate: nil, + endDate: nil, + updatedAt: now) + .toUsageSnapshot() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 73, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60), + resetDescription: "API spend this month"), + secondary: nil, + tertiary: nil, + mistralUsage: usage.mistralUsage, + updatedAt: now, + identity: usage.identity) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.id == "mistral-balance") + #expect(primary.statusText == "$12.50") + #expect(primary.detailText == nil) + #expect(primary.resetText == nil) + + let percentMetric = try #require(model.metrics.dropFirst().first) + #expect(percentMetric.id == "primary") + #expect(percentMetric.percent == 27) + #expect(percentMetric.detailText == "API spend this month") + } + + @Test + func `mistral model surfaces monthly cost as primary detail text`() throws { + let now = Date() + let resetsAt = now.addingTimeInterval(3 * 24 * 60 * 60) + let identity = ProviderIdentitySnapshot( + providerID: .mistral, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: "€1.2345 this month"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.detailText == "€1.2345 this month") + #expect(primary.resetText?.hasPrefix("Resets") == true) + } +} diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index b380175365..fdb53aabf2 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -59,6 +59,74 @@ struct MistralUsageParserTests { #expect(snapshot.currency == "EUR") } + @Test + func `parses credits response`() throws { + let json = """ + { + "wallet_amount": 12.5, + "credit_notes_amount": 2.25, + "ongoing_usage_balance": 1.5, + "currency": "USD", + "minimum_credits_purchase": 10, + "maximum_credits_purchase": 1000 + } + """ + + let credits = try MistralUsageFetcher.parseCredits(data: Data(json.utf8)) + + #expect(credits.walletAmount == 12.5) + #expect(credits.creditNotesAmount == 2.25) + #expect(credits.ongoingUsageBalance == 1.5) + #expect(credits.currency == "USD") + #expect(credits.availableAmount == 13.25) + #expect(credits.formattedAvailableAmount == "$13.25") + } + + @Test + func `credits available amount floors after ongoing usage`() { + let credits = MistralCreditsSnapshot( + walletAmount: 1, + creditNotesAmount: 0.5, + ongoingUsageBalance: 3, + currency: "USD") + + #expect(credits.availableAmount == 0) + #expect(credits.formattedAvailableAmount == "$0.00") + } + + @Test + func `fetches credits from dashboard endpoint with existing web session`() async throws { + let json = """ + { + "wallet_amount": 3, + "credit_notes_amount": 4, + "ongoing_usage_balance": 0, + "currency": "EUR" + } + """ + let transport = ProviderHTTPTransportHandler { request in + #expect(request.url?.absoluteString == "https://admin.mistral.ai/api/billing/credits") + #expect(request.value(forHTTPHeaderField: "Cookie") == "ory_session_test=abc; csrftoken=csrf") + #expect(request.value(forHTTPHeaderField: "X-CSRFTOKEN") == "csrf") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://admin.mistral.ai/organization/billing") + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(json.utf8), response) + } + + let credits = try await MistralUsageFetcher.fetchCredits( + cookieHeader: "ory_session_test=abc; csrftoken=csrf", + csrfToken: "csrf", + transport: transport) + + #expect(credits.availableAmount == 7) + #expect(credits.formattedAvailableAmount == "€7.00") + } + @Test func `daily spend keeps non token Mistral units out of token totals`() throws { let json = """ @@ -149,6 +217,33 @@ struct MistralUsageSnapshotConversionTests { #expect(usage.providerCost == nil) } + @Test + func `converts credits into balance data without replacing api spend or primary percent`() { + let credits = MistralCreditsSnapshot( + walletAmount: 10, + creditNotesAmount: 2.5, + ongoingUsageBalance: 1, + currency: "USD") + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + credits: credits, + startDate: nil, + endDate: Date(), + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.identity?.loginMethod == "API spend: $1.2345 this month") + #expect(usage.mistralUsage?.credits == credits) + #expect(usage.mistralUsage?.credits?.formattedAvailableAmount == "$11.50") + } + @Test func `converts zero cost into zero spend text`() { let snapshot = MistralUsageSnapshot( diff --git a/Tests/CodexBarTests/MistralVibeUsageTests.swift b/Tests/CodexBarTests/MistralVibeUsageTests.swift index f98cdcdc15..97787940f2 100644 --- a/Tests/CodexBarTests/MistralVibeUsageTests.swift +++ b/Tests/CodexBarTests/MistralVibeUsageTests.swift @@ -18,6 +18,23 @@ private final class MistralRequestCapture: @unchecked Sendable { } } +private final class MistralRequestPathLog: @unchecked Sendable { + private let lock = NSLock() + private var storedPaths: [String] = [] + + var paths: [String] { + self.lock.withLock { self.storedPaths } + } + + func record(_ request: URLRequest) { + let host = request.url?.host ?? "" + let path = request.url?.path ?? "" + self.lock.withLock { + self.storedPaths.append("\(host)\(path)") + } + } +} + struct MistralVibeUsageTests { #if os(macOS) @Test @@ -132,6 +149,45 @@ struct MistralVibeUsageTests { #expect(result == nil) } + @Test + func `combined fetch preserves monthly plan when optional credits time out`() async throws { + let requestLog = MistralRequestPathLog() + let usageData = Data(Self.billingUsageResponseJSON.utf8) + let vibeData = Data(Self.responseJSON(usagePercentage: 37).utf8) + let transport = ProviderHTTPTransportHandler { request in + requestLog.record(request) + guard let url = request.url else { throw URLError(.badURL) } + if url.host == "admin.mistral.ai", url.path == "/api/billing/v2/usage" { + let response = try Self.response(url: url, statusCode: 200) + return (usageData, response) + } + if url.host == "console.mistral.ai" { + let response = try Self.response(url: url, statusCode: 200) + return (vibeData, response) + } + if url.host == "admin.mistral.ai", url.path == "/api/billing/credits" { + try await Task.sleep(for: .milliseconds(25)) + throw URLError(.timedOut) + } + throw URLError(.badURL) + } + + let snapshot = try await MistralWebFetchStrategy.fetchUsageWithVibe( + cookieHeader: "ory_session_test=abc; csrftoken=csrf", + csrfToken: "csrf", + timeout: 1, + transport: transport) + + let monthlyPlan = snapshot.extraRateWindows?.first { $0.id == "mistral-monthly-plan" } + #expect(monthlyPlan?.window.usedPercent == 37) + #expect(snapshot.mistralUsage?.credits == nil) + #expect(requestLog.paths == [ + "admin.mistral.ai/api/billing/v2/usage", + "console.mistral.ai/api-ui/trpc/billing.vibeUsage", + "admin.mistral.ai/api/billing/credits", + ]) + } + @Test func `monthly plan window preserves existing extras`() { let existing = NamedRateWindow( @@ -196,4 +252,34 @@ struct MistralVibeUsageTests { }}}}] """ } + + private static var billingUsageResponseJSON: String { + """ + { + "completion": {"models": {}}, + "ocr": {"models": {}}, + "connectors": {"models": {}}, + "libraries_api": {"pages": {"models": {}}, "tokens": {"models": {}}}, + "fine_tuning": {"training": {}, "storage": {}}, + "audio": {"models": {}}, + "vibe_usage": 0.0, + "date": "2026-02-01T00:00:00Z", + "previous_month": "2026-01", + "next_month": "2026-03", + "start_date": "2026-02-01T00:00:00Z", + "end_date": "2026-02-28T23:59:59.999Z", + "currency": "USD", + "currency_symbol": "$", + "prices": [] + } + """ + } + + private static func response(url: URL, statusCode: Int) throws -> HTTPURLResponse { + try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)) + } } diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index 7a04c047a4..08acfa0dd6 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -245,7 +245,7 @@ struct ProvidersPaneCoverageTests { ]) #expect(picker?.options.first?.title == "Pay-as-you-go") #expect(picker?.options.last?.title == "Monthly Plan") - #expect(picker?.subtitle == "Shows current-month Mistral API spend in the menu bar.") + #expect(picker?.subtitle == "Choose Mistral API spend or Monthly Plan usage for the menu bar.") } } diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index f614aabf38..a0320708ba 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -142,6 +142,101 @@ struct StatusItemAnimationSignatureTests { #expect(signature.contains("weekly=1.000")) } + @Test + func `merged mistral icon uses monthly plan metric when selected`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-mistral-monthly-plan" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .mistral + settings.menuBarShowsBrandIconWithPercent = false + settings.usageBarsShowUsed = true + settings.syntheticAPIToken = "synthetic-test-token" + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) + + let registry = ProviderRegistry.shared + if let mistralMeta = registry.metadata[.mistral] { + settings.setProviderEnabled(provider: .mistral, metadata: mistralMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: RateWindow(usedPercent: 42, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()), + provider: .mistral) + + #expect(store.iconStyle == .combined) + #expect(controller.primaryProviderForUnifiedIcon() == .mistral) + + controller.applyIcon(phase: nil) + let signature = try #require(controller.lastAppliedMergedIconRenderSignature) + + #expect(signature.contains("provider=mistral")) + #expect(signature.contains("primary=42.000")) + #expect(signature.contains("weekly=nil")) + } + + @Test + func `mistral pay as you go icon ignores balance primary percent`() { + let suite = "StatusItemAnimationSignatureTests-mistral-payg-balance-percent" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.automatic, for: .mistral) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$12.50"), + secondary: nil, + updatedAt: Date()) + + let percents = controller.resolvedMenuBarIconPercents( + provider: .mistral, + snapshot: snapshot, + style: .mistral, + showUsed: true) + + #expect(percents?.primary == nil) + #expect(percents?.secondary == nil) + } + @Test func `merged brand percent reapplies title when cached render is skipped`() throws { let suite = "StatusItemAnimationSignatureTests-merged-brand-percent-title-restore" diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift index e834dd2423..1c181ebc20 100644 --- a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -346,6 +346,76 @@ struct StatusItemBalanceDisplayTests { #expect(displayText == "€1.2345") } + @Test + func `menu bar display text uses mistral monthly plan when selected`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mistral-monthly-plan", + provider: .mistral) + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: nil, + updatedAt: Date()) + .toUsageSnapshot() + .with(extraRateWindows: [ + NamedRateWindow( + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: RateWindow( + usedPercent: 42, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ]) + + store._setSnapshotForTesting(snapshot, provider: .mistral) + store._setErrorForTesting(nil, provider: .mistral) + + let displayText = controller.menuBarDisplayText(for: .mistral, snapshot: snapshot) + + #expect(snapshot.identity?.loginMethod == "API spend: €1.2345 this month") + #expect(displayText == "42%") + } + + @Test + func `menu bar display text falls back to mistral spend when monthly plan is missing`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mistral-monthly-plan-missing", + provider: .mistral) + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: nil, + updatedAt: Date()) + .toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .mistral) + store._setErrorForTesting(nil, provider: .mistral) + + let displayText = controller.menuBarDisplayText(for: .mistral, snapshot: snapshot) + + #expect(snapshot.identity?.loginMethod == "API spend: €1.2345 this month") + #expect(displayText == "€1.2345") + } + @Test func `menu bar display text uses kimi k2 api key credits`() { let settings = self.makeSettings( @@ -560,7 +630,7 @@ struct StatusItemBalanceDisplayTests { } @Test - func `mistral primary window is nil even when billing end date is set`() { + func `mistral primary window is nil without credits even when billing end date is set`() { let endDate = Date(timeIntervalSinceNow: 3600) let snapshot = MistralUsageSnapshot( totalCost: 0.5, @@ -574,7 +644,7 @@ struct StatusItemBalanceDisplayTests { endDate: endDate, updatedAt: Date()).toUsageSnapshot() - // Mistral doesn't expose a reset time — primary is always nil. + // Billing end date alone is not a quota window; credits are what populate primary. #expect(snapshot.primary == nil) } diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index 9e3f2d3de8..e01de5583c 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -116,6 +116,48 @@ struct StatusItemControllerMenuTests { #expect(percent == 76) } + @Test + func `mistral switcher uses monthly plan metric when selected`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: RateWindow(usedPercent: 42, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .mistral, + snapshot: snapshot, + showUsed: true, + preference: .monthlyPlan) + + #expect(percent == 42) + } + + @Test + func `mistral switcher ignores pay as you go balance primary`() { + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$12.50"), + secondary: nil, + updatedAt: Date()) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .mistral, + snapshot: snapshot, + showUsed: true, + preference: .automatic) + + #expect(percent == nil) + } + @Test @MainActor func `menu card width stays at base width when menu accessories are present`() { diff --git a/docs/providers.md b/docs/providers.md index 4bbc1b2c1f..e441c6252f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -59,7 +59,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | Doubao | API key from config/env → Volcengine Ark chat-completions probe (`api`). | | Sakana AI | Manual Cookie header → billing page parser for 5-hour and weekly quota windows (`web`). | | Abacus AI | Browser cookies → compute points + billing API (`web`). | -| Mistral | Console billing and Vibe subscription usage via browser cookies (`web`). | +| Mistral | Console billing, credit balance, and Vibe subscription usage via browser cookies (`web`). | | DeepSeek | API key from env or token accounts → balance endpoint (`api`). | | Moonshot | API key from config/env → balance endpoint (`api`). | | Codebuff | API token from config/env or `codebuff login` credentials → usage API (`api`). | @@ -350,11 +350,11 @@ headers, source selection, provider ordering, and token accounts are stored in ` ## Mistral - Session cookie (`ory_session_*`) from browser auto-import or manual `Cookie:` header. - CSRF token (`csrftoken` cookie) sent as `X-CSRFTOKEN` for billing and Vibe usage requests. -- Domains: `admin.mistral.ai` for API billing and `console.mistral.ai` for optional Vibe subscription usage. Console requests forward only `csrftoken` and `ory_session_*`; all other admin cookies stay origin-bound. -- Reads monthly usage and pricing from the Mistral billing API. +- Domains: `admin.mistral.ai` for API billing and credit balance, and `console.mistral.ai` for optional Vibe subscription usage. Console requests forward only `csrftoken` and `ory_session_*`; all other admin cookies stay origin-bound. +- Reads monthly usage and pricing from the billing usage endpoint, plus credit balance from the billing credits endpoint, using the Mistral web session. - Cost is computed client-side from token counts and response pricing. - Reads Vibe monthly-plan usage percentage and reset time when the console endpoint is available. -- The menu bar metric can show either pay-as-you-go API spend or monthly-plan usage. +- The menu bar metric can show either pay-as-you-go API spend or monthly-plan usage; the provider card shows balance when the credits endpoint is available. - Resets at end of calendar month. - Status: `https://status.mistral.ai` (link only, no auto-polling). From 97c00358c578810b339baf6dddf0ab75b0928ae2 Mon Sep 17 00:00:00 2001 From: Sailesh Sivakumar <32437884+ss251@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:54:19 +0530 Subject: [PATCH 026/105] feat(sakana): surface pay-as-you-go credit balance (#1825) Fetch Sakana pay-as-you-go credit data as bounded best-effort enrichment and render it only when optional usage is enabled, without delaying subscription quota refreshes. Co-authored-by: ss251 --- CHANGELOG.md | 1 + Sources/CodexBar/MenuCardView+Costs.swift | 9 + Sources/CodexBar/MenuCardView.swift | 6 +- Sources/CodexBar/MenuDescriptor.swift | 20 +- .../Providers/ProviderDiagnosticExport.swift | 1 + .../Sakana/SakanaProviderDescriptor.swift | 3 +- .../Providers/Sakana/SakanaUsageFetcher.swift | 235 ++++++++++++- Sources/CodexBarCore/UsageFetcher.swift | 9 + .../MenuDescriptorSakanaTests.swift | 128 +++++++ .../SakanaUsageFetcherTests.swift | 314 +++++++++++++++++- docs/providers.md | 4 +- docs/sakana.md | 57 +++- 12 files changed, 755 insertions(+), 32 deletions(-) create mode 100644 Tests/CodexBarTests/MenuDescriptorSakanaTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 48855d3bd2..053ceefd9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.38.2 — Unreleased ### Added +- 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! ### Changed diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 5fc962519b..4da95b52b9 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 && diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index c2c57a5a84..491e396147 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -820,7 +820,11 @@ extension UsageMenuCardView.Model { input.provider == .factory || (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 diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 97f856adc6..15606c80ed 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -238,7 +238,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 +267,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" { @@ -303,6 +307,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( diff --git a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift index 929bf0f003..91506be870 100644 --- a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift +++ b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift @@ -180,6 +180,7 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable { if snapshot.minimaxUsage != nil { providerSpecificData.append("minimaxUsage") } if snapshot.deepseekUsage != nil { providerSpecificData.append("deepseekUsage") } if snapshot.openRouterUsage != nil { providerSpecificData.append("openRouterUsage") } + if snapshot.sakanaPayAsYouGo != nil { providerSpecificData.append("sakanaPayAsYouGo") } if snapshot.openAIAPIUsage != nil { providerSpecificData.append("openAIAPIUsage") } if snapshot.claudeAdminAPIUsage != nil { providerSpecificData.append("claudeAdminAPIUsage") } if snapshot.mistralUsage != nil { providerSpecificData.append("mistralUsage") } diff --git a/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift index bcaad858ac..aa711d57f7 100644 --- a/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaProviderDescriptor.swift @@ -56,7 +56,8 @@ struct SakanaWebFetchStrategy: ProviderFetchStrategy { } let usage = try await SakanaUsageFetcher.fetchUsage( cookieHeader: cookieHeader, - timeout: context.webTimeout) + timeout: context.webTimeout, + includeOptionalUsage: context.includeOptionalUsage) return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") } diff --git a/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift index 0e94eb6aed..b3c3f250ee 100644 --- a/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Sakana/SakanaUsageFetcher.swift @@ -18,6 +18,7 @@ public struct SakanaUsageSnapshot: Sendable { public let priceLabel: String? public let fiveHour: QuotaWindow? public let weekly: QuotaWindow? + public let payAsYouGo: SakanaPayAsYouGoSnapshot? public let updatedAt: Date public init( @@ -25,12 +26,14 @@ public struct SakanaUsageSnapshot: Sendable { priceLabel: String?, fiveHour: QuotaWindow?, weekly: QuotaWindow?, + payAsYouGo: SakanaPayAsYouGoSnapshot? = nil, updatedAt: Date = Date()) { self.planName = planName self.priceLabel = priceLabel self.fiveHour = fiveHour self.weekly = weekly + self.payAsYouGo = payAsYouGo self.updatedAt = updatedAt } @@ -64,11 +67,36 @@ public struct SakanaUsageSnapshot: Sendable { secondary: secondary, tertiary: nil, providerCost: nil, + sakanaPayAsYouGo: self.payAsYouGo, updatedAt: self.updatedAt, identity: identity) } } +/// Sakana "Pay as you go" tab data (prepaid credit balance + a rolling usage total for the +/// console's selected date range). Fetched best-effort alongside the subscription quota windows; +/// absence never fails the primary Sakana fetch. +public struct SakanaPayAsYouGoSnapshot: Codable, Equatable, Sendable { + public let creditBalance: Double + public let periodUsageTotal: Double? + /// Raw label from the console's date-range picker (e.g. "Jun 02, 2026 - Jul 01, 2026"). + public let periodLabel: String? + + public init( + creditBalance: Double, + periodUsageTotal: Double? = nil, + periodLabel: String? = nil) + { + self.creditBalance = creditBalance + self.periodUsageTotal = periodUsageTotal + self.periodLabel = periodLabel + } + + public var balanceDetail: String { + UsageFormatter.usdString(self.creditBalance) + } +} + public enum SakanaUsageError: LocalizedError, Sendable, Equatable { case missingCookie case loginRequired @@ -89,8 +117,27 @@ public enum SakanaUsageError: LocalizedError, Sendable, Equatable { } } +private final class SakanaPayAsYouGoResult: @unchecked Sendable { + private let lock = NSLock() + private var result: SakanaPayAsYouGoSnapshot? + + func complete(with result: SakanaPayAsYouGoSnapshot?) { + self.lock.withLock { + self.result = result + } + } + + func valueIfCompleted() -> SakanaPayAsYouGoSnapshot? { + self.lock.withLock { self.result } + } +} + public enum SakanaUsageFetcher { private static let billingURL = URL(string: "https://console.sakana.ai/billing")! + private static let payAsYouGoURL = URL(string: "https://console.sakana.ai/billing?tab=payAsYouGo")! + /// Optional enrichment gets a small shared budget from the start of the primary request. A slow + /// primary therefore never waits, while a fast primary can briefly collect an in-flight result. + private static let payAsYouGoEnrichmentBudget: Duration = .milliseconds(200) private static let defaultTransport: ProviderHTTPClient = { let configuration = URLSessionConfiguration.ephemeral configuration.httpCookieStorage = nil @@ -103,7 +150,8 @@ public enum SakanaUsageFetcher { cookieHeader: String, session transportOverride: (any ProviderHTTPTransport)? = nil, timeout: TimeInterval = 15, - now: Date = Date()) async throws -> SakanaUsageSnapshot + now: Date = Date(), + includeOptionalUsage: Bool = true) async throws -> SakanaUsageSnapshot { guard let cookieHeader = CookieHeaderNormalizer.normalize(cookieHeader) else { throw SakanaUsageError.missingCookie @@ -117,22 +165,185 @@ public enum SakanaUsageFetcher { request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") let transport = transportOverride ?? self.defaultTransport - let response = try await transport.response(for: request) - if response.statusCode == 401 || response.statusCode == 403 || (300..<400).contains(response.statusCode) { - throw SakanaUsageError.loginRequired + let fetchStartedAt = ContinuousClock.now + let payAsYouGoResult = includeOptionalUsage ? SakanaPayAsYouGoResult() : nil + let payAsYouGoTask: Task? = if let payAsYouGoResult { + Task { + let result = await self.boundedFetchPayAsYouGo( + cookieHeader: cookieHeader, + transport: transport, + timeout: timeout) + payAsYouGoResult.complete(with: result) + } + } else { + nil + } + + return try await withTaskCancellationHandler { + do { + let response = try await transport.response(for: request) + if response.statusCode == 401 || response.statusCode == 403 || + (300..<400).contains(response.statusCode) + { + throw SakanaUsageError.loginRequired + } + guard response.response.url?.scheme?.lowercased() == "https", + response.response.url?.host?.lowercased() == self.billingURL.host?.lowercased() + else { + throw SakanaUsageError.loginRequired + } + guard response.statusCode == 200 else { + throw SakanaUsageError.apiError(response.statusCode) + } + guard let html = String(data: response.data, encoding: .utf8), !html.isEmpty else { + throw SakanaUsageError.parseFailed("Billing page response was empty.") + } + let snapshot = try self.parseBillingHTML(html, now: now) + let payAsYouGo = await self.collectPayAsYouGo( + task: payAsYouGoTask, + result: payAsYouGoResult, + fetchStartedAt: fetchStartedAt) + try Task.checkCancellation() + return SakanaUsageSnapshot( + planName: snapshot.planName, + priceLabel: snapshot.priceLabel, + fiveHour: snapshot.fiveHour, + weekly: snapshot.weekly, + payAsYouGo: payAsYouGo, + updatedAt: snapshot.updatedAt) + } catch { + payAsYouGoTask?.cancel() + throw error + } + } onCancel: { + payAsYouGoTask?.cancel() + } + } + + private static func collectPayAsYouGo( + task: Task?, + result: SakanaPayAsYouGoResult?, + fetchStartedAt: ContinuousClock.Instant) async -> SakanaPayAsYouGoSnapshot? + { + guard let task, let result else { return nil } + let elapsed = fetchStartedAt.duration(to: .now) + let remainingBudget = elapsed < self.payAsYouGoEnrichmentBudget + ? self.payAsYouGoEnrichmentBudget - elapsed + : .zero + if remainingBudget > .zero { + let join = BoundedTaskJoin(sourceTask: task) + _ = await join.value(joinGrace: remainingBudget) + } + task.cancel() + return result.valueIfCompleted() + } + + /// Caps the lifetime of the optional Pay-as-you-go fetch. The primary fetch only consumes an + /// already-completed or shared-budget result and cancels this task otherwise. + private static let payAsYouGoJoinGrace: Duration = .seconds(5) + + private static func boundedFetchPayAsYouGo( + cookieHeader: String, + transport: any ProviderHTTPTransport, + timeout: TimeInterval) async -> SakanaPayAsYouGoSnapshot? + { + await self.boundedFetch(timeout: self.payAsYouGoJoinGrace) { + await self.fetchPayAsYouGo(cookieHeader: cookieHeader, transport: transport, timeout: timeout) + } + } + + static func _boundedFetchPayAsYouGoForTesting( + timeout: Duration, + operation: @escaping @Sendable () async -> SakanaPayAsYouGoSnapshot?) async -> SakanaPayAsYouGoSnapshot? + { + await self.boundedFetch(timeout: timeout, operation: operation) + } + + private static func boundedFetch( + timeout: Duration, + operation: @escaping @Sendable () async -> SakanaPayAsYouGoSnapshot?) async -> SakanaPayAsYouGoSnapshot? + { + let sourceTask = Task { + await operation() } - guard response.response.url?.scheme?.lowercased() == "https", - response.response.url?.host?.lowercased() == self.billingURL.host?.lowercased() + let race = BoundedTaskJoin(sourceTask: sourceTask) + switch await race.value(joinGrace: timeout) { + case let .value(result): + return result + case .timedOut, .failure: + return nil + } + } + + /// Best-effort fetch of the Pay-as-you-go tab. Never throws: subscription quota windows are + /// the primary, historically-supported contract of this fetcher, and an account without PAYG + /// credit (or a console change that breaks this parser) must not regress that core behavior. + private static func fetchPayAsYouGo( + cookieHeader: String, + transport: any ProviderHTTPTransport, + timeout: TimeInterval) async -> SakanaPayAsYouGoSnapshot? + { + var request = URLRequest(url: self.payAsYouGoURL) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + + guard let response = try? await transport.response(for: request), + response.statusCode == 200, + response.response.url?.scheme?.lowercased() == "https", + response.response.url?.host?.lowercased() == self.billingURL.host?.lowercased(), + let html = String(data: response.data, encoding: .utf8), !html.isEmpty else { - throw SakanaUsageError.loginRequired + return nil } - guard response.statusCode == 200 else { - throw SakanaUsageError.apiError(response.statusCode) + return self.parsePayAsYouGoHTML(html) + } + + static func parsePayAsYouGoHTML(_ html: String) -> SakanaPayAsYouGoSnapshot? { + guard let balanceText = self.capture( + pattern: #"]*>\s*Credit balance\s*[\s\S]{0,900}?]*tabular-nums[^"]*"[^>]*>"# + + #"\$?([0-9][0-9,]*(?:\.[0-9]+)?)

"#, + in: html), + let creditBalance = self.parseAmount(balanceText) + else { + return nil } - guard let html = String(data: response.data, encoding: .utf8), !html.isEmpty else { - throw SakanaUsageError.parseFailed("Billing page response was empty.") + + let usageTotalText = self.capture( + pattern: #"]*>\s*Usage\s*\s*]*>\s*Total(?:)?:\s*"# + + #"(?:)?\$?([0-9][0-9,]*(?:\.[0-9]+)?)\s*"#, + in: html) + let periodUsageTotal = usageTotalText.flatMap(self.parseAmount) + + let periodLabel = self.capture( + pattern: #"aria-label="Usage date range"[^>]*>([\s\S]*?)"#, + in: html).map(self.stripHTMLComments) + + return SakanaPayAsYouGoSnapshot( + creditBalance: creditBalance, + periodUsageTotal: periodUsageTotal, + periodLabel: periodLabel) + } + + private static func parseAmount(_ text: String) -> Double? { + guard let value = Double(text.replacingOccurrences(of: ",", with: "")), value.isFinite else { + return nil } - return try self.parseBillingHTML(html, now: now) + return value + } + + /// Strips React's `` hydration boundary comments (inserted between separately + /// interpolated JSX text nodes) and collapses the remaining whitespace. + private static func stripHTMLComments(_ text: String) -> String { + let stripped = text.replacingOccurrences( + of: #""#, + with: "", + options: .regularExpression) + return stripped + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) } static func parseBillingHTML( diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index b2ac31f973..ef6a0ee3b3 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -184,6 +184,7 @@ public struct UsageSnapshot: Codable, Sendable { public let deepseekUsage: DeepSeekUsageSummary? public let mimoUsage: MiMoUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? + public let sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? public let crossModelUsage: CrossModelUsageSnapshot? public let clawRouterUsage: ClawRouterUsageSnapshot? public let openAIAPIUsage: OpenAIAPIUsageSnapshot? @@ -215,6 +216,7 @@ public struct UsageSnapshot: Codable, Sendable { case ampUsage case mimoUsage case openRouterUsage + case sakanaPayAsYouGo case crossModelUsage case clawRouterUsage case openAIAPIUsage @@ -246,6 +248,7 @@ public struct UsageSnapshot: Codable, Sendable { deepseekUsage: DeepSeekUsageSummary? = nil, mimoUsage: MiMoUsageSnapshot? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, + sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? = nil, crossModelUsage: CrossModelUsageSnapshot? = nil, clawRouterUsage: ClawRouterUsageSnapshot? = nil, openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil, @@ -276,6 +279,7 @@ public struct UsageSnapshot: Codable, Sendable { self.deepseekUsage = deepseekUsage self.mimoUsage = mimoUsage self.openRouterUsage = openRouterUsage + self.sakanaPayAsYouGo = sakanaPayAsYouGo self.crossModelUsage = crossModelUsage self.clawRouterUsage = clawRouterUsage self.openAIAPIUsage = openAIAPIUsage @@ -323,6 +327,9 @@ public struct UsageSnapshot: Codable, Sendable { self.deepseekUsage = nil // Not persisted, fetched fresh each time self.mimoUsage = try container.decodeIfPresent(MiMoUsageSnapshot.self, forKey: .mimoUsage) self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) + self.sakanaPayAsYouGo = try container.decodeIfPresent( + SakanaPayAsYouGoSnapshot.self, + forKey: .sakanaPayAsYouGo) self.crossModelUsage = try container.decodeIfPresent(CrossModelUsageSnapshot.self, forKey: .crossModelUsage) self.clawRouterUsage = try container.decodeIfPresent(ClawRouterUsageSnapshot.self, forKey: .clawRouterUsage) self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) @@ -377,6 +384,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encodeIfPresent(self.ampUsage, forKey: .ampUsage) try container.encodeIfPresent(self.mimoUsage, forKey: .mimoUsage) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) + try container.encodeIfPresent(self.sakanaPayAsYouGo, forKey: .sakanaPayAsYouGo) try container.encodeIfPresent(self.crossModelUsage, forKey: .crossModelUsage) try container.encodeIfPresent(self.clawRouterUsage, forKey: .clawRouterUsage) try container.encodeIfPresent(self.openAIAPIUsage, forKey: .openAIAPIUsage) @@ -557,6 +565,7 @@ public struct UsageSnapshot: Codable, Sendable { deepseekUsage: self.deepseekUsage, mimoUsage: self.mimoUsage, openRouterUsage: self.openRouterUsage, + sakanaPayAsYouGo: self.sakanaPayAsYouGo, crossModelUsage: self.crossModelUsage, clawRouterUsage: self.clawRouterUsage, openAIAPIUsage: self.openAIAPIUsage, diff --git a/Tests/CodexBarTests/MenuDescriptorSakanaTests.swift b/Tests/CodexBarTests/MenuDescriptorSakanaTests.swift new file mode 100644 index 0000000000..62a9304ea4 --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorSakanaTests.swift @@ -0,0 +1,128 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorSakanaTests { + @Test + func `sakana pay as you go rows render when optional usage is enabled`() throws { + let lines = try Self.menuLines(showOptionalUsage: true) + + #expect(lines.contains("Balance: $12.34")) + #expect(lines.contains("Usage: $5.67")) + } + + @Test + func `sakana pay as you go rows are hidden when optional usage is disabled`() throws { + // Regression for the render-path staleness gap: toggling "Show optional credits and extra + // usage" off only rebuilds the menu, it does not immediately refetch, so a + // previously-populated sakanaPayAsYouGo lingers in the cached snapshot. The rows must be + // gated on the setting, not only on the presence of the (possibly stale) snapshot field. + let lines = try Self.menuLines(showOptionalUsage: false) + + #expect(!lines.contains(where: { $0.hasPrefix("Balance:") })) + #expect(!lines.contains(where: { $0.hasPrefix("Usage:") })) + // The required quota windows must still render regardless of the optional-usage setting. + #expect(lines.contains(where: { $0.hasPrefix("5-hour") })) + #expect(lines.contains(where: { $0.hasPrefix("Weekly") })) + } + + private static func menuLines(showOptionalUsage: Bool) throws -> [String] { + let suite = "MenuDescriptorSakanaTests-\(showOptionalUsage)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.showOptionalCreditsAndExtraUsage = showOptionalUsage + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = SakanaUsageSnapshot( + planName: "Standard", + priceLabel: "$20/mo", + fiveHour: .init(usedPercent: 10, resetsAt: nil), + weekly: .init(usedPercent: 20, resetsAt: nil), + payAsYouGo: SakanaPayAsYouGoSnapshot( + creditBalance: 12.34, + periodUsageTotal: 5.67, + periodLabel: "Jun 02, 2026 - Jul 01, 2026")) + store._setSnapshotForTesting(snapshot.toUsageSnapshot(), provider: .sakana) + + let descriptor = MenuDescriptor.build( + provider: .sakana, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + return descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + } +} + +struct SakanaMenuCardModelTests { + @Test + func `pay as you go renders in the live menu card`() throws { + let model = try Self.model(showOptionalUsage: true) + + #expect(model.providerCost?.title == "Extra usage") + #expect(model.providerCost?.spendLine == "Balance: $12.34") + #expect(model.providerCost?.percentLine == "Usage: $5.67") + #expect(model.providerCost?.percentUsed == nil) + } + + @Test + func `pay as you go hides immediately when optional usage is disabled`() throws { + let model = try Self.model(showOptionalUsage: false) + + #expect(model.providerCost == nil) + #expect(model.metrics.map(\.title) == ["5-hour", "Weekly"]) + } + + private static func model(showOptionalUsage: Bool) throws -> UsageMenuCardView.Model { + let now = Date(timeIntervalSince1970: 0) + let snapshot = SakanaUsageSnapshot( + planName: "Standard", + priceLabel: "$20/mo", + fiveHour: .init(usedPercent: 10, resetsAt: nil), + weekly: .init(usedPercent: 20, resetsAt: nil), + payAsYouGo: SakanaPayAsYouGoSnapshot( + creditBalance: 12.34, + periodUsageTotal: 5.67, + periodLabel: "Jun 02, 2026 - Jul 01, 2026"), + updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.sakana]) + + return UsageMenuCardView.Model.make(.init( + provider: .sakana, + metadata: metadata, + snapshot: snapshot.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: showOptionalUsage, + hidePersonalInfo: false, + now: now)) + } +} diff --git a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift index 761fbc39a3..9292635d83 100644 --- a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift @@ -35,7 +35,8 @@ struct SakanaUsageFetcherTests { cookieHeader: "Cookie: session=abc; theme=dark", session: transport, now: Date(timeIntervalSince1970: 0)) - let request = await transport.lastCapturedRequest() + let requests = await transport.capturedRequestsSnapshot() + let request = requests.first { $0.url == "https://console.sakana.ai/billing" } #expect(snapshot.fiveHour?.usedPercent == 92) #expect(request?.url == "https://console.sakana.ai/billing") @@ -44,12 +45,165 @@ struct SakanaUsageFetcherTests { #expect(request?.acceptLanguage == "en-US,en;q=0.9") } + @Test + func `fetches pay as you go concurrently and merges the credit balance`() async throws { + let transport = SakanaScriptedTransport( + statusCode: 200, + body: Self.billingHTML, + overridesByURL: [ + "https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML), + ], + billingWaitsForPayAsYouGo: true) + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=abc", + session: transport, + now: Date(timeIntervalSince1970: 0)) + let requests = await transport.capturedRequestsSnapshot() + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(snapshot.payAsYouGo?.creditBalance == 12.34) + #expect(snapshot.payAsYouGo?.periodUsageTotal == 5.67) + #expect(snapshot.payAsYouGo?.periodLabel == "Jun 02, 2026 - Jul 01, 2026") + #expect(requests.count == 2) + let payAsYouGoRequest = requests.first { $0.url == "https://console.sakana.ai/billing?tab=payAsYouGo" } + #expect(payAsYouGoRequest?.cookie == "session=abc") + + let usage = snapshot.toUsageSnapshot() + #expect(usage.sakanaPayAsYouGo?.balanceDetail == "$12.34") + } + + @Test + func `quick pay as you go response can finish after primary within the shared budget`() async throws { + let transport = SakanaScriptedTransport( + statusCode: 200, + body: Self.billingHTML, + overridesByURL: [ + "https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML), + ], + payAsYouGoDelay: .milliseconds(20)) + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=abc", + session: transport, + now: Date(timeIntervalSince1970: 0)) + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(snapshot.payAsYouGo?.creditBalance == 12.34) + } + + @Test + func `fetch skips the pay as you go request entirely when optional usage is disabled`() async throws { + let transport = SakanaScriptedTransport( + statusCode: 200, + body: Self.billingHTML, + overridesByURL: [ + "https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML), + ]) + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=abc", + session: transport, + now: Date(timeIntervalSince1970: 0), + includeOptionalUsage: false) + let requests = await transport.capturedRequestsSnapshot() + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(snapshot.payAsYouGo == nil) + // Only the required subscription-quota request is made; disabling optional usage must not + // just discard the PAYG result, it must skip the network request entirely. + #expect(requests.count == 1) + #expect(requests.first?.url == "https://console.sakana.ai/billing") + } + + @Test + func `pay as you go bounded fetch does not wait for an operation that ignores cancellation`() async throws { + let startedAt = ContinuousClock.now + + let fetched = await SakanaUsageFetcher._boundedFetchPayAsYouGoForTesting(timeout: .milliseconds(20)) { + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: SakanaPayAsYouGoSnapshot(creditBalance: 9)) + } + } + } + + let elapsed = startedAt.duration(to: .now) + #expect(fetched == nil) + #expect(elapsed < .milliseconds(300)) + + try await Task.sleep(for: .milliseconds(550)) + } + + @Test + func `fetch tolerates a failing pay as you go request without failing the primary fetch`() async throws { + // Default response is a 500; only the primary billing URL is overridden to succeed, so the + // pay-as-you-go request (not present in the override map) falls through to that failure. + let transport = SakanaScriptedTransport( + statusCode: 500, + body: "boom", + overridesByURL: [ + "https://console.sakana.ai/billing": (200, Self.billingHTML), + ]) + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=abc", + session: transport, + now: Date(timeIntervalSince1970: 0)) + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(snapshot.payAsYouGo == nil) + } + + @Test + func `slow pay as you go request never delays the primary quota result`() async throws { + let transport = SakanaScriptedTransport( + statusCode: 200, + body: Self.billingHTML, + billingWaitsForPayAsYouGo: true, + payAsYouGoBlocksUntilCancelled: true) + let startedAt = ContinuousClock.now + + let snapshot = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=abc", + session: transport, + now: Date(timeIntervalSince1970: 0)) + + #expect(snapshot.fiveHour?.usedPercent == 92) + #expect(snapshot.payAsYouGo == nil) + #expect(startedAt.duration(to: .now) < .milliseconds(500)) + for _ in 0..<1000 where await !(transport.didCancelPayAsYouGo()) { + await Task.yield() + } + #expect(await transport.didCancelPayAsYouGo()) + } + + @Test + func `required fetch failure cancels the concurrent pay as you go request`() async throws { + let transport = SakanaScriptedTransport( + statusCode: 401, + body: "expired", + billingWaitsForPayAsYouGo: true, + payAsYouGoBlocksUntilCancelled: true) + + await #expect(throws: SakanaUsageError.loginRequired) { + _ = try await SakanaUsageFetcher.fetchUsage( + cookieHeader: "session=expired", + session: transport) + } + + for _ in 0..<1000 where await !(transport.didCancelPayAsYouGo()) { + await Task.yield() + } + #expect(await transport.didCancelPayAsYouGo()) + } + @Test func `fetch rejects cross origin login redirect`() async throws { let transport = try SakanaScriptedTransport( statusCode: 200, body: Self.billingHTML, - responseURL: #require(URL(string: "https://auth.sakana.ai/login"))) + responseURL: #require(URL(string: "https://auth.sakana.ai")?.appending(path: "login"))) await #expect(throws: SakanaUsageError.loginRequired) { _ = try await SakanaUsageFetcher.fetchUsage( @@ -165,6 +319,49 @@ struct SakanaUsageFetcherTests { #expect(usage.primary?.resetsAt?.timeIntervalSince1970 == 1_782_226_380) } + @Test + func `pay as you go html maps credit balance usage total and date range label`() { + let usage = SakanaUsageFetcher.parsePayAsYouGoHTML(Self.payAsYouGoHTML) + + #expect(usage?.creditBalance == 12.34) + #expect(usage?.periodUsageTotal == 5.67) + #expect(usage?.periodLabel == "Jun 02, 2026 - Jul 01, 2026") + #expect(usage?.balanceDetail == "$12.34") + } + + @Test + func `pay as you go html without usage total still maps credit balance`() { + let html = Self.payAsYouGoHTML.replacing( + "Total: $5.67", + with: "") + + let usage = SakanaUsageFetcher.parsePayAsYouGoHTML(html) + + #expect(usage?.creditBalance == 12.34) + #expect(usage?.periodUsageTotal == nil) + } + + @Test + func `billing html without a pay as you go tab returns nil`() { + #expect(SakanaUsageFetcher.parsePayAsYouGoHTML(Self.billingHTML) == nil) + } + + @Test + func `sakana usage snapshot carries pay as you go through to the usage snapshot mapping`() { + let payAsYouGo = SakanaPayAsYouGoSnapshot(creditBalance: 9, periodUsageTotal: 1.5, periodLabel: "Last 30 days") + let snapshot = SakanaUsageSnapshot( + planName: "Standard", + priceLabel: "$20/mo", + fiveHour: .init(usedPercent: 10, resetsAt: nil), + weekly: .init(usedPercent: 20, resetsAt: nil), + payAsYouGo: payAsYouGo) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.sakanaPayAsYouGo?.creditBalance == 9) + #expect(usage.sakanaPayAsYouGo?.balanceDetail == "$9.00") + } + private static func date(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date? { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "UTC")! @@ -191,6 +388,20 @@ struct SakanaUsageFetcherTests {

32% used

""" + + /// Minimal reproduction of the "Pay as you go" tab, which the live console only server-renders + /// when the request includes `?tab=payAsYouGo`. The `` markers reproduce React's + /// hydration-boundary comments between separately interpolated JSX text nodes. + private static let payAsYouGoHTML = """ +
+

Credit balance

+ +

$12.34

+ +

Usage

+ Total: $5.67 +
+ """ } private actor SakanaScriptedTransport: ProviderHTTPTransport { @@ -205,35 +416,118 @@ private actor SakanaScriptedTransport: ProviderHTTPTransport { private let body: String private let responseURL: URL? private let headers: [String: String] - private var capturedRequest: CapturedRequest? + /// Per-URL response overrides (keyed by the full request URL string), used to stub the + /// subscription-tab and pay-as-you-go-tab requests independently. Falls back to + /// `(statusCode, body)` for any URL not present here. + private let overridesByURL: [String: (statusCode: Int, body: String)] + private let billingWaitsForPayAsYouGo: Bool + private let payAsYouGoBlocksUntilCancelled: Bool + private let payAsYouGoDelay: Duration? + private var capturedRequests: [CapturedRequest] = [] + private var payAsYouGoStarted = false + private var payAsYouGoCompleted = false + private var payAsYouGoWasCancelled = false + private var payAsYouGoStartWaiters: [CheckedContinuation] = [] + private var payAsYouGoCompletionWaiters: [CheckedContinuation] = [] init( statusCode: Int, body: String, responseURL: URL? = nil, - headers: [String: String] = [:]) + headers: [String: String] = [:], + overridesByURL: [String: (statusCode: Int, body: String)] = [:], + billingWaitsForPayAsYouGo: Bool = false, + payAsYouGoBlocksUntilCancelled: Bool = false, + payAsYouGoDelay: Duration? = nil) { self.statusCode = statusCode self.body = body self.responseURL = responseURL self.headers = headers + self.overridesByURL = overridesByURL + self.billingWaitsForPayAsYouGo = billingWaitsForPayAsYouGo + self.payAsYouGoBlocksUntilCancelled = payAsYouGoBlocksUntilCancelled + self.payAsYouGoDelay = payAsYouGoDelay } func lastCapturedRequest() -> CapturedRequest? { - self.capturedRequest + self.capturedRequests.last + } + + func capturedRequestsSnapshot() -> [CapturedRequest] { + self.capturedRequests } - func data(for request: URLRequest) throws -> (Data, URLResponse) { - self.capturedRequest = CapturedRequest( + func didCancelPayAsYouGo() -> Bool { + self.payAsYouGoWasCancelled + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let isPayAsYouGo = request.url?.query == "tab=payAsYouGo" + if isPayAsYouGo { + self.markPayAsYouGoStarted() + if let payAsYouGoDelay { + try await Task.sleep(for: payAsYouGoDelay) + } + if self.payAsYouGoBlocksUntilCancelled { + do { + try await Task.sleep(for: .seconds(30)) + } catch { + self.payAsYouGoWasCancelled = true + throw error + } + } + } else if self.billingWaitsForPayAsYouGo { + await self.waitForPayAsYouGoStart() + if !self.payAsYouGoBlocksUntilCancelled { + await self.waitForPayAsYouGoCompletion() + } + } + + self.capturedRequests.append(CapturedRequest( url: request.url?.absoluteString, method: request.httpMethod, cookie: request.value(forHTTPHeaderField: "Cookie"), - acceptLanguage: request.value(forHTTPHeaderField: "Accept-Language")) + acceptLanguage: request.value(forHTTPHeaderField: "Accept-Language"))) + + let override = request.url.flatMap { self.overridesByURL[$0.absoluteString] } + let (responseStatusCode, responseBody) = override ?? (self.statusCode, self.body) let response = HTTPURLResponse( url: self.responseURL ?? request.url!, - statusCode: self.statusCode, + statusCode: responseStatusCode, httpVersion: "HTTP/1.1", headerFields: self.headers)! - return (Data(self.body.utf8), response) + if isPayAsYouGo { + self.markPayAsYouGoCompleted() + } + return (Data(responseBody.utf8), response) + } + + private func waitForPayAsYouGoStart() async { + guard !self.payAsYouGoStarted else { return } + await withCheckedContinuation { continuation in + self.payAsYouGoStartWaiters.append(continuation) + } + } + + private func waitForPayAsYouGoCompletion() async { + guard !self.payAsYouGoCompleted else { return } + await withCheckedContinuation { continuation in + self.payAsYouGoCompletionWaiters.append(continuation) + } + } + + private func markPayAsYouGoStarted() { + self.payAsYouGoStarted = true + let waiters = self.payAsYouGoStartWaiters + self.payAsYouGoStartWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + private func markPayAsYouGoCompleted() { + self.payAsYouGoCompleted = true + let waiters = self.payAsYouGoCompletionWaiters + self.payAsYouGoCompletionWaiters.removeAll() + waiters.forEach { $0.resume() } } } diff --git a/docs/providers.md b/docs/providers.md index 4bbc1b2c1f..9916fedda3 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -57,7 +57,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | Perplexity | Browser cookies/manual cookie/env session token → credits API (`web`). | | Xiaomi MiMo | Browser cookies → balance/token plan endpoints (`web`). | | Doubao | API key from config/env → Volcengine Ark chat-completions probe (`api`). | -| Sakana AI | Manual Cookie header → billing page parser for 5-hour and weekly quota windows (`web`). | +| Sakana AI | Manual Cookie header → billing page parser for 5-hour/weekly quota windows plus a best-effort pay-as-you-go credit balance (`web`). | | Abacus AI | Browser cookies → compute points + billing API (`web`). | | Mistral | Console billing and Vibe subscription usage via browser cookies (`web`). | | DeepSeek | API key from env or token accounts → balance endpoint (`api`). | @@ -337,6 +337,8 @@ headers, source selection, provider ordering, and token accounts are stored in ` ## Sakana AI - Manual `Cookie:` header from `console.sakana.ai`; no automatic browser import. - Reads the billing page and surfaces 5-hour and weekly quota windows when present. +- Also fetches the pay-as-you-go tab (best-effort, never fails the primary quota fetch) for a + `fugu`/`fugu-ultra` prepaid credit balance and a rolling usage total. - Status: none yet. - Details: `docs/sakana.md`. diff --git a/docs/sakana.md b/docs/sakana.md index 2af31db60d..2dbdad267c 100644 --- a/docs/sakana.md +++ b/docs/sakana.md @@ -1,9 +1,10 @@ --- -summary: "Sakana AI provider: manual Cookie header, billing page parser, 5-hour and weekly quota windows." +summary: "Sakana AI provider: manual Cookie header, billing page parser, 5-hour/weekly quota windows, and pay-as-you-go credit balance." read_when: - Adding or modifying the Sakana AI provider - Debugging Sakana AI cookie import or quota parsing - Adjusting Sakana AI menu labels or reset window display + - Debugging Sakana pay-as-you-go credit balance or usage-total parsing --- # Sakana AI @@ -43,10 +44,52 @@ Alternatively, set the environment variable `SAKANA_COOKIE` to the raw cookie he `"MMMM d, yyyy 'at' h:mm a"` format strings. - Plan name and price label (e.g. `Standard $20/mo`) are joined and surfaced as the `loginMethod` identity field for plan display in the menu. -- Token cost tracking (`supportsTokenCost: false`): not supported; cost summary is unavailable. -- Credits row (`supportsCredits: false`): not shown. +- Token cost tracking (`supportsTokenCost: false`): not supported; cost summary is unavailable. Sakana has no + organization-level usage/cost API to query historically, only the per-request `usage` object returned by chat + completions calls (which CodexBar never makes), so there is no local-log source to scan the way Claude/Codex are. +- Credits row (`supportsCredits: false`): not shown. The shared credits-card UI path (`MenuCardView+Costs.swift`) + has no Sakana branch and would just render the static `creditsHint` string instead of the fetched balance, so + `supportsCredits` stays off; the balance is surfaced explicitly instead (see below). - Widget support: not currently available for Sakana AI. +## Pay-as-you-go credits + +Sakana also sells prepaid credit for pay-as-you-go API usage (the model IDs `fugu` and `fugu-ultra`), separate from +the subscription quota windows above. `console.sakana.ai/billing` renders this data server-side under its +"Pay as you go" tab, but that tab's markup is only present in the HTML response when the request URL includes +`?tab=payAsYouGo` — the default `/billing` response (used for the subscription quota fetch above) does not include +it. CodexBar issues a **second, best-effort** GET to `https://console.sakana.ai/billing?tab=payAsYouGo` with the same +cookie header alongside the subscription request — skipped entirely (no request made) when +`context.includeOptionalUsage` is `false`, i.e. Settings → Advanced → "Show optional credits and extra usage" is +disabled. + +- **Credit balance**: parsed from the `

Credit balance

` card's adjacent `tabular-nums` amount. +- **Recent usage total**: parsed from the `Usage` chart header's `Total: $…` text, covering whatever date range is + currently selected on the console (defaults to the last 30 days). React renders this text with `` + hydration-boundary comments splitting the label from the amount; the parser strips those before reading the value. +- **Date range label**: the raw text of the "Usage date range" picker button (e.g. `Jun 02, 2026 - Jul 01, 2026`), + kept only as context — CodexBar does not currently interpret it as start/end dates. + +This second fetch never throws and never blocks the primary result: if it fails (network error, non-200, wrong +origin, empty body, or the expected markup isn't found), the pay-as-you-go fields are simply absent from that +refresh and the subscription quota windows are returned exactly as before. An account with no pay-as-you-go credit +purchased still returns a `$0.00` balance (the card is always rendered), so absence here almost always means the +request itself failed rather than "no credit." + +The optional request runs concurrently with the required subscription request and has its own five-second bound. +It is cancelled when the required request fails or the caller cancels, so it cannot add a second full request +timeout or outlive the refresh that started it. + +- Menu: an `Extra usage` card shows `Balance: $X.XX` and, when available, `Usage: $X.XX` alongside the quota windows. + The values are gated on Settings → Advanced → "Show optional credits and extra usage" at **both** + the fetch and the render layer: turning the setting off only rebuilds the menu without an immediate refetch, so a + previously-fetched balance would otherwise linger in the cached snapshot until the next refresh. Both the live + menu-card model and the text descriptor hide the values whenever the setting is off, independent of cached data. +- Not shown in the menu bar text: unlike some other credits-only providers, Sakana already has real 5-hour/weekly + rate windows, and the "secondary metric" preference that would otherwise select an alternate menu bar display is + the legitimate way to show the *weekly* window there. Reusing it for the PAYG balance would silently replace the + weekly percentage for anyone who picks that preference, so the balance is menu-only for now. + ## CLI usage ``` @@ -75,9 +118,13 @@ There is no `codexbar config set` command for `cookieHeader`; use one of the pat - `Sources/CodexBarCore/Providers/Sakana/` - `SakanaProviderDescriptor.swift` — provider metadata, fetch plan, CLI config - `SakanaSettingsReader.swift` — `SAKANA_COOKIE` env key, cookie normalizer - - `SakanaUsageFetcher.swift` — billing-page HTML fetch and quota parser + - `SakanaUsageFetcher.swift` — billing-page HTML fetch and quota parser; also defines + `SakanaPayAsYouGoSnapshot` and the pay-as-you-go tab fetch/parser - `Sources/CodexBar/Providers/Sakana/` - `SakanaProviderImplementation.swift` — settings UI, availability check - `SakanaSettingsStore.swift` — `sakanaCookieHeader` settings binding +- `Sources/CodexBar/MenuCardView+Costs.swift` — live menu-card balance and usage section +- `Sources/CodexBar/MenuDescriptor.swift` — text-descriptor balance and usage rows - `Tests/CodexBarTests/SakanaUsageFetcherTests.swift` — parser regression tests -- Dashboard: `https://console.sakana.ai/billing` +- Dashboard: `https://console.sakana.ai/billing` (subscription tab), `https://console.sakana.ai/billing?tab=payAsYouGo` + (pay-as-you-go tab) From c15316406af130d9e36469fbb0dc4027e4db37bc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 1 Jul 2026 09:46:03 +0100 Subject: [PATCH 027/105] docs: define provider presentation precedence --- .../current-merged-menu-redacted.png | Bin 0 -> 17629 bytes ...-provider-presentation-and-focus-design.md | 240 ++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 docs/screenshots/current-merged-menu-redacted.png create mode 100644 docs/superpowers/specs/2026-07-01-provider-presentation-and-focus-design.md diff --git a/docs/screenshots/current-merged-menu-redacted.png b/docs/screenshots/current-merged-menu-redacted.png new file mode 100644 index 0000000000000000000000000000000000000000..d76087308be5e9ed3dd9c5983c9350ec69b17883 GIT binary patch literal 17629 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 iCI`^Y^fRn@8U24~1>We<<%Y2U0000T literal 0 HcmV?d00001 diff --git a/docs/superpowers/specs/2026-07-01-provider-presentation-and-focus-design.md b/docs/superpowers/specs/2026-07-01-provider-presentation-and-focus-design.md new file mode 100644 index 0000000000..7df76a0169 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-provider-presentation-and-focus-design.md @@ -0,0 +1,240 @@ +--- +summary: "Decision-ready design for provider pins beside the merged icon and focus-aware merged-icon selection." +read_when: + - Implementing separate provider icons while Merge Icons is enabled + - Implementing focus-aware provider presentation + - Changing merged-icon provider precedence or status-item layout +--- + +# Provider Presentation and Focus-Aware Icon — Design + +**Status:** proposed; owner signoff required +**Date:** 2026-07-01 +**Issues:** [#1167](https://github.com/steipete/CodexBar/issues/1167), +[#780](https://github.com/steipete/CodexBar/issues/780) + +## Decision summary + +Two opt-in controls should share one presentation policy: + +1. **Also show separately:** while Merge Icons is enabled, selected providers get stable provider status items in + addition to the merged item. +2. **Merged icon source:** choose `Current selection`, `Highest usage`, or `Frontmost provider app`. + +Provider pins affect status-item layout only. Frontmost-app matching affects the collapsed merged icon only. Neither +feature changes the selected menu tab, selected account, enabled providers, or Overview contents. + +Recommended first implementation: ship provider pins and privacy-safe native app matching as separate PRs after the +product choices below are approved. Terminal-session inference remains a later, separately reviewed opt-in. + +## Current behavior + +With multiple providers and Merge Icons enabled, CodexBar creates one merged status item and removes all provider +status items. `primaryProviderForUnifiedIcon()` chooses the merged icon from highest usage, the first Overview +provider, the selected menu provider, or the first enabled provider. Menu selection is persisted. + +![Current merged menu with synthetic, redacted data](../../screenshots/current-merged-menu-redacted.png) + +The current implementation spreads the binary merged/split assumption across status-item visibility, menu +attachment, icon animation, and observation signatures. Adding either request as another independent conditional +would let those paths disagree. + +## Goals + +- Let users pin important providers beside the merged icon without losing the unified menu. +- Let the collapsed merged icon follow a recognized, enabled frontmost provider app. +- Preserve explicit menu, account, and Overview selection. +- Keep provider data siloed and avoid new sensitive-data collection. +- Keep status-item identities stable across refreshes and focus changes. +- Use an event-driven monitor; no polling or idle battery cost. + +## Non-goals + +- Filtering providers out of the merged menu or Overview. +- A second provider-content subset; Overview selection remains the only content subset. +- Switching provider accounts based on focus. +- Reading window titles, terminal scrollback, command lines, working directories, environment variables, prompts, or + shell history. +- Requiring Accessibility permission for the native-app MVP. +- Inferring a terminal tab's provider in the first implementation. + +## User model + +### Provider pins + +Preferences → Display → Menu Bar gains **Also show separately** when Merge Icons is enabled and at least two +providers are enabled. Its Configure popover lists enabled providers in provider order. + +- A pinned provider gets its own icon and provider menu. +- The provider remains in the merged switcher and Overview eligibility. +- The merged icon remains visible even when every enabled provider is pinned. +- Turning Merge Icons off restores existing all-separate behavior. Pins remain stored but dormant. +- Disabling a pinned provider hides its separate icon but preserves the pin for a later re-enable. + +Pins are additive, not subtractive. This avoids a provider silently disappearing from the unified menu and keeps one +consistent place for cross-provider comparison. + +### Merged icon source + +Replace the current highest-usage toggle with a picker: + +| Choice | Collapsed merged icon | Menu opens on | +| --- | --- | --- | +| Current selection | Existing resolver | Existing persisted selection or Overview | +| Highest usage | Existing closest-to-limit resolver | Existing persisted selection or Overview | +| Frontmost provider app | Recognized enabled provider; otherwise existing resolver | Existing persisted selection or Overview | + +The existing `menuBarShowsHighestUsage = true` preference migrates to `highestUsage`; false migrates to +`currentSelection`. No new provider filter is introduced. This avoids overlap with #1781 and preserves the existing +Overview provider subset. + +## Presentation policy + +Introduce a pure model consumed by status-item lifecycle, menus, icons, animation, and tests: + +```swift +struct StatusItemLayout: Equatable, Sendable { + let showsMergedItem: Bool + let separateProviders: [UsageProvider] +} + +struct UnifiedIconContext: Equatable, Sendable { + let source: UnifiedIconSource + let focusedProvider: UsageProvider? + let isMergedMenuOpen: Bool +} +``` + +`StatusItemLayout.resolve(...)` takes provider order, enabled providers, `mergeIcons`, stored pins, and fallback state. +It returns identities only; it never creates AppKit objects. + +| State | Merged item | Separate items | +| --- | --- | --- | +| Merge off | hidden | all enabled providers, or existing fallback | +| Merge on, fewer than two displayable providers | existing single-provider behavior | existing provider/fallback item | +| Merge on, two or more displayable providers | visible | enabled pinned providers in provider order | + +`updateVisibility()`, `rebuildProviderStatusItems()`, `attachMenus()`, icon observation signatures, and animation all +consume the same resolved layout. No path independently derives merged versus split visibility. + +The merged item's content and animation inputs continue to include all enabled providers. Each separate provider item +uses only that provider. Existing render-signature caches prevent unchanged duplicate work. + +## Focus-aware resolution + +`FrontmostProviderMonitor` observes `NSWorkspace.didActivateApplicationNotification` and seeds itself from +`NSWorkspace.frontmostApplication` at startup. It publishes an in-memory `FocusMatch` only when a provider-owned +bundle-identifier mapping recognizes the application. + +Provider mappings belong to provider descriptors rather than a central switch. A mapping is eligible only when its +provider is enabled. Unknown or disabled apps produce no override. + +The frontmost match is an ephemeral input to unified-icon resolution: + +```text +merged menu open ───────────────► keep current menu presentation stable +frontmost mode + enabled match ─► render matched provider on collapsed merged item +otherwise ──────────────────────► run the existing resolver unchanged +``` + +Focus changes never write `selectedMenuProvider`, `mergedMenuLastSelectedWasOverview`, account selection, or provider +configuration. When the menu closes, the latest focus match can affect the next collapsed render. Activation bursts +are coalesced on the main actor; no timer runs while the frontmost app is unchanged. + +### Terminal boundary + +`NSWorkspace` can identify Terminal, iTerm, or Warp as the frontmost app, but not which tab or pane is active. Treating +the terminal application itself as one provider would be incorrect. + +If terminal inference is later approved, add provider-source adapters behind a second off-by-default preference. Each +adapter must return only a provider identity and confidence/source metadata. It must not retain or log raw terminal +content. Permission prompts, supported terminal applications, ambiguity behavior, and energy impact need their own +design and Tahoe proof before merge. + +## Settings and migration + +- `separateProvidersWhenMerged: [UsageProvider]`: normalized, de-duplicated provider order; unknown values ignored. +- `unifiedIconSource: UnifiedIconSource`: `currentSelection`, `highestUsage`, or `frontmostApp`. +- Read the legacy `menuBarShowsHighestUsage` value only when the new enum key is absent. +- Preserve pins for disabled providers; expose only enabled providers in the Configure popover. +- Turning features off does not delete their stored choices. + +## Lifecycle and privacy + +- Start `FrontmostProviderMonitor` only when `frontmostApp` is selected; stop and unregister when another source is + selected or the controller shuts down. +- Store only the current matched provider and match source in memory. +- Do not persist frontmost-app history. +- Logs may contain the resolved provider and source category, never titles, arguments, paths, or raw application data. +- Native bundle matching uses public process metadata and requires no new permission. + +## Failure and edge cases + +| Case | Behavior | +| --- | --- | +| Pinned provider disabled | Separate item removed; pin retained dormant | +| Provider order changes | Separate items follow new provider order with stable identities | +| Every enabled provider pinned | Merged item plus every pinned provider remain visible | +| Merge Icons disabled | Existing all-separate behavior; pin controls disabled | +| One displayable provider remains | Existing single-provider behavior; no duplicate merged/provider pair | +| Focused app unrecognized | Existing merged-icon resolver | +| Focused provider disabled | Existing merged-icon resolver | +| Focus changes while menu open | Menu and icon presentation stay stable until close | +| App terminates without another activation | Re-evaluate `frontmostApplication`; fall back if unmatched | +| Screen/menu-bar reconstruction | Recreate identities from `StatusItemLayout`; do not discard stored pins | + +## Testing + +### Pure model + +- Table-test `StatusItemLayout` for merge on/off, zero/one/many providers, every pin subset, provider reordering, + disabled pins, fallback state, and all-providers-pinned. +- Table-test unified-icon precedence for all three sources, menu-open suppression, disabled matches, unknown apps, + Overview selection, explicit provider selection, and missing snapshots. +- Test migration from `menuBarShowsHighestUsage` and round-trip normalization of provider pins. + +### Controller seams + +- Extend split lifecycle tests to prove stable status-item identities while pins, order, and enabled state change. +- Prove merged and pinned menus attach to the correct item and never share provider-only menu state. +- Prove animation and icon observation signatures include the resolved layout and focus match. +- Inject a fake workspace event source; do not activate real applications in unit tests. +- Prove monitor registration, coalescing, and shutdown without AppKit status-bar construction where possible. + +### Tahoe visual and performance proof + +Use a freshly packaged exact-head build in the macOS Tahoe VM: + +1. Verify one merged item plus the selected provider pins. +2. Toggle pins, provider enabled state, Merge Icons, and provider order; capture redacted screenshots. +3. Switch between two mapped native apps and an unmapped app; verify only the collapsed merged icon changes. +4. Open the menu during focus switches; verify the selected tab/account and Overview stay fixed. +5. Profile repeated focus changes and merged-menu scrolling. Require no persistent idle timer, no status-item churn, and + no regression from the native Overview scroller. + +Run focused model/controller suites, `make check`, and `make test`. Keep all live proof free of account identity, usage +values, and unpublished provider/model data. + +## Rollout + +1. **Provider pins PR:** settings, pure layout model, controller adoption, tests, Tahoe screenshots. +2. **Native focus PR:** enum migration, provider mappings, event monitor, collapsed-icon precedence, energy proof. +3. **Terminal adapters:** only after a separate product/privacy decision. + +Each implementation PR should remain independently reversible. Neither changes provider fetching or account state. + +## Owner decisions required + +| Decision | Recommendation | Alternative and cost | +| --- | --- | --- | +| Are pins additive? | Yes; pinned providers remain in merged content | Removing them fragments Overview/switcher semantics | +| Keep merged item when all providers are pinned? | Yes; explicit Merge Icons promise stays stable | Auto-hide makes layout change as the last pin toggles | +| Unified-icon setting shape | One three-choice picker | Multiple toggles create conflicting precedence | +| Focus effect | Collapsed merged icon only | Mutating selection makes focus fight explicit user state | +| First focus scope | Native provider apps only | Terminal inference adds privacy, permission, and ambiguity work | +| Terminal follow-up | Separate off-by-default design | Bundling it blocks the privacy-safe MVP | + +## Acceptance + +Owner approval of the six recommendations above makes both implementation slices product-clear. Until then this spec +is the bounded artifact; it intentionally changes no runtime behavior. From c0e864dda7465e0be689924b581265a84aeed505 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 11:37:02 +0100 Subject: [PATCH 028/105] docs: accept provider presentation design --- ...6-07-01-provider-presentation-and-focus-design.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-07-01-provider-presentation-and-focus-design.md b/docs/superpowers/specs/2026-07-01-provider-presentation-and-focus-design.md index 7df76a0169..d91356b639 100644 --- a/docs/superpowers/specs/2026-07-01-provider-presentation-and-focus-design.md +++ b/docs/superpowers/specs/2026-07-01-provider-presentation-and-focus-design.md @@ -8,7 +8,7 @@ read_when: # Provider Presentation and Focus-Aware Icon — Design -**Status:** proposed; owner signoff required +**Status:** accepted on 2026-07-04 **Date:** 2026-07-01 **Issues:** [#1167](https://github.com/steipete/CodexBar/issues/1167), [#780](https://github.com/steipete/CodexBar/issues/780) @@ -24,8 +24,8 @@ Two opt-in controls should share one presentation policy: Provider pins affect status-item layout only. Frontmost-app matching affects the collapsed merged icon only. Neither feature changes the selected menu tab, selected account, enabled providers, or Overview contents. -Recommended first implementation: ship provider pins and privacy-safe native app matching as separate PRs after the -product choices below are approved. Terminal-session inference remains a later, separately reviewed opt-in. +Implementation should ship provider pins and privacy-safe native app matching as separate PRs. Terminal-session +inference remains a later, separately reviewed opt-in. ## Current behavior @@ -223,7 +223,7 @@ values, and unpublished provider/model data. Each implementation PR should remain independently reversible. Neither changes provider fetching or account state. -## Owner decisions required +## Accepted owner decisions | Decision | Recommendation | Alternative and cost | | --- | --- | --- | @@ -236,5 +236,5 @@ Each implementation PR should remain independently reversible. Neither changes p ## Acceptance -Owner approval of the six recommendations above makes both implementation slices product-clear. Until then this spec -is the bounded artifact; it intentionally changes no runtime behavior. +The six recommendations above are approved. This spec is the bounded implementation contract; it intentionally +changes no runtime behavior. From 3db8772d87472db75b72d1c00945c74dc0cfc37a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 07:13:06 -0400 Subject: [PATCH 029/105] fix: include Dia for OpenCode cookie import (#1830) --- CHANGELOG.md | 1 + .../BrowserCookieAccessGate.swift | 7 +- .../OpenCode/OpenCodeProviderDescriptor.swift | 2 +- .../OpenCode/OpenCodeWebCookieSupport.swift | 7 +- .../CodexBarCore/Providers/Providers.swift | 10 +++ .../BrowserCookieOrderLabelTests.swift | 21 ++++- .../CodexBarTests/BrowserDetectionTests.swift | 86 +++++++++++++++++++ docs/opencode.md | 4 +- 8 files changed, 125 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f7be63c81..281b29e16a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Repository: reject oversized tracked blobs and generated release/build artifacts during checks. Thanks @joeVenner! ### Fixed +- 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 diff --git a/Sources/CodexBarCore/BrowserCookieAccessGate.swift b/Sources/CodexBarCore/BrowserCookieAccessGate.swift index 0265079706..479c6e1270 100644 --- a/Sources/CodexBarCore/BrowserCookieAccessGate.swift +++ b/Sources/CodexBarCore/BrowserCookieAccessGate.swift @@ -63,7 +63,7 @@ public enum BrowserCookieAccessGate { } guard shouldCheckKeychain else { return false } - let requiresInteraction = self.chromiumKeychainRequiresInteraction() + let requiresInteraction = self.chromiumKeychainRequiresInteraction(for: browser) return self.lock.withLock { state in self.loadIfNeeded(&state) if requiresInteraction { @@ -110,8 +110,9 @@ public enum BrowserCookieAccessGate { } } - private static func chromiumKeychainRequiresInteraction() -> Bool { - for label in self.safeStorageLabels { + private static func chromiumKeychainRequiresInteraction(for browser: Browser) -> Bool { + let labels = browser.safeStorageLabels.isEmpty ? self.safeStorageLabels : browser.safeStorageLabels + for label in labels { switch KeychainAccessPreflight.checkGenericPassword(service: label.service, account: label.account) { case .allowed: return false diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift index 2f62ba57cb..ae4e72a68c 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift @@ -20,7 +20,7 @@ public enum OpenCodeProviderDescriptor { defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, - browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + browserCookieOrder: ProviderBrowserCookieDefaults.opencodeCookieImportOrder, dashboardURL: "https://opencode.ai", statusPageURL: nil), branding: ProviderBranding( diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift index 5d239b08d0..463787233f 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift @@ -51,12 +51,7 @@ enum OpenCodeWebCookieSupport { #if os(macOS) static func automaticImportOrder(provider: UsageProvider) -> BrowserCookieImportOrder { - if provider == .opencodego, - let order = ProviderDefaults.metadata[provider]?.browserCookieOrder - { - return order - } - return [.chrome] + ProviderDefaults.metadata[provider]?.browserCookieOrder ?? [.chrome] } #endif } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index b999dff47b..b059d335b9 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -229,6 +229,16 @@ public enum ProviderBrowserCookieDefaults { #endif } + /// OpenCode web Auto stays Chrome-only by default, with Dia as the one bounded provider exception + /// because Dia has a confirmed reporter need. Other browsers stay on Manual until users can choose them. + public static var opencodeCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome, .dia] + #else + nil + #endif + } + /// Grok is normally signed in through Chrome; keep this narrow so CLI/live probes do not touch /// unrelated browser keychains. public static var grokCookieImportOrder: BrowserCookieImportOrder? { diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index c8cf68d48a..8824497bb2 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -49,8 +49,25 @@ struct BrowserCookieOrderStatusStringTests { } @Test - func `opencode automatic cookies keep chrome only default`() { - #expect(OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode) == [.chrome]) + func `opencode automatic cookies only use chrome and dia`() { + let order = OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode) + #expect(order == ProviderDefaults.metadata[.opencode]?.browserCookieOrder) + #expect(order == ProviderBrowserCookieDefaults.opencodeCookieImportOrder) + #expect(order == [.chrome, .dia]) + } + + @Test + func `opencode automatic cookies bound keychain prompt labels to chrome and dia`() { + let order = OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode) + let labels = order.flatMap(\.safeStorageLabels).map(\.service) + + #expect(labels == ["Chrome Safe Storage", "Dia Safe Storage"]) + #expect(!order.contains(.safari)) + #expect(!order.contains(.firefox)) + #expect(!order.contains(.edge)) + #expect(!order.contains(.brave)) + #expect(!order.contains(.arc)) + #expect(!order.contains(.chromium)) } @Test diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index eeda8a0fe0..46be2cca91 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -30,6 +30,14 @@ struct BrowserDetectionTests { profileAccessIssue: { _ in nil }) } + private static func labelIDs(for browser: Browser) -> [String] { + browser.safeStorageLabels.map { self.labelID(service: $0.service, account: $0.account) } + } + + private static func labelID(service: String, account: String?) -> String { + "\(service)|\(account ?? "")" + } + @Test(.disabled( if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1", "Default-home cookie access is explicitly enabled for this test run.")) @@ -281,6 +289,84 @@ struct BrowserDetectionTests { #expect(preflightCount == 1) } + @Test + func `chrome keychain preflight queries only chrome labels`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let chromeLabels = Self.labelIDs(for: .chrome) + let chromeLabelSet = Set(chromeLabels) + var queriedLabels: [String] = [] + + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in + let label = Self.labelID(service: service, account: account) + queriedLabels.append(label) + return .notFound + } operation: { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true) + } + } + + #expect(queriedLabels == chromeLabels) + #expect(queriedLabels.allSatisfy { chromeLabelSet.contains($0) }) + } + + @Test + func `dia keychain preflight queries only dia labels`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let diaLabels = Self.labelIDs(for: .dia) + let diaLabelSet = Set(diaLabels) + var queriedLabels: [String] = [] + + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in + let label = Self.labelID(service: service, account: account) + queriedLabels.append(label) + return .notFound + } operation: { + #expect(BrowserCookieAccessGate.shouldAttempt(.dia) == true) + } + } + + #expect(queriedLabels == diaLabels) + #expect(queriedLabels.allSatisfy { diaLabelSet.contains($0) }) + } + + @Test + func `browser keychain interaction suppresses only that browser`() throws { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 2000) + let chromeLabels = Self.labelIDs(for: .chrome) + let diaLabels = Self.labelIDs(for: .dia) + let firstChromeLabel = try #require(chromeLabels.first) + let firstDiaLabel = try #require(diaLabels.first) + let allowedLabels = Set(chromeLabels + diaLabels) + var queriedLabels: [String] = [] + + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in + let label = Self.labelID(service: service, account: account) + queriedLabels.append(label) + if label == firstChromeLabel { return .allowed } + if label == firstDiaLabel { return .interactionRequired } + return .notFound + } operation: { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == true) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(1)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == true) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false) + } + } + + #expect(queriedLabels == [firstChromeLabel, firstDiaLabel, firstChromeLabel]) + #expect(queriedLabels.allSatisfy { allowedLabels.contains($0) }) + } + @Test func `dia requires profile data`() throws { let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/docs/opencode.md b/docs/opencode.md index 3d1c504e70..d65c9d5159 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -22,7 +22,9 @@ read_when: ## Notes - Responses are `text/javascript` with serialized objects; parse via regex. - Missing workspace ID or rolling usage fields should raise parse errors; omitted weekly usage stays absent. -- Cookie import defaults to Chrome-only to avoid extra browser prompts; pass a browser list to override. +- OpenCode web Auto imports Chrome first, then Dia when their cookie stores exist; Keychain preflight stays scoped + to each candidate browser. Other browsers stay on Manual Cookie import until CodexBar has an explicit browser + selector. - Set `CODEXBAR_OPENCODE_WORKSPACE_ID` to skip workspace lookup and force a specific workspace. - Workspace override accepts a raw `wrk_…` ID or a full `https://opencode.ai/workspace/...` URL. - Cached cookies: Keychain cache `com.steipete.codexbar.cache` (account `cookie.opencode`, source + timestamp). Browser From 1e49619e7330c5f58a1ccb356103a0cc19f90df9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 07:33:41 -0400 Subject: [PATCH 030/105] feat: add Codex project cost rollups (#1884) Group local Codex usage and costs by canonical project and worktree source across menus, CLI text, and JSON output. Preserve the existing cache artifact while migrating project metadata safely, and avoid rendering legacy cache rows before migration. Co-authored-by: Clemens Peters --- CHANGELOG.md | 1 + .../CodexBar/CostHistoryChartMenuView.swift | 222 +++++++++++++- .../StatusItemController+HostedSubmenus.swift | 2 + ...ItemController+MenuRefreshScheduling.swift | 22 ++ Sources/CodexBarCLI/CLICostCommand.swift | 191 ++++++++++-- Sources/CodexBarCLI/CLIHelp.swift | 4 +- Sources/CodexBarCore/CostUsageFetcher.swift | 192 +++++++++++- Sources/CodexBarCore/CostUsageModels.swift | 64 ++++ .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 4 + .../CostUsageScanner+CacheHelpers.swift | 19 ++ .../CostUsage/CostUsageScanner+Projects.swift | 169 +++++++++++ .../Vendored/CostUsage/CostUsageScanner.swift | 175 ++++++++++- Tests/CodexBarTests/CLICostTests.swift | 144 +++++++++ .../CostHistoryChartMenuViewTests.swift | 41 +++ Tests/CodexBarTests/CostUsageCacheTests.swift | 20 ++ .../CostUsageFetcherCacheSnapshotTests.swift | 40 +++ .../CostUsageScannerBreakdownTests.swift | 284 ++++++++++++++++++ .../StatusMenuHostedSubmenuRefreshTests.swift | 51 +++- docs/cli.md | 2 + 20 files changed, 1603 insertions(+), 46 deletions(-) create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 281b29e16a..1f5baffd18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.38.2 — Unreleased ### Added +- 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! diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 17fc33d3db..ae6cea50ae 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 } @@ -224,6 +227,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) @@ -252,13 +290,25 @@ struct CostHistoryChartMenuView: View { private static let chartHeight: CGFloat = 114 private static let axisLabelAreaHeight: CGFloat = 16 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 @@ -268,9 +318,26 @@ struct CostHistoryChartMenuView: View { 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") @@ -403,6 +470,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 +563,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? { @@ -625,7 +789,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 +802,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.. 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/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 072677339b..ac8e503abf 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -60,7 +60,7 @@ extension CodexBarCLI { [--json-only] [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] - [--no-color] [--pretty] [--refresh] + [--no-color] [--pretty] [--refresh] [--days ] [--group-by project] Description: Print local token cost usage from Claude/Codex native logs plus supported pi sessions. @@ -68,6 +68,7 @@ extension CodexBarCLI { Examples: codexbar cost + codexbar cost --provider codex --group-by project codexbar cost --provider claude --format json --pretty """ } @@ -217,6 +218,7 @@ extension CodexBarCLI { [--json-only] [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh] + [--days ] [--group-by project] codexbar serve [--port ] [--refresh-interval ] [--request-timeout ] [--json-output] [--log-level ] [-v|--verbose] diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index c4d59ca60e..2f968fa8a4 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -165,7 +165,7 @@ public struct CostUsageFetcher: Sendable { // pool thread; CostUsageScanExecutor bridges this task's cancellation into the // scanner-level checks. let scanOptions = options - let daily = try await CostUsageScanExecutor.run { checkCancellation in + let scanResult = try await CostUsageScanExecutor.run { checkCancellation in var daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, @@ -192,6 +192,15 @@ public struct CostUsageFetcher: Sendable { try checkCancellation() } + var projects: [CostUsageProjectBreakdown] = [] + var piDaily: CostUsageDailyReport? + if provider == .codex { + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: scanOptions.cacheRoot) + projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: since, until: until), + modelsDevCacheRoot: scanOptions.cacheRoot) + } if provider == .codex || provider == .claude { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, @@ -201,12 +210,23 @@ public struct CostUsageFetcher: Sendable { options: piOptions, checkCancellation: checkCancellation) try checkCancellation() + if provider == .codex { + piDaily = piReport + } daily = CostUsageDailyReport.merged([daily, piReport]) } - return daily + if provider == .codex { + projects = Self.mergedProjectBreakdowns( + projects + [piDaily.flatMap(Self.unknownProjectBreakdown(from:))].compactMap(\.self)) + } + return (daily: daily, projects: projects) } - return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays) + return Self.tokenSnapshot( + from: scanResult.daily, + now: now, + historyDays: clampedHistoryDays, + projects: scanResult.projects) } static func loadCachedCodexTokenSnapshot( @@ -231,6 +251,7 @@ public struct CostUsageFetcher: Sendable { let options = overrideScannerOptions ?? CostUsageScanner.Options() let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) var reports: [CostUsageDailyReport] = [] + var projects: [CostUsageProjectBreakdown] = [] if !cache.days.isEmpty, cache.roots == CostUsageScanner.codexRootsFingerprint(options: options), @@ -242,6 +263,12 @@ public struct CostUsageFetcher: Sendable { modelsDevCacheRoot: options.cacheRoot) if !daily.data.isEmpty { reports.append(daily) + if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { + projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot)) + } } } @@ -253,13 +280,17 @@ public struct CostUsageFetcher: Sendable { cacheRoot: options.cacheRoot) { reports.append(piDaily) + if let piProject = Self.unknownProjectBreakdown(from: piDaily) { + projects.append(piProject) + } } guard !reports.isEmpty else { return nil } return Self.tokenSnapshot( from: CostUsageDailyReport.merged(reports), now: now, - historyDays: clampedHistoryDays) + historyDays: clampedHistoryDays, + projects: Self.mergedProjectBreakdowns(projects)) } return cachedSnapshot.flatMap(\.self) } @@ -281,7 +312,8 @@ public struct CostUsageFetcher: Sendable { from daily: CostUsageDailyReport, now: Date, historyDays: Int = 30, - useCurrentLocalDayForSession: Bool = true) -> CostUsageTokenSnapshot + useCurrentLocalDayForSession: Bool = true, + projects: [CostUsageProjectBreakdown] = []) -> CostUsageTokenSnapshot { let sessionEntry = useCurrentLocalDayForSession ? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now) @@ -316,9 +348,159 @@ public struct CostUsageFetcher: Sendable { last30DaysCostUSD: last30DaysCostUSD, historyDays: historyDays, daily: daily.data, + projects: projects, updatedAt: now) } + private static func unknownProjectBreakdown(from daily: CostUsageDailyReport) -> CostUsageProjectBreakdown? { + guard !daily.data.isEmpty else { return nil } + return CostUsageProjectBreakdown( + name: CostUsageProjectBreakdown.unknownProjectName, + path: nil, + totalTokens: daily.summary?.totalTokens, + totalCostUSD: daily.summary?.totalCostUSD, + daily: daily.data, + modelBreakdowns: self.projectModelBreakdowns(from: daily.data), + sources: [ + CostUsageProjectSourceBreakdown( + name: CostUsageProjectBreakdown.unknownProjectName, + path: nil, + totalTokens: daily.summary?.totalTokens, + totalCostUSD: daily.summary?.totalCostUSD, + daily: daily.data, + modelBreakdowns: self.projectModelBreakdowns(from: daily.data)), + ]) + } + + private static func mergedProjectBreakdowns( + _ projects: [CostUsageProjectBreakdown]) -> [CostUsageProjectBreakdown] + { + var dailyByPath: [String: [CostUsageDailyReport]] = [:] + var namesByPath: [String: String] = [:] + var sourceDailyByProjectPath: [String: [String: [CostUsageDailyReport]]] = [:] + var sourceNamesByProjectPath: [String: [String: String]] = [:] + for project in projects { + let key = project.path ?? "" + namesByPath[key] = project.name + dailyByPath[key, default: []].append(CostUsageDailyReport(data: project.daily, summary: nil)) + let sources = project.sources.isEmpty + ? [ + CostUsageProjectSourceBreakdown( + name: project.name, + path: project.path, + totalTokens: project.totalTokens, + totalCostUSD: project.totalCostUSD, + daily: project.daily, + modelBreakdowns: project.modelBreakdowns), + ] + : project.sources + for source in sources { + let sourceKey = source.path ?? "" + sourceNamesByProjectPath[key, default: [:]][sourceKey] = source.name + sourceDailyByProjectPath[key, default: [:]][sourceKey, default: []] + .append(CostUsageDailyReport(data: source.daily, summary: nil)) + } + } + return dailyByPath.map { key, reports in + let merged = CostUsageDailyReport.merged(reports) + return CostUsageProjectBreakdown( + name: namesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName, + path: key.isEmpty ? nil : key, + totalTokens: merged.summary?.totalTokens, + totalCostUSD: merged.summary?.totalCostUSD, + daily: merged.data, + modelBreakdowns: Self.projectModelBreakdowns(from: merged.data), + sources: Self.mergedProjectSources( + sourceDailyByPath: sourceDailyByProjectPath[key] ?? [:], + sourceNamesByPath: sourceNamesByProjectPath[key] ?? [:])) + } + .sorted { lhs, rhs in + let lhsCost = lhs.totalCostUSD ?? -1 + let rhsCost = rhs.totalCostUSD ?? -1 + if lhsCost != rhsCost { return lhsCost > rhsCost } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private static func mergedProjectSources( + sourceDailyByPath: [String: [CostUsageDailyReport]], + sourceNamesByPath: [String: String]) -> [CostUsageProjectSourceBreakdown] + { + sourceDailyByPath.map { key, reports in + let merged = CostUsageDailyReport.merged(reports) + return CostUsageProjectSourceBreakdown( + name: sourceNamesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName, + path: key.isEmpty ? nil : key, + totalTokens: merged.summary?.totalTokens, + totalCostUSD: merged.summary?.totalCostUSD, + daily: merged.data, + modelBreakdowns: Self.projectModelBreakdowns(from: merged.data)) + } + .sorted { lhs, rhs in + let lhsCost = lhs.totalCostUSD ?? -1 + let rhsCost = rhs.totalCostUSD ?? -1 + if lhsCost != rhsCost { return lhsCost > rhsCost } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private struct ProjectBreakdownAccumulator { + var totalTokens = 0 + var sawTotalTokens = false + var costUSD: Double = 0 + var sawCost = false + + mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) { + if let totalTokens = breakdown.totalTokens { + self.totalTokens += totalTokens + self.sawTotalTokens = true + } + if let costUSD = breakdown.costUSD { + self.costUSD += costUSD + self.sawCost = true + } + } + + func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown { + CostUsageDailyReport.ModelBreakdown( + modelName: modelName, + costUSD: self.sawCost ? self.costUSD : nil, + totalTokens: self.sawTotalTokens ? self.totalTokens : nil) + } + } + + private static func projectModelBreakdowns( + from entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.ModelBreakdown]? + { + var accumulators: [String: ProjectBreakdownAccumulator] = [:] + for entry in entries { + for breakdown in entry.modelBreakdowns ?? [] { + var accumulator = accumulators[breakdown.modelName] ?? ProjectBreakdownAccumulator() + accumulator.add(breakdown) + accumulators[breakdown.modelName] = accumulator + } + } + guard !accumulators.isEmpty else { return nil } + return accumulators.map { modelName, accumulator in + accumulator.build(modelName: modelName) + } + .sorted { lhs, rhs in + let lhsCost = lhs.costUSD ?? -1 + let rhsCost = rhs.costUSD ?? -1 + if lhsCost != rhsCost { return lhsCost > rhsCost } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + return lhs.modelName > rhs.modelName + } + } + static func selectCurrentSession(from sessions: [CostUsageSessionReport.Entry]) -> CostUsageSessionReport.Entry? { diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index d2a6a4d2c7..797faf4915 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -11,6 +11,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { public let historyDays: Int public let historyLabel: String? public let daily: [CostUsageDailyReport.Entry] + public let projects: [CostUsageProjectBreakdown] public let updatedAt: Date public init( @@ -24,6 +25,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyDays: Int = 30, historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry], + projects: [CostUsageProjectBreakdown] = [], updatedAt: Date) { self.sessionTokens = sessionTokens @@ -38,6 +40,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.historyDays = historyDays self.historyLabel = historyLabel self.daily = daily + self.projects = projects self.updatedAt = updatedAt } @@ -77,6 +80,67 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { } } +public struct CostUsageProjectBreakdown: Sendable, Equatable { + public static let unknownProjectName = "Unknown project" + + public let name: String + public let path: String? + public let totalTokens: Int? + public let totalCostUSD: Double? + public let daily: [CostUsageDailyReport.Entry] + public let modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]? + public let sources: [CostUsageProjectSourceBreakdown] + + public init( + name: String, + path: String?, + totalTokens: Int?, + totalCostUSD: Double?, + daily: [CostUsageDailyReport.Entry], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]?, + sources: [CostUsageProjectSourceBreakdown] = []) + { + self.name = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? Self.unknownProjectName + : name + let cleanPath = path?.trimmingCharacters(in: .whitespacesAndNewlines) + self.path = cleanPath?.isEmpty == true ? nil : cleanPath + self.totalTokens = totalTokens + self.totalCostUSD = totalCostUSD + self.daily = daily + self.modelBreakdowns = modelBreakdowns + self.sources = sources + } +} + +public struct CostUsageProjectSourceBreakdown: Sendable, Equatable { + public let name: String + public let path: String? + public let totalTokens: Int? + public let totalCostUSD: Double? + public let daily: [CostUsageDailyReport.Entry] + public let modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]? + + public init( + name: String, + path: String?, + totalTokens: Int?, + totalCostUSD: Double?, + daily: [CostUsageDailyReport.Entry], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]?) + { + self.name = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? CostUsageProjectBreakdown.unknownProjectName + : name + let cleanPath = path?.trimmingCharacters(in: .whitespacesAndNewlines) + self.path = cleanPath?.isEmpty == true ? nil : cleanPath + self.totalTokens = totalTokens + self.totalCostUSD = totalCostUSD + self.daily = daily + self.modelBreakdowns = modelBreakdowns + } +} + public struct CostUsageDailyReport: Sendable, Decodable { public struct ModelBreakdown: Sendable, Decodable, Equatable { public let modelName: String diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 781603f740..57705aaa14 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "c54070a94f6419ea" + static let value = "f9681e8eb932e939" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 40c2d8c6f4..09517ba061 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -3,6 +3,7 @@ import Foundation enum CostUsageCacheIO { private static let compatibleCodexProducerKeys: Set = [ "codex:cu:p3c27f997569eb3c5", + "codex:cu:pc54070a94f6419ea", ] private static func artifactVersion(for provider: UsageProvider) -> Int { @@ -110,6 +111,7 @@ struct CostUsageCache: Codable { var scanUntilKey: String? var codexPricingKey: String? var codexPriorityMetadataKey: String? + var codexProjectMetadataVersion: Int? var codexPriorityTurnKeys: [String: String]? var codexPriorityTurnIDsByDay: [String: [String]]? @@ -136,6 +138,8 @@ struct CostUsageFileUsage: Codable { var lastCodexTurnID: String? var sessionId: String? var forkedFromId: String? + var projectPath: String? + var canonicalProjectPath: String? var codexCostNanos: [String: [String: Int64]]? var codexPrioritySurchargeNanos: [String: [String: Int64]]? var codexStandardCostNanos: [String: [String: Int64]]? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 98e8779b4d..6543356d94 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -274,6 +274,8 @@ extension CostUsageScanner { lastCodexTurnID: String? = nil, sessionId: String? = nil, forkedFromId: String? = nil, + projectPath: String? = nil, + canonicalProjectPath: String? = nil, codexCostNanos: [String: [String: Int64]]? = nil, codexPrioritySurchargeNanos: [String: [String: Int64]]? = nil, codexStandardCostNanos: [String: [String: Int64]]? = nil, @@ -297,6 +299,8 @@ extension CostUsageScanner { lastCodexTurnID: lastCodexTurnID, sessionId: sessionId, forkedFromId: forkedFromId, + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, codexCostNanos: codexCostNanos, codexPrioritySurchargeNanos: codexPrioritySurchargeNanos, codexStandardCostNanos: codexStandardCostNanos, @@ -663,6 +667,8 @@ extension CostUsageScanner { lastCodexTurnID: usage.lastCodexTurnID, sessionId: usage.sessionId, forkedFromId: usage.forkedFromId, + projectPath: usage.projectPath, + canonicalProjectPath: usage.canonicalProjectPath, codexCostNanos: Self.mergeCostMaps( Self.costMapOutsideScanWindow(usage.codexCostNanos, range: context.range), Self.codexCostNanos( @@ -942,6 +948,10 @@ extension CostUsageScanner { return false } let sessionId = delta.sessionId ?? cached.sessionId + let projectPath = delta.projectPath ?? cached.projectPath + let canonicalProjectPath = delta.projectPath.map { + context.resources.projectPathResolver.canonicalProjectPath(for: $0) + } ?? cached.canonicalProjectPath ?? context.resources.projectPathResolver.canonicalProjectPath(for: projectPath) let sessionAlreadyContributed = sessionId.map { state.contributingSessionIds.contains($0) } ?? false let cachedRows = cached.codexRows ?? [] let retainedCachedRows: [CodexUsageRow] @@ -1004,6 +1014,8 @@ extension CostUsageScanner { lastCodexTurnID: delta.lastCodexTurnID, sessionId: sessionId, forkedFromId: delta.forkedFromId ?? migratedCached.forkedFromId, + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, codexCostNanos: Self.codexMergedCostMap( migratedCached.codexCostNanos, deltaRows: uniqueRows, @@ -1056,6 +1068,11 @@ extension CostUsageScanner { inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:), checkCancellation: context.checkCancellation) let sessionId = parsed.sessionId ?? input.cached?.sessionId + let projectPath = parsed.projectPath ?? input.cached?.projectPath + let canonicalProjectPath = parsed.projectPath.map { + context.resources.projectPathResolver.canonicalProjectPath(for: $0) + } ?? input.cached?.canonicalProjectPath ?? context.resources.projectPathResolver + .canonicalProjectPath(for: projectPath) let uniqueRows = Self.uniqueCodexRows( rows: parsed.rows, sessionId: sessionId, @@ -1091,6 +1108,8 @@ extension CostUsageScanner { lastCodexTurnID: parsed.lastCodexTurnID, sessionId: sessionId, forkedFromId: parsed.forkedFromId, + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, codexCostNanos: Self.mergeCostMaps( context.dropDeferredCodexRows ? nil diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift new file mode 100644 index 0000000000..fc77b8ebe1 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift @@ -0,0 +1,169 @@ +import Foundation + +extension CostUsageScanner { + static func buildCodexProjectBreakdownsFromCache( + cache: CostUsageCache, + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + priorityTurns: [String: CodexPriorityTurnMetadata] = [:]) -> [CostUsageProjectBreakdown] + { + let projectPathResolver = CodexCanonicalProjectPathResolver() + var accumulatorsByProjectPath: [String: CodexProjectBreakdownAccumulator] = [:] + for (filePath, usage) in cache.files { + guard usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) else { + continue + } + var fileCache = CostUsageCache() + fileCache.files[filePath] = usage + fileCache.days = usage.days + let report = Self.buildCodexReportFromCache( + cache: fileCache, + range: range, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot, + priorityTurns: priorityTurns) + guard !report.data.isEmpty else { continue } + let projectKey = usage.canonicalProjectPath + ?? projectPathResolver.canonicalProjectPath(for: usage.projectPath) + ?? "" + let sourceKey = usage.projectPath ?? "" + var accumulator = accumulatorsByProjectPath[projectKey] ?? CodexProjectBreakdownAccumulator() + accumulator.add(report: report, sourcePath: sourceKey) + accumulatorsByProjectPath[projectKey] = accumulator + } + + return accumulatorsByProjectPath.map { projectPath, accumulator in + let merged = CostUsageDailyReport.merged(accumulator.reports) + let resolvedPath = projectPath.isEmpty ? nil : projectPath + return CostUsageProjectBreakdown( + name: Self.codexProjectName(path: resolvedPath), + path: resolvedPath, + totalTokens: merged.summary?.totalTokens, + totalCostUSD: merged.summary?.totalCostUSD, + daily: merged.data, + modelBreakdowns: Self.codexProjectModelBreakdowns(from: merged.data), + sources: Self.codexProjectSourceBreakdowns(from: accumulator.reportsBySourcePath)) + } + .sorted { lhs, rhs in + let lhsCost = lhs.totalCostUSD ?? -1 + let rhsCost = rhs.totalCostUSD ?? -1 + if lhsCost != rhsCost { return lhsCost > rhsCost } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private static func codexProjectName(path: String?) -> String { + guard let path, !path.isEmpty else { return CostUsageProjectBreakdown.unknownProjectName } + let name = URL(fileURLWithPath: path, isDirectory: true).lastPathComponent + return name.isEmpty ? path : name + } + + private struct CodexProjectBreakdownAccumulator { + var reports: [CostUsageDailyReport] = [] + var reportsBySourcePath: [String: [CostUsageDailyReport]] = [:] + + mutating func add(report: CostUsageDailyReport, sourcePath: String) { + self.reports.append(report) + self.reportsBySourcePath[sourcePath, default: []].append(report) + } + } + + private static func codexProjectSourceBreakdowns( + from reportsBySourcePath: [String: [CostUsageDailyReport]]) -> [CostUsageProjectSourceBreakdown] + { + reportsBySourcePath.map { sourcePath, reports in + let merged = CostUsageDailyReport.merged(reports) + let resolvedPath = sourcePath.isEmpty ? nil : sourcePath + return CostUsageProjectSourceBreakdown( + name: Self.codexProjectName(path: resolvedPath), + path: resolvedPath, + totalTokens: merged.summary?.totalTokens, + totalCostUSD: merged.summary?.totalCostUSD, + daily: merged.data, + modelBreakdowns: Self.codexProjectModelBreakdowns(from: merged.data)) + } + .sorted { lhs, rhs in + let lhsCost = lhs.totalCostUSD ?? -1 + let rhsCost = rhs.totalCostUSD ?? -1 + if lhsCost != rhsCost { return lhsCost > rhsCost } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private struct ProjectBreakdownAccumulator { + var totalTokens = 0 + var sawTotalTokens = false + var costUSD: Double = 0 + var sawCost = false + var standardCostUSD: Double = 0 + var sawStandardCost = false + var priorityCostUSD: Double = 0 + var sawPriorityCost = false + var standardTokens = 0 + var sawStandardTokens = false + var priorityTokens = 0 + var sawPriorityTokens = false + + mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) { + if let totalTokens = breakdown.totalTokens { + self.totalTokens += totalTokens + self.sawTotalTokens = true + } + if let costUSD = breakdown.costUSD { + self.costUSD += costUSD + self.sawCost = true + } + if let standardCostUSD = breakdown.standardCostUSD { + self.standardCostUSD += standardCostUSD + self.sawStandardCost = true + } + if let priorityCostUSD = breakdown.priorityCostUSD { + self.priorityCostUSD += priorityCostUSD + self.sawPriorityCost = true + } + if let standardTokens = breakdown.standardTokens { + self.standardTokens += standardTokens + self.sawStandardTokens = true + } + if let priorityTokens = breakdown.priorityTokens { + self.priorityTokens += priorityTokens + self.sawPriorityTokens = true + } + } + + func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown { + CostUsageDailyReport.ModelBreakdown( + modelName: modelName, + costUSD: self.sawCost ? self.costUSD : nil, + totalTokens: self.sawTotalTokens ? self.totalTokens : nil, + standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, + priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, + standardTokens: self.sawStandardTokens ? self.standardTokens : nil, + priorityTokens: self.sawPriorityTokens ? self.priorityTokens : nil) + } + } + + private static func codexProjectModelBreakdowns( + from entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.ModelBreakdown]? + { + var accumulators: [String: ProjectBreakdownAccumulator] = [:] + for entry in entries { + for breakdown in entry.modelBreakdowns ?? [] { + var accumulator = accumulators[breakdown.modelName] ?? ProjectBreakdownAccumulator() + accumulator.add(breakdown) + accumulators[breakdown.modelName] = accumulator + } + } + guard !accumulators.isEmpty else { return nil } + return Self.sortedModelBreakdowns(accumulators.map { modelName, accumulator in + accumulator.build(modelName: modelName) + }) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index defb8a6d1d..27cdb544be 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -3,10 +3,12 @@ import CryptoKit #else import Crypto #endif +import Dispatch import Foundation // swiftlint:disable type_body_length file_length enum CostUsageScanner { + static let codexProjectMetadataVersion = 1 typealias CancellationCheck = () throws -> Void static let log = CodexBarLog.logger(LogCategories.tokenCost) @@ -57,6 +59,7 @@ enum CostUsageScanner { let lastCodexTurnID: String? let sessionId: String? let forkedFromId: String? + let projectPath: String? let rows: [CodexUsageRow] } @@ -176,6 +179,7 @@ enum CostUsageScanner { struct CodexScanResources { let fileIndex: CodexSessionFileIndex let inheritedResolver: CodexInheritedTotalsResolver + let projectPathResolver: CodexCanonicalProjectPathResolver let modelsDevCatalog: ModelsDevCatalog? let modelsDevCacheRoot: URL? let priorityTurns: [String: CodexPriorityTurnMetadata] @@ -191,6 +195,94 @@ enum CostUsageScanner { let checkCancellation: CancellationCheck? } + final class CodexCanonicalProjectPathResolver { + private var cache: [String: String] = [:] + private let homeCodexWorktreesPrefix: String + + init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) { + self.homeCodexWorktreesPrefix = homeDirectory + .appendingPathComponent(".codex/worktrees", isDirectory: true) + .standardizedFileURL + .path + } + + func canonicalProjectPath(for projectPath: String?) -> String? { + guard let projectPath else { return nil } + if let cached = self.cache[projectPath] { + return cached + } + let resolved = self.resolveCanonicalProjectPath(projectPath) ?? projectPath + self.cache[projectPath] = resolved + return resolved + } + + private func resolveCanonicalProjectPath(_ projectPath: String) -> String? { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: projectPath, isDirectory: &isDirectory), + isDirectory.boolValue + else { return nil } + guard let output = self.gitWorktreeList(projectPath: projectPath) else { return nil } + let worktrees = output + .split(separator: "\n") + .compactMap { line -> String? in + guard line.hasPrefix("worktree ") else { return nil } + let rawPath = line.dropFirst("worktree ".count) + return Self.standardizedAbsolutePath(String(rawPath)) + } + guard !worktrees.isEmpty else { return nil } + return worktrees.first { !self.isEphemeralWorktreePath($0) } + } + + private func gitWorktreeList(projectPath: String) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git", "-C", projectPath, "worktree", "list", "--porcelain"] + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + let outputCapture = ProcessPipeCapture(pipe: outputPipe) + let errorCapture = ProcessPipeCapture(pipe: errorPipe) + + let semaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in semaphore.signal() } + do { + try process.run() + } catch { + return nil + } + outputCapture.start() + errorCapture.start() + + if semaphore.wait(timeout: .now() + .seconds(1)) == .timedOut { + process.terminate() + outputCapture.stop() + errorCapture.stop() + return nil + } + let data = outputCapture.finishSynchronously(timeout: 0.1) + errorCapture.stop() + guard process.terminationStatus == 0 else { return nil } + return String(data: data, encoding: .utf8) + } + + private func isEphemeralWorktreePath(_ path: String) -> Bool { + path == self.homeCodexWorktreesPrefix + || path.hasPrefix(self.homeCodexWorktreesPrefix + "/") + || path.hasSuffix("/.codex/worktrees") + || path.contains("/.codex/worktrees/") + || path == "/private/tmp" + || path.hasPrefix("/private/tmp/") + } + + private static func standardizedAbsolutePath(_ path: String) -> String? { + let expanded = (path as NSString).expandingTildeInPath + guard expanded.hasPrefix("/") else { return nil } + return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path + } + } + struct CodexRefreshPlan { let refreshMs: Int64 let roots: [URL] @@ -198,6 +290,7 @@ enum CostUsageScanner { let rootsChanged: Bool let windowExpanded: Bool let needsCostCacheMigration: Bool + let needsProjectMetadataMigration: Bool let modelsDevCatalog: ModelsDevCatalog? let codexPricingKey: String let codexPriorityMetadataKey: String @@ -955,6 +1048,7 @@ enum CostUsageScanner { let sessionId: String? let forkedFromId: String? let forkTimestamp: String? + let projectPath: String? } private struct CodexTokenCountRecord { @@ -993,6 +1087,7 @@ enum CostUsageScanner { private static let codexJSONFieldTurnId = Array("turn_id".utf8) private static let codexJSONFieldTurnIdCamel = Array("turnId".utf8) private static let codexJSONFieldType = Array("type".utf8) + private static let codexJSONFieldCwd = Array("cwd".utf8) private static func codexForkParentId(from payload: [String: Any]?) -> String? { guard let payload else { return nil } @@ -1065,6 +1160,24 @@ enum CostUsageScanner { return nil } + static func normalizedCodexProjectPath(_ rawPath: String?) -> String? { + guard let rawPath = rawPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawPath.isEmpty + else { return nil } + let expanded = (rawPath as NSString).expandingTildeInPath + guard expanded.hasPrefix("/") else { return nil } + return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path + } + + private static func codexProjectPath( + from bytes: UnsafeBufferPointer, + payloadRange: Range?) -> String? + { + guard let payloadRange else { return nil } + return Self.normalizedCodexProjectPath( + Self.extractJSONByteStringField(Self.codexJSONFieldCwd, from: bytes, in: payloadRange, atDepth: 1)) + } + private static func codexTotals( from bytes: UnsafeBufferPointer, in objectRange: Range?) -> CostUsageCodexTotals? @@ -1122,7 +1235,8 @@ enum CostUsageScanner { Self.codexJSONFieldTimestamp, from: rawBuffer, in: objectRange, - atDepth: 1))) + atDepth: 1), + projectPath: Self.codexProjectPath(from: rawBuffer, payloadRange: payloadRange))) case "turn_context": guard let payloadRange = Self.extractJSONByteObjectField( @@ -1280,7 +1394,8 @@ enum CostUsageScanner { ?? obj["id"] as? String, forkedFromId: Self.codexForkParentId(from: payload), forkTimestamp: payload?["timestamp"] as? String - ?? obj["timestamp"] as? String) + ?? obj["timestamp"] as? String, + projectPath: Self.normalizedCodexProjectPath(payload?["cwd"] as? String)) } } @@ -1507,6 +1622,7 @@ enum CostUsageScanner { lastCodexTurnID: initialCodexTurnID, sessionId: nil, forkedFromId: nil, + projectPath: nil, rows: []) } @@ -1528,6 +1644,7 @@ enum CostUsageScanner { var previousTotals = initialTotals var sessionId: String? var forkedFromId: String? + var projectPath: String? var inheritedTotals: CostUsageCodexTotals? var remainingInheritedTotals: CostUsageCodexTotals? var forkBaselineResolved = false @@ -1577,6 +1694,9 @@ enum CostUsageScanner { if forkedFromId == nil { forkedFromId = metadata.forkedFromId } + if projectPath == nil { + projectPath = metadata.projectPath + } if let forkedFromId { try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: metadata.forkTimestamp ?? "") } @@ -1798,6 +1918,7 @@ enum CostUsageScanner { { sessionId = metadata.sessionId forkedFromId = metadata.forkedFromId + projectPath = metadata.projectPath if let forkedFromId = metadata.forkedFromId, inheritedTotals == nil { @@ -1866,6 +1987,9 @@ enum CostUsageScanner { if forkedFromId == nil { forkedFromId = Self.codexForkParentId(from: payload) } + if projectPath == nil { + projectPath = Self.normalizedCodexProjectPath(payload?["cwd"] as? String) + } if let forkedFromId { let forkedAt = payload?["timestamp"] as? String ?? obj["timestamp"] as? String @@ -2137,6 +2261,7 @@ enum CostUsageScanner { lastCodexTurnID: currentTurnID, sessionId: sessionId, forkedFromId: forkedFromId, + projectPath: projectPath, rows: rows) } @@ -2188,6 +2313,7 @@ enum CostUsageScanner { let rootsChanged = cache.roots != rootsFingerprint let windowExpanded = Self.requestedWindowExpandsCache(range: range, cache: cache) let needsCostCacheMigration = cache.files.values.contains { Self.needsCodexCostCache($0, range: range) } + let needsProjectMetadataMigration = cache.codexProjectMetadataVersion != Self.codexProjectMetadataVersion let modelsDevLoad = ModelsDevCache.load(now: now, cacheRoot: options.cacheRoot) let modelsDevCatalog = modelsDevLoad.artifact?.catalog let codexPricingKey = Self.codexPricingKey(modelsDevArtifact: modelsDevLoad.artifact) @@ -2206,6 +2332,7 @@ enum CostUsageScanner { || windowExpanded || rootsChanged || needsCostCacheMigration + || needsProjectMetadataMigration || needsTurnIDCacheMigration || pricingChanged || priorityMetadataChanged @@ -2236,6 +2363,7 @@ enum CostUsageScanner { || windowExpanded || rootsChanged || needsCostCacheMigration + || needsProjectMetadataMigration || needsTurnIDCacheMigration || pricingChanged || priorityMetadataChanged @@ -2251,6 +2379,7 @@ enum CostUsageScanner { rootsChanged: rootsChanged, windowExpanded: windowExpanded, needsCostCacheMigration: needsCostCacheMigration, + needsProjectMetadataMigration: needsProjectMetadataMigration, modelsDevCatalog: modelsDevCatalog, codexPricingKey: codexPricingKey, codexPriorityMetadataKey: codexPriorityMetadataKey, @@ -2342,23 +2471,20 @@ enum CostUsageScanner { let resources = CodexScanResources( fileIndex: fileIndex, inheritedResolver: inheritedResolver, + projectPathResolver: CodexCanonicalProjectPathResolver(), modelsDevCatalog: plan.modelsDevCatalog, modelsDevCacheRoot: options.cacheRoot, priorityTurns: plan.priorityTurns) + let scanContext = Self.codexFileScanContext( + range: range, + options: options, + plan: plan, + resources: resources, + checkCancellation: checkCancellation) for fileURL in files { try Self.scanCodexFile( fileURL: fileURL, - context: CodexFileScanContext( - range: range, - forceFullScan: options - .forceRescan || plan.windowExpanded || plan.pricingChanged || plan.priorityMetadataChanged, - dropDeferredCodexRows: options.forceRescan || plan.pricingChanged || plan - .priorityMetadataChanged - || plan.needsTurnIDCacheMigration, - requiresTurnIDCache: plan.needsTurnIDCacheMigration, - changedPriorityTurnIDs: plan.changedPriorityTurnIDs, - resources: resources, - checkCancellation: checkCancellation), + context: scanContext, cache: &cache, state: &scanState) } @@ -2370,6 +2496,7 @@ enum CostUsageScanner { isForceRescan: options.forceRescan) let shouldDropAllUnscannedFiles = options.forceRescan || plan.rootsChanged || cache.files.isEmpty + || plan.needsProjectMetadataMigration for key in cache.files.keys where !filePathsInScan.contains(key) { guard let old = cache.files[key] else { continue } let shouldDrop = shouldDropAllUnscannedFiles || @@ -2393,7 +2520,7 @@ enum CostUsageScanner { } let shouldRetainWiderWindow = !options.forceRescan && !plan.pricingChanged && !plan - .priorityMetadataChanged && !plan.needsTurnIDCacheMigration + .priorityMetadataChanged && !plan.needsTurnIDCacheMigration && !plan.needsProjectMetadataMigration let retainedSinceKey = shouldRetainWiderWindow ? [cachedSinceKey, range.scanSinceKey].compactMap(\.self).min() ?? range.scanSinceKey : range.scanSinceKey @@ -2406,6 +2533,7 @@ enum CostUsageScanner { cache.scanUntilKey = retainedUntilKey cache.codexPricingKey = plan.codexPricingKey cache.codexPriorityMetadataKey = plan.codexPriorityMetadataKey + cache.codexProjectMetadataVersion = Self.codexProjectMetadataVersion if plan.hasPriorityMetadata { cache.codexPriorityTurnKeys = Self.mergePriorityTurnKeys( existing: shouldRetainWiderWindow ? cache.codexPriorityTurnKeys : nil, @@ -2432,6 +2560,25 @@ enum CostUsageScanner { modelsDevCacheRoot: options.cacheRoot, priorityTurns: plan.priorityTurns) } + + private static func codexFileScanContext( + range: CostUsageDayRange, + options: Options, + plan: CodexRefreshPlan, + resources: CodexScanResources, + checkCancellation: CancellationCheck?) -> CodexFileScanContext + { + CodexFileScanContext( + range: range, + forceFullScan: options.forceRescan || plan.windowExpanded || plan.pricingChanged + || plan.priorityMetadataChanged || plan.needsProjectMetadataMigration, + dropDeferredCodexRows: options.forceRescan || plan.pricingChanged || plan.priorityMetadataChanged + || plan.needsTurnIDCacheMigration, + requiresTurnIDCache: plan.needsTurnIDCacheMigration, + changedPriorityTurnIDs: plan.changedPriorityTurnIDs, + resources: resources, + checkCancellation: checkCancellation) + } } // swiftlint:enable type_body_length diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index 1b223bce56..008bf2f6e5 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -38,6 +38,67 @@ struct CLICostTests { #expect(output.contains("Claude Code /status")) } + @Test + func `renders codex project grouped cost text`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + daily: [], + projects: [ + CostUsageProjectBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 7000, + totalCostUSD: 7.5, + daily: [], + modelBreakdowns: nil, + sources: [ + CostUsageProjectSourceBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 5000, + totalCostUSD: 5.25, + daily: [], + modelBreakdowns: nil), + CostUsageProjectSourceBreakdown( + name: "client-a", + path: "/Users/test/.codex/worktrees/abcd/client-a", + totalTokens: 2000, + totalCostUSD: 2.25, + daily: [], + modelBreakdowns: nil), + ]), + CostUsageProjectBreakdown( + name: CostUsageProjectBreakdown.unknownProjectName, + path: nil, + totalTokens: 2000, + totalCostUSD: 2.49, + daily: [], + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let output = CodexBarCLI.renderCostText( + provider: .codex, + snapshot: snap, + groupBy: .project, + useColor: false) + .replacingOccurrences(of: "\u{00A0}", with: " ") + .replacingOccurrences(of: "$ ", with: "$") + + #expect(output.contains("Codex Cost (API-rate estimate)")) + #expect(output.contains("Projects (Last 30 days):")) + #expect(output.contains("client-a: $7.50 · 7K tokens")) + #expect(output.contains("/work/client-a")) + #expect(output.contains(" - client-a: $5.25 · 5K tokens")) + #expect(output.contains(" - client-a: $2.25 · 2K tokens")) + #expect(output.contains("/Users/test/.codex/worktrees/abcd/client-a")) + #expect(output.contains("Unknown project: $2.49 · 2K tokens")) + } + @Test func `encodes cost payload JSON`() throws { let payload = CostPayload( @@ -95,6 +156,89 @@ struct CLICostTests { #expect(json.contains("1700000000")) } + @Test + func `codex cost payload includes project rollups`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 0.01, + last30DaysTokens: 40, + last30DaysCostUSD: 0.04, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-04-02", + inputTokens: 30, + outputTokens: 10, + totalTokens: 40, + costUSD: 0.04, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.04, + totalTokens: 40), + ]), + ], + projects: [ + CostUsageProjectBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 40, + totalCostUSD: 0.04, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-04-02", + inputTokens: 30, + outputTokens: 10, + totalTokens: 40, + costUSD: 0.04, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: nil), + ], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.04, + totalTokens: 40), + ], + sources: [ + CostUsageProjectSourceBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 40, + totalCostUSD: 0.04, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-04-02", + inputTokens: 30, + outputTokens: 10, + totalTokens: 40, + costUSD: 0.04, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: nil), + ], + modelBreakdowns: nil), + ]), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + let payload = CodexBarCLI.makeCostPayload(provider: .codex, snapshot: snapshot, error: nil) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(payload) + guard let json = String(data: data, encoding: .utf8) else { + Issue.record("Failed to decode cost payload JSON") + return + } + + #expect(json.contains("\"projects\"")) + #expect(json.contains("\"sources\"")) + #expect(json.contains("\"name\":\"client-a\"")) + #expect(json.contains("/work/client-a") || json.contains("\\/work\\/client-a")) + #expect(json.contains("\"totalCost\":0.04")) + #expect(json.contains("\"daily\"")) + #expect(json.contains("\"gpt-5.4\"")) + } + @Test func `encodes exact codex model I ds and zero cost breakdowns`() throws { let payload = CostPayload( diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index 188532812f..20840f1251 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -181,5 +181,46 @@ struct CostHistoryChartMenuViewTests { modeSubtitlePresence: [false], hasTotal: true) #expect(withTotal > withoutTotal) + + let withProjects = CostHistoryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [false], + hasTotal: true, + projectCount: 3) + #expect(withProjects > withTotal) + + let withProjectSources = CostHistoryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [false], + hasTotal: true, + projectSourceCounts: [3]) + #expect(withProjectSources > withProjects) + } + + @Test + @MainActor + func `single differing project source remains visible`() { + let matching = Self.project(path: "/tmp/main", sourcePath: "/tmp/main") + let differing = Self.project(path: "/tmp/main", sourcePath: "/tmp/worktree") + + #expect(CostHistoryChartMenuView.visibleProjectSources(matching).isEmpty) + #expect(CostHistoryChartMenuView.visibleProjectSources(differing).compactMap(\.path) == ["/tmp/worktree"]) + } + + private static func project(path: String, sourcePath: String) -> CostUsageProjectBreakdown { + CostUsageProjectBreakdown( + name: "Project", + path: path, + totalTokens: 10, + totalCostUSD: 0.1, + daily: [], + modelBreakdowns: nil, + sources: [ + CostUsageProjectSourceBreakdown( + name: "Source", + path: sourcePath, + totalTokens: 10, + totalCostUSD: 0.1, + daily: [], + modelBreakdowns: nil), + ]) } } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index b7252a246d..7ecd2fcdcf 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -100,6 +100,26 @@ struct CostUsageCacheTests { #expect(loaded.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3]) } + @Test + func `current codex cache accepts project metadata migration producer`() throws { + let root = try self.makeTemporaryCacheRoot() + defer { try? FileManager.default.removeItem(at: root) } + + var cache = CostUsageCache() + cache.lastScanUnixMs = 123 + cache.days = ["2026-05-18": ["gpt-5.5": [1, 2, 3]]] + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: root, + producerKey: "codex:cu:pc54070a94f6419ea") + + let loaded = CostUsageCacheIO.load(provider: .codex, cacheRoot: root) + + #expect(loaded.lastScanUnixMs == 123) + #expect(loaded.days["2026-05-18"]?["gpt-5.5"] == [1, 2, 3]) + } + @Test func `non codex cache does not require producer key`() throws { let root = try self.makeTemporaryCacheRoot() diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index df7761a316..46c56ef043 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -71,6 +71,46 @@ struct CostUsageFetcherCacheSnapshotTests { #expect(managed == nil) } + @Test + func `cached codex token snapshot omits projects until metadata migration`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + let current = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + #expect(current?.projects.count == 1) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.codexProjectMetadataVersion = nil + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let legacy = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + #expect(legacy?.sessionTokens == 42) + #expect(legacy?.projects.isEmpty == true) + } + @Test func `cached codex token snapshot refuses mismatched roots fingerprint`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 681c6761a6..d4b8a054f6 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -18,6 +18,17 @@ struct CostUsageScannerBreakdownTests { ] } + private func codexSessionMeta(timestamp: String, id: String, cwd: String) -> [String: Any] { + [ + "type": "session_meta", + "timestamp": timestamp, + "payload": [ + "id": id, + "cwd": cwd, + ], + ] + } + private func codexTokenCount( timestamp: String, model: String, @@ -195,6 +206,186 @@ struct CostUsageScannerBreakdownTests { #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } + @Test + func `codex project breakdowns group by cwd and preserve daily totals`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "gpt-5.4" + let projectA = env.root.appendingPathComponent("client-a", isDirectory: true).path + let projectB = env.root.appendingPathComponent("client-b", isDirectory: true).path + let projectAWorktree = env.root + .appendingPathComponent(".codex/worktrees/abcd/client-a", isDirectory: true) + .path + + try self.makeGitRepositoryWithWorktree(projectPath: projectA, worktreePath: projectAWorktree) + + func sessionMeta(id: String, cwd: String?) -> [String: Any] { + var payload: [String: Any] = ["id": id] + if let cwd { payload["cwd"] = cwd } + return [ + "type": "session_meta", + "timestamp": iso0, + "payload": payload, + ] + } + + let firstA = try env.writeCodexSessionFile( + day: day, + filename: "client-a-1.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-a-1", cwd: projectA), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 10, cached: 0, output: 1)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "client-a-2.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-a-2", cwd: projectA + "/."), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 20, cached: 0, output: 2)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "client-a-worktree.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-a-worktree", cwd: projectAWorktree), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 12, cached: 0, output: 1)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "client-b.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-b", cwd: projectB), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 5, cached: 0, output: 5)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "unknown.jsonl", + contents: env.jsonl([ + sessionMeta(id: "unknown", cwd: nil), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 7, cached: 0, output: 3)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(report.summary?.totalTokens == 66) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot) + let projectABreakdown = projects.first { $0.path == projectA } + #expect(projectABreakdown?.totalTokens == 46) + #expect(projectABreakdown?.sources.count == 2) + #expect(projectABreakdown?.sources.first(where: { $0.path == projectA })?.totalTokens == 33) + #expect(projectABreakdown?.sources.first(where: { $0.path == projectAWorktree })?.totalTokens == 13) + #expect(projects.first(where: { $0.path == projectB })?.totalTokens == 10) + #expect(projects.first(where: { $0.path == nil })?.name == CostUsageProjectBreakdown.unknownProjectName) + #expect(projects.first(where: { $0.path == nil })?.totalTokens == 10) + #expect(cache.files.values.first(where: { $0.projectPath == projectAWorktree })? + .canonicalProjectPath == projectA) + #expect(cache.codexProjectMetadataVersion == 1) + + let appended = try "\n" + env.jsonl([ + self.codexTokenCount( + timestamp: iso2, + model: model, + total: (input: 15, cached: 0, output: 2)), + ]) + let handle = try FileHandle(forWritingTo: firstA) + try handle.seekToEnd() + try handle.write(contentsOf: Data(appended.utf8)) + try handle.close() + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot) + #expect(projects.first(where: { $0.path == projectA })?.totalTokens == 52) + } + + private func makeGitRepositoryWithWorktree(projectPath: String, worktreePath: String) throws { + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: projectPath, isDirectory: true), + withIntermediateDirectories: true) + try self.runGit(["init", projectPath]) + try self.runGit(["-C", projectPath, "config", "user.email", "codexbar-test@example.com"]) + try self.runGit(["-C", projectPath, "config", "user.name", "CodexBar Test"]) + try self.runGit(["-C", projectPath, "config", "commit.gpgsign", "false"]) + try "test\n".write( + to: URL(fileURLWithPath: projectPath).appendingPathComponent("README.md"), + atomically: false, + encoding: .utf8) + try self.runGit(["-C", projectPath, "add", "README.md"]) + try self.runGit(["-C", projectPath, "commit", "-m", "init"]) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: worktreePath).deletingLastPathComponent(), + withIntermediateDirectories: true) + try self.runGit(["-C", projectPath, "worktree", "add", "-b", "codex-test", worktreePath]) + } + + private func runGit(_ arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git"] + arguments + let output = Pipe() + process.standardOutput = output + process.standardError = output + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + let data = output.fileHandleForReading.readDataToEndOfFile() + let message = String(data: data, encoding: .utf8) ?? "" + throw NSError( + domain: "CodexBarTests.Git", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: message]) + } + } + @Test func `codex incremental append falls back to rescan when fork metadata appears late`() throws { let env = try CostUsageTestEnvironment() @@ -928,6 +1119,99 @@ struct CostUsageScannerBreakdownTests { #expect(repeatedWide.summary?.totalTokens == 32) } + @Test + func `codex project metadata migration drops unscanned legacy files`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let olderDay = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let model = "gpt-5.4" + let olderProject = env.root.appendingPathComponent("older-project", isDirectory: true).path + let currentProject = env.root.appendingPathComponent("current-project", isDirectory: true).path + let olderFile = try env.writeCodexSessionFile( + day: olderDay, + filename: "older-project.jsonl", + contents: env.jsonl([ + self.codexSessionMeta(timestamp: env.isoString(for: olderDay), id: "older", cwd: olderProject), + self.codexTurnContext(timestamp: env.isoString(for: olderDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: olderDay.addingTimeInterval(1)), + model: model, + last: (input: 20, cached: 0, output: 0)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "current-project.jsonl", + contents: env.jsonl([ + self.codexSessionMeta(timestamp: env.isoString(for: day), id: "current", cwd: currentProject), + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + last: (input: 10, cached: 0, output: 0)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let wide = CostUsageScanner.loadDailyReport( + provider: .codex, + since: olderDay, + until: day, + now: day, + options: options) + #expect(wide.summary?.totalTokens == 30) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.codexProjectMetadataVersion = nil + for key in cache.files.keys { + cache.files[key]?.projectPath = nil + cache.files[key]?.canonicalProjectPath = nil + } + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + options.refreshMinIntervalSeconds = 60 + let narrow = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + #expect(narrow.summary?.totalTokens == 10) + + cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(cache.codexProjectMetadataVersion == 1) + #expect(cache.scanSinceKey == "2026-05-17") + #expect(cache.scanUntilKey == "2026-05-19") + #expect(cache.files[olderFile.path] == nil) + let migratedProjects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: olderDay, until: day), + modelsDevCacheRoot: env.cacheRoot) + #expect(!migratedProjects.contains(where: { $0.path == olderProject })) + #expect(migratedProjects.first(where: { $0.path == currentProject })?.totalTokens == 10) + + let repeatedWide = CostUsageScanner.loadDailyReport( + provider: .codex, + since: olderDay, + until: day, + now: day.addingTimeInterval(2), + options: options) + #expect(repeatedWide.summary?.totalTokens == 30) + cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let rescannedProjects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: olderDay, until: day), + modelsDevCacheRoot: env.cacheRoot) + #expect(rescannedProjects.first(where: { $0.path == olderProject })?.totalTokens == 20) + #expect(rescannedProjects.first(where: { $0.path == currentProject })?.totalTokens == 10) + } + @Test func `codex long turn context preserves model attribution`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 30db8bc2fe..32485b0d9b 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -37,6 +37,29 @@ struct StatusMenuHostedSubmenuRefreshTests { #expect(controller.menuAdjunctReadinessSignature() != before) } + @Test + func `project source changes open menu readiness`() { + let settings = Self.makeSettings() + settings.costUsageEnabled = true + Self.enableOnly(settings, provider: .codex) + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(projectSourcePath: "/tmp/main"), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let before = controller.menuAdjunctReadinessSignature() + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(projectSourcePath: "/tmp/worktree"), provider: .codex) + + #expect(controller.menuAdjunctReadinessSignature() != before) + } + @Test func `status submenu link stays scoped to its provider`() throws { let settings = Self.makeSettings() @@ -608,8 +631,31 @@ struct StatusMenuHostedSubmenuRefreshTests { store._setSnapshotForTesting(snapshot, provider: .zai) } - private static func makeTokenSnapshot(dailyCost: Double = 1.23) -> CostUsageTokenSnapshot { - CostUsageTokenSnapshot( + private static func makeTokenSnapshot( + dailyCost: Double = 1.23, + projectSourcePath: String? = nil) -> CostUsageTokenSnapshot + { + let projects = projectSourcePath.map { sourcePath in + [ + CostUsageProjectBreakdown( + name: "Project", + path: "/tmp/main", + totalTokens: 123, + totalCostUSD: dailyCost, + daily: [], + modelBreakdowns: nil, + sources: [ + CostUsageProjectSourceBreakdown( + name: "Source", + path: sourcePath, + totalTokens: 123, + totalCostUSD: dailyCost, + daily: [], + modelBreakdowns: nil), + ]), + ] + } ?? [] + return CostUsageTokenSnapshot( sessionTokens: 123, sessionCostUSD: 0.12, last30DaysTokens: 123, @@ -624,6 +670,7 @@ struct StatusMenuHostedSubmenuRefreshTests { modelsUsed: nil, modelBreakdowns: nil), ], + projects: projects, updatedAt: Date()) } } diff --git a/docs/cli.md b/docs/cli.md index 28809223d6..6e4e245884 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -115,6 +115,7 @@ payloads include the visible account label in `account`. - `sessionTokens`, `sessionCostUSD` - `last30DaysTokens`, `last30DaysCostUSD` - `daily[]`: `date`, `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost`, `modelsUsed`, `modelBreakdowns[]` (`modelName`, `cost`) +- Codex only: `projects[]`: `name`, `path`, `totalTokens`, `totalCost`, `daily[]`, `modelBreakdowns[]`, `sources[]` - `totals`: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost` ## Example usage @@ -126,6 +127,7 @@ codexbar --format json --pretty # machine output codexbar --format json --provider both codexbar cost # local cost usage (default 30-day window + today) codexbar cost --days 90 # choose a 1...365 day cost window +codexbar cost --provider codex --group-by project codexbar cost --provider claude --format json --pretty codexbar serve --port 8080 # localhost HTTP JSON server codexbar serve --request-timeout 0 # disable serve request deadlines From a1da184c6397b32725d5010d442c2b5f8e1eba11 Mon Sep 17 00:00:00 2001 From: Brahim HAMICHAN <98790932+brahimhamichan@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:38:15 +0100 Subject: [PATCH 031/105] Show read-only Codex reset credit inventory (#1690) * feat: show read-only reset credit inventory Co-authored-by: Brahim Hamichan * fix: harden reset credit refresh privacy * fix: isolate reset credit stable IDs * fix: decouple reset credit inventory from display preference * test: isolate reset credit fetch policy coverage * test: keep reset credit coverage focused * fix: complete reset credit localization coverage * docs: clarify reset credit fetch policy * fix: enforce read-only reset credit display * chore: reconcile reset credit changelog * fix: preserve reset credit submenus on macOS 14 * refactor: keep reset credits in hosted card * fix: dedupe reset credit notifications across accounts --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../CodexResetCreditExpiryNotifier.swift | 63 ++++ .../CodexBar/MenuCardHeightFingerprint.swift | 15 +- .../MenuCardView+CodexResetCredits.swift | 130 ++++++--- .../CodexBar/MenuCardView+ModelHelpers.swift | 7 +- Sources/CodexBar/MenuCardView.swift | 14 +- .../PreferencesProviderDetailView.swift | 33 ++- .../Resources/ar.lproj/Localizable.strings | 2 + .../Resources/ca.lproj/Localizable.strings | 2 + .../Resources/de.lproj/Localizable.strings | 2 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/fa.lproj/Localizable.strings | 2 + .../Resources/fr.lproj/Localizable.strings | 2 + .../Resources/gl.lproj/Localizable.strings | 2 + .../Resources/id.lproj/Localizable.strings | 2 + .../Resources/it.lproj/Localizable.strings | 2 + .../Resources/ja.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../Resources/nl.lproj/Localizable.strings | 2 + .../Resources/pl.lproj/Localizable.strings | 2 + .../Resources/pt-BR.lproj/Localizable.strings | 2 + .../Resources/ru.lproj/Localizable.strings | 2 + .../Resources/sv.lproj/Localizable.strings | 2 + .../Resources/th.lproj/Localizable.strings | 2 + .../Resources/tr.lproj/Localizable.strings | 2 + .../Resources/uk.lproj/Localizable.strings | 2 + .../Resources/vi.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + .../CodexBar/StatusItemController+Menu.swift | 11 +- .../UsageStore+CodexResetCredits.swift | 130 +++++++++ Sources/CodexBar/UsageStore+Refresh.swift | 11 +- .../CodexBar/UsageStore+TokenAccounts.swift | 15 +- Sources/CodexBar/UsageStore.swift | 1 + Sources/CodexBarCLI/CLIRenderer.swift | 8 +- Sources/CodexBarCore/CreditsModels.swift | 129 ++++++++- .../CodexOAuth/CodexOAuthCredentials.swift | 66 ++++- .../CodexOAuth/CodexOAuthUsageFetcher.swift | 60 +++- .../Codex/CodexProviderDescriptor.swift | 99 +++++-- Tests/CodexBarTests/CLISnapshotTests.swift | 15 +- .../CodexOAuthResetCreditFetchTests.swift | 161 +++++++++++ Tests/CodexBarTests/CodexOAuthTests.swift | 93 ++---- .../CodexRateLimitResetCreditsTests.swift | 124 +++++++- .../CodexResetCreditExpiryNotifierTests.swift | 107 +++++++ .../CodexResetCreditOutcomeTests.swift | 271 ++++++++++++++++++ .../CodexResetCreditsMenuCardTests.swift | 248 ++++++++-------- .../MenuCardHeightFingerprintTests.swift | 18 +- docs/codex.md | 5 + 49 files changed, 1572 insertions(+), 309 deletions(-) create mode 100644 Sources/CodexBar/CodexResetCreditExpiryNotifier.swift create mode 100644 Sources/CodexBar/UsageStore+CodexResetCredits.swift create mode 100644 Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift create mode 100644 Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift create mode 100644 Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f5baffd18..5657416a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.38.2 — Unreleased ### Added +- Codex: show every available reset-credit expiry in menus and provider settings, including non-expiring credits, and summarize credits nearing expiry. Thanks @brahimhamichan! - 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! 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/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 9e1962b85c..54d0592e24 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 ?? "")", @@ -119,6 +118,18 @@ extension UsageMenuCardView.Model.TokenUsageSection { } } +extension CodexResetCreditsPresentation { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("text", self.text), + MenuCardHeightFingerprint.field("detail", self.detailText), + MenuCardHeightFingerprint.join(self.items.map { + MenuCardHeightFingerprint.field("expiry", $0.expiryText) + }), + ]) + } +} + extension InlineUsageDashboardModel { fileprivate var heightFingerprint: String { MenuCardHeightFingerprint.join([ diff --git a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift index 68d6248e68..8f90608e4f 100644 --- a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift +++ b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift @@ -1,11 +1,89 @@ import CodexBarCore import SwiftUI -struct CodexResetCreditsContent: View { +struct CodexResetCreditPresentationItem: Equatable { + let expiryText: String +} + +struct CodexResetCreditsPresentation: Equatable { let text: String let detailText: String? + let items: [CodexResetCreditPresentationItem] + + 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 + CodexResetCreditPresentationItem( + expiryText: Self.expiryText(for: credit, resetStyle: resetStyle, now: now)) + } + let detailText = inventory.nextExpiringCredit.flatMap { credit in + credit.expiresAt.map { expiresAt in + String( + format: L("Next expires %@"), + Self.formattedTime(expiresAt, resetStyle: resetStyle, now: now)) + } + } ?? (inventory.credits.allSatisfy { $0.expiresAt == nil } ? L("No expiry") : nil) + return CodexResetCreditsPresentation( + text: Self.availableText(count: inventory.count), + detailText: detailText, + items: items) + } + + private static func availableText(count: Int) -> String { + count == 1 ? L("1 available") : String(format: L("%d available"), count) + } + + private static func expiryText( + for credit: CodexRateLimitResetCredit, + resetStyle: ResetTimeDisplayStyle, + now: Date) -> String + { + guard let expiresAt = credit.expiresAt else { return L("No expiry") } + return String( + format: L("Expires %@"), + Self.formattedTime(expiresAt, resetStyle: resetStyle, now: now)) + } + + 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 + nonisolated static func expiryRows(_ presentation: CodexResetCreditsPresentation) -> [String] { + presentation.items.map(\.expiryText) + } + var body: some View { VStack(alignment: .leading, spacing: 4) { Text(L("Limit Reset Credits")) @@ -13,60 +91,44 @@ struct CodexResetCreditsContent: View { .fontWeight(.medium) .lineLimit(1) HStack(alignment: .firstTextBaseline) { - Text(self.text) + Text(self.presentation.text) .font(.footnote.weight(.semibold)) .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) .lineLimit(1) .layoutPriority(1) Spacer() - if let detailText, !detailText.isEmpty { + if let detailText = self.presentation.detailText, !detailText.isEmpty { Text(detailText) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) } } + ForEach(Array(Self.expiryRows(self.presentation).enumerated()), id: \.offset) { index, expiryText in + Text("\(index + 1). \(expiryText)") + .font(.caption) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .trailing) + } } .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? { - guard input.provider == .codex, - let resetCredits = input.snapshot?.codexResetCredits, - resetCredits.availableCount > 0 - 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? { + static func codexResetCredits(input: Input) -> CodexResetCreditsPresentation? { guard input.provider == .codex, - let resetCredits = input.snapshot?.codexResetCredits, - let expiresAt = resetCredits.nextExpiringAvailableCredit?.expiresAt + let resetCredits = input.snapshot?.codexResetCredits 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+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 1b4a1e0b65..910cceeb42 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -65,14 +65,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 +107,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), diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 0e4f180842..9cadc51e12 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -112,8 +112,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? @@ -549,20 +548,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)) @@ -863,8 +860,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, diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index ff84d554cf..a27e18d7b7 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -333,8 +333,9 @@ struct ProviderMetricsInlineView: View { let hasCredits = self.model.creditsText != nil 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, !hasCredits, !hasTokenUsage, !hasResetCredits { Text(self.placeholderText) .font(.footnote) .foregroundStyle(.secondary) @@ -362,6 +363,10 @@ struct ProviderMetricsInlineView: View { ProviderDetailInfoRow(label: L("Credits"), value: credits) } + if let resetCredits = self.model.codexResetCredits { + ProviderCodexResetCreditsInlineRow(presentation: resetCredits) + } + if let providerCost = self.model.providerCost { ProviderMetricInlineCostRow( section: providerCost, @@ -463,6 +468,32 @@ 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) + } + ForEach(Array(self.presentation.items.enumerated()), id: \.offset) { _, item in + Text(item.expiryText) + .font(.caption) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .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/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index dd74cc6042..ea594812bc 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1130,6 +1130,8 @@ "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 1217458dee..c4a19dfeec 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -974,6 +974,8 @@ "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 75139c8ba8..b8d9c1fc13 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1120,6 +1120,8 @@ "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 486176c315..ea2f3d119d 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1131,6 +1131,8 @@ "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 059122ac0c..0b8ff4a511 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -980,6 +980,8 @@ "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 0ff879eea6..065e9a94ca 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1130,6 +1130,8 @@ "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 10126b9982..b62e4e8e00 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1120,6 +1120,8 @@ "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 index 0897317896..3604c7e51f 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -969,6 +969,8 @@ "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"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 881099f896..0596adeb7c 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1129,6 +1129,8 @@ "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 98fab71602..647f20d0b3 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1129,6 +1129,8 @@ "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 e6193ab0df..984a0e7b6a 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1121,6 +1121,8 @@ "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 8be911dce8..61f34a0835 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1089,6 +1089,8 @@ "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 f9ea445940..cbaada05b8 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1120,6 +1120,8 @@ "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 c263d474c5..a6cbcf0549 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1129,6 +1129,8 @@ "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 8e87d55141..7283aa36aa 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1121,6 +1121,8 @@ "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 index 1eab0a1920..d1d99a914b 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1131,6 +1131,8 @@ "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/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 7bbe6684c8..f819599ff9 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1119,6 +1119,8 @@ "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 322ae5b4ca..b64368d757 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1130,6 +1130,8 @@ "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 ad552962b1..ff36b8a6b1 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1127,6 +1127,8 @@ "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 0949658872..74a46b6524 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1120,6 +1120,8 @@ "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 2fbe38c9d1..fa3a84b6f7 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1121,6 +1121,8 @@ "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 1abdd45a01..e506486577 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1098,6 +1098,8 @@ "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 700ab17d6e..f42532589c 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -993,6 +993,8 @@ "1 available" = "1 次可用"; "%d available" = "%d 次可用"; "Next expires %@" = "下一個將於 %@ 到期"; +"Expires %@" = "%@ 到期"; +"No expiry" = "永不過期"; "Other (%d items)" = "其他(%d 個項目)"; "Expand" = "展開"; "Collapse" = "收合"; diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 714dbd2791..22f621439f 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1233,25 +1233,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 +1277,6 @@ extension StatusItemController { containsInteractiveControls: true)) } - if hasStorage || hasCredits || hasExtraUsage || hasCost { - addSectionSeparator() - } - if self.addStorageMenuCardSection(to: menu, provider: provider, width: width), hasCredits || hasExtraUsage { diff --git a/Sources/CodexBar/UsageStore+CodexResetCredits.swift b/Sources/CodexBar/UsageStore+CodexResetCredits.swift new file mode 100644 index 0000000000..ea2535a2f4 --- /dev/null +++ b/Sources/CodexBar/UsageStore+CodexResetCredits.swift @@ -0,0 +1,130 @@ +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)), + attempts: self.attempts) + } + + fileprivate func replacingResult( + with result: Result) -> ProviderFetchOutcome + { + ProviderFetchOutcome(result: result, attempts: self.attempts) + } +} diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 87e8e3c63b..ac0e0b8917 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -176,13 +176,19 @@ 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()! } @@ -256,6 +262,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) { 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.swift b/Sources/CodexBar/UsageStore.swift index 0d0211a806..e392da775f 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -206,6 +206,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)? diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index ad37803d2d..69f6978acf 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -345,14 +345,14 @@ enum CLIRenderer { lines: inout [String]) { guard provider == .codex, let resetCredits = snapshot.codexResetCredits else { return } - let value = if resetCredits.availableCount == 1 { + let inventory = resetCredits.availableInventory(at: now) + let value = if inventory.count == 1 { "1 available" } else { - "\(resetCredits.availableCount) available" + "\(inventory.count) available" } lines.append(self.labelValueLine("Limit Reset Credits", value: value, useColor: useColor)) - guard resetCredits.availableCount > 0, - let expiresAt = resetCredits.nextExpiringAvailableCredit?.expiresAt + guard let expiresAt = inventory.nextExpiringCredit?.expiresAt else { return } diff --git a/Sources/CodexBarCore/CreditsModels.swift b/Sources/CodexBarCore/CreditsModels.swift index cab9dc55d2..eaa4dc7b21 100644 --- a/Sources/CodexBarCore/CreditsModels.swift +++ b/Sources/CodexBarCore/CreditsModels.swift @@ -1,4 +1,9 @@ import Foundation +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif public struct CreditEvent: Identifiable, Equatable, Codable, Sendable { public var id: UUID @@ -101,19 +106,53 @@ public struct CodexRateLimitResetCreditsSnapshot: Equatable, Codable, Sendable { } public var nextExpiringAvailableCredit: CodexRateLimitResetCredit? { - self.credits + self.availableInventory(at: self.updatedAt).nextExpiringCredit + } + + public func availableInventory(at date: Date) -> CodexRateLimitResetCreditInventory { + CodexRateLimitResetCreditInventory(credits: self.credits, at: date) + } + + public func availableCredits(at date: Date) -> [CodexRateLimitResetCredit] { + self.availableInventory(at: date).credits + } +} + +public struct CodexRateLimitResetCreditInventory: Equatable, Sendable { + public let credits: [CodexRateLimitResetCredit] + + public var count: Int { + self.credits.count + } + + public var nextExpiringCredit: CodexRateLimitResetCredit? { + self.credits.first { $0.expiresAt != nil } + } + + public init(credits: [CodexRateLimitResetCredit], at date: Date) { + self.credits = credits .filter { credit in - credit.status == .available && (credit.expiresAt ?? .distantPast) > self.updatedAt + credit.status == .available && (credit.expiresAt.map { $0 > date } ?? true) } - .min { lhs, rhs in - guard let lhsExpiresAt = lhs.expiresAt else { return false } - guard let rhsExpiresAt = rhs.expiresAt else { return true } - return lhsExpiresAt < rhsExpiresAt + .sorted { lhs, rhs in + switch (lhs.expiresAt, rhs.expiresAt) { + case let (lhsDate?, rhsDate?): + if lhsDate != rhsDate { return lhsDate < rhsDate } + case (_?, nil): + return true + case (nil, _?): + return false + case (nil, nil): + break + } + return lhs.id < rhs.id } } } public struct CodexRateLimitResetCredit: Equatable, Codable, Sendable, Identifiable { + private static let stableIDPrefix = "codex-reset-credit-v1-" + public let id: String public let resetType: String public let status: CodexRateLimitResetCreditStatus @@ -147,7 +186,75 @@ public struct CodexRateLimitResetCredit: Equatable, Codable, Sendable, Identifia title: String?, description: String?) { - self.id = id + self.id = Self.stableID(forProviderID: id) + self.resetType = resetType + self.status = status + self.grantedAt = grantedAt + self.expiresAt = expiresAt + self.redeemStartedAt = redeemStartedAt + self.redeemedAt = redeemedAt + self.title = title + self.description = description + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedID = try container.decode(String.self, forKey: .id) + let resetType = try container.decode(String.self, forKey: .resetType) + let status = try container.decode(CodexRateLimitResetCreditStatus.self, forKey: .status) + let grantedAt = try container.decode(Date.self, forKey: .grantedAt) + let expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt) + let redeemStartedAt = try container.decodeIfPresent(Date.self, forKey: .redeemStartedAt) + let redeemedAt = try container.decodeIfPresent(Date.self, forKey: .redeemedAt) + let title = try container.decodeIfPresent(String.self, forKey: .title) + let description = try container.decodeIfPresent(String.self, forKey: .description) + + if Self.isCanonicalStableID(decodedID) { + self.init( + persistedStableID: decodedID, + resetType: resetType, + status: status, + grantedAt: grantedAt, + expiresAt: expiresAt, + redeemStartedAt: redeemStartedAt, + redeemedAt: redeemedAt, + title: title, + description: description) + } else { + self.init( + id: decodedID, + resetType: resetType, + status: status, + grantedAt: grantedAt, + expiresAt: expiresAt, + redeemStartedAt: redeemStartedAt, + redeemedAt: redeemedAt, + title: title, + description: description) + } + } + + static func stableID(forProviderID providerID: String) -> String { + let domainSeparatedValue = "com.steipete.CodexBar.reset-credit-id.v1\0\(providerID)" + let digest = SHA256.hash(data: Data(domainSeparatedValue.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return Self.stableIDPrefix + digest + } + + private init( + persistedStableID: String, + resetType: String, + status: CodexRateLimitResetCreditStatus, + grantedAt: Date, + expiresAt: Date?, + redeemStartedAt: Date?, + redeemedAt: Date?, + title: String?, + description: String?) + { + precondition(Self.isCanonicalStableID(persistedStableID)) + self.id = persistedStableID self.resetType = resetType self.status = status self.grantedAt = grantedAt @@ -157,6 +264,14 @@ public struct CodexRateLimitResetCredit: Equatable, Codable, Sendable, Identifia self.title = title self.description = description } + + private static func isCanonicalStableID(_ id: String) -> Bool { + guard id.hasPrefix(self.stableIDPrefix) else { return false } + let digest = id.dropFirst(Self.stableIDPrefix.count) + return digest.utf8.count == 64 && digest.utf8.allSatisfy { byte in + (48...57).contains(byte) || (97...102).contains(byte) + } + } } public enum CodexRateLimitResetCreditStatus: Equatable, Codable, Sendable { diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index bb0902d2dd..7c180daaa7 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -74,33 +74,57 @@ public enum CodexOAuthCredentialsStore { return try self.parse(data: data) } + public static func loadOAuthTokens(env: [String: String] = ProcessInfo.processInfo + .environment) throws -> CodexOAuthCredentials + { + let url = self.authFilePath(env: env) + guard FileManager.default.fileExists(atPath: url.path) else { + throw CodexOAuthCredentialsError.notFound + } + + let data = try Data(contentsOf: url) + guard let credentials = try self.tokenCredentials(data: data) else { + throw CodexOAuthCredentialsError.missingTokens + } + return credentials + } + public static func parse(data: Data) throws -> CodexOAuthCredentials { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON") } - if let apiKey = json["OPENAI_API_KEY"] as? String, - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return CodexOAuthCredentials( - accessToken: apiKey, - refreshToken: "", - idToken: nil, - accountId: nil, - lastRefresh: nil) + if let apiKeyCredentials = Self.apiKeyCredentials(in: json) { + return apiKeyCredentials } - guard let tokens = json["tokens"] as? [String: Any] else { - throw CodexOAuthCredentialsError.missingTokens + if let tokenCredentials = Self.tokenCredentials(in: json) { + return tokenCredentials } - guard let accessToken = Self.stringValue(in: tokens, snakeCaseKey: "access_token", camelCaseKey: "accessToken"), - let refreshToken = Self.stringValue( + + throw CodexOAuthCredentialsError.missingTokens + } + + private static func tokenCredentials(data: Data) throws -> CodexOAuthCredentials? { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON") + } + return self.tokenCredentials(in: json) + } + + private static func tokenCredentials(in json: [String: Any]) -> CodexOAuthCredentials? { + guard let tokens = json["tokens"] as? [String: Any], + let accessToken = stringValue( + in: tokens, + snakeCaseKey: "access_token", + camelCaseKey: "accessToken"), + let refreshToken = stringValue( in: tokens, snakeCaseKey: "refresh_token", camelCaseKey: "refreshToken"), !accessToken.isEmpty else { - throw CodexOAuthCredentialsError.missingTokens + return nil } let idToken = Self.stringValue(in: tokens, snakeCaseKey: "id_token", camelCaseKey: "idToken") @@ -115,6 +139,20 @@ public enum CodexOAuthCredentialsStore { lastRefresh: lastRefresh) } + private static func apiKeyCredentials(in json: [String: Any]) -> CodexOAuthCredentials? { + guard let apiKey = json["OPENAI_API_KEY"] as? String, + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + return CodexOAuthCredentials( + accessToken: apiKey, + refreshToken: "", + idToken: nil, + accountId: nil, + lastRefresh: nil) + } + public static func save( _ credentials: CodexOAuthCredentials, env: [String: String] = ProcessInfo.processInfo.environment) throws diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift index 128a32d8fe..382d77109f 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift @@ -381,7 +381,12 @@ public enum CodexOAuthUsageFetcher { } } catch let error as CodexOAuthFetchError { throw error + } catch is CancellationError { + throw CancellationError() } catch { + if Task.isCancelled || (error as? URLError)?.code == .cancelled { + throw CancellationError() + } throw CodexOAuthFetchError.networkError(error) } } @@ -420,7 +425,7 @@ public enum CodexOAuthUsageFetcher { throw CodexOAuthFetchError.invalidResponse } return CodexRateLimitResetCreditsSnapshot( - credits: payload.credits, + credits: payload.credits.map(\.model), availableCount: payload.availableCount, updatedAt: Date()) } catch { @@ -434,7 +439,12 @@ public enum CodexOAuthUsageFetcher { } } catch let error as CodexOAuthFetchError { throw error + } catch is CancellationError { + throw CancellationError() } catch { + if Task.isCancelled || (error as? URLError)?.code == .cancelled { + throw CancellationError() + } throw CodexOAuthFetchError.networkError(error) } } @@ -518,7 +528,7 @@ public enum CodexOAuthUsageFetcher { } private struct RateLimitResetCreditsResponse: Decodable { - let credits: [CodexRateLimitResetCredit] + let credits: [RateLimitResetCreditResponse] let availableCount: Int private enum CodingKeys: String, CodingKey { @@ -527,6 +537,43 @@ public enum CodexOAuthUsageFetcher { } } + private struct RateLimitResetCreditResponse: Decodable { + let id: String + let resetType: String + let status: CodexRateLimitResetCreditStatus + let grantedAt: Date + let expiresAt: Date? + let redeemStartedAt: Date? + let redeemedAt: Date? + let title: String? + let description: String? + + private enum CodingKeys: String, CodingKey { + case id + case resetType = "reset_type" + case status + case grantedAt = "granted_at" + case expiresAt = "expires_at" + case redeemStartedAt = "redeem_started_at" + case redeemedAt = "redeemed_at" + case title + case description + } + + var model: CodexRateLimitResetCredit { + CodexRateLimitResetCredit( + id: self.id, + resetType: self.resetType, + status: self.status, + grantedAt: self.grantedAt, + expiresAt: self.expiresAt, + redeemStartedAt: self.redeemStartedAt, + redeemedAt: self.redeemedAt, + title: self.title, + description: self.description) + } + } + private static func decodeISO8601Date(from decoder: Decoder) throws -> Date { let container = try decoder.singleValueContainer() let raw = try container.decode(String.self) @@ -560,14 +607,17 @@ extension CodexOAuthUsageFetcher { self.resolveRateLimitResetCreditsURL(env: env, configContents: configContents) } - static func _decodeRateLimitResetCreditsForTesting(_ data: Data) throws -> CodexRateLimitResetCreditsSnapshot { + static func _decodeRateLimitResetCreditsForTesting( + _ data: Data, + now: Date = Date()) throws -> CodexRateLimitResetCreditsSnapshot + { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom(Self.decodeISO8601Date) let payload = try decoder.decode(RateLimitResetCreditsResponse.self, from: data) return CodexRateLimitResetCreditsSnapshot( - credits: payload.credits, + credits: payload.credits.map(\.model), availableCount: payload.availableCount, - updatedAt: Date()) + updatedAt: now) } } #endif diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index d9dcb48de5..5a15ee161a 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -178,30 +178,22 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { accessToken: credentials.accessToken, accountId: credentials.accountId, env: context.env) - let resetCredits: CodexRateLimitResetCreditsSnapshot? = if Self.shouldFetchResetCredits(context) { - try? await CodexOAuthUsageFetcher.fetchRateLimitResetCredits( - accessToken: credentials.accessToken, - accountId: credentials.accountId, - env: context.env) - } else { - nil - } + let resetCredits = try await Self.fetchResetCreditsIfRequested( + context: context, + credentials: credentials) let updatedAt = Date() let oauthResult = try Self.makeResult( usageResponse: usage, resetCredits: resetCredits, credentials: credentials, - updatedAt: updatedAt) + updatedAt: updatedAt, + allowEmptyUsageForResetCreditEnrichment: Self.defersResetCreditFetchToApp(context)) return try await Self.replacingWithCLIMonthlyLimitIfAvailable(oauthResult, context: context) } private static func shouldFetchResetCredits(_ context: ProviderFetchContext) -> Bool { - switch context.runtime { - case .app: - true - case .cli: - context.includeCredits - } + guard case .cli = context.runtime else { return false } + return context.includeCredits } func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { @@ -253,7 +245,8 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { usageResponse: CodexUsageResponse, resetCredits: CodexRateLimitResetCreditsSnapshot? = nil, credentials: CodexOAuthCredentials, - updatedAt: Date) throws -> ProviderFetchResult + updatedAt: Date, + allowEmptyUsageForResetCreditEnrichment: Bool = false) throws -> ProviderFetchResult { let credits = Self.mapCredits(response: usageResponse, updatedAt: updatedAt) let reconciled = CodexReconciledState.fromOAuth( @@ -274,7 +267,10 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { sourceLabel: "oauth") } - guard credits != nil || (resetCredits?.availableCount ?? 0) > 0 else { + guard credits != nil + || (resetCredits?.availableInventory(at: updatedAt).count ?? 0) > 0 + || allowEmptyUsageForResetCreditEnrichment + else { throw UsageError.noRateLimitsFound } @@ -328,6 +324,43 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { strategyKind: oauthResult.strategyKind) } + private static func fetchResetCreditsIfRequested( + context: ProviderFetchContext, + credentials: CodexOAuthCredentials) async throws -> CodexRateLimitResetCreditsSnapshot? + { + try await self.fetchResetCreditsIfRequested( + context: context, + credentials: credentials, + fetcher: { credentials in + try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits( + accessToken: credentials.accessToken, + accountId: credentials.accountId, + env: context.env) + }) + } + + private static func defersResetCreditFetchToApp(_ context: ProviderFetchContext) -> Bool { + if case .app = context.runtime { return true } + return false + } + + private static func fetchResetCreditsIfRequested( + context: ProviderFetchContext, + credentials: CodexOAuthCredentials, + fetcher: @escaping @Sendable (CodexOAuthCredentials) async throws + -> CodexRateLimitResetCreditsSnapshot) async throws -> CodexRateLimitResetCreditsSnapshot? + { + guard self.shouldFetchResetCredits(context) else { return nil } + // The app enriches the winning outcome once in UsageStore. One-shot CLI callers do not + // pass through that app layer, so only their explicit credits flag reaches this request. + do { + return try await fetcher(credentials) + } catch { + if error is CancellationError || Task.isCancelled { throw CancellationError() } + return nil + } + } + private static func identitiesAreCompatible( oauth: ProviderIdentitySnapshot?, cli: ProviderIdentitySnapshot?) -> Bool @@ -349,10 +382,30 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { guard let credits = result.credits else { return false } return credits.remaining == 0 && credits.codexCreditLimit == nil - && (result.usage.codexResetCredits?.availableCount ?? 0) == 0 + && (result.usage.codexResetCredits?.availableInventory(at: result.usage.updatedAt).count ?? 0) == 0 } } +#if DEBUG +extension CodexOAuthFetchStrategy { + static func _fetchResetCreditsForTesting( + context: ProviderFetchContext, + credentials: CodexOAuthCredentials, + fetcher: @escaping @Sendable (CodexOAuthCredentials) async throws + -> CodexRateLimitResetCreditsSnapshot) async throws -> CodexRateLimitResetCreditsSnapshot? + { + try await self.fetchResetCreditsIfRequested( + context: context, + credentials: credentials, + fetcher: fetcher) + } + + static func _shouldFetchResetCreditsForTesting(_ context: ProviderFetchContext) -> Bool { + self.shouldFetchResetCredits(context) + } +} +#endif + #if DEBUG extension CodexOAuthFetchStrategy { static func _mapUsageForTesting(_ data: Data, credentials: CodexOAuthCredentials) throws -> UsageSnapshot? { @@ -364,7 +417,8 @@ extension CodexOAuthFetchStrategy { _ data: Data, credentials: CodexOAuthCredentials, resetCredits: CodexRateLimitResetCreditsSnapshot? = nil, - sourceMode: ProviderSourceMode = .oauth) throws -> ProviderFetchResult + sourceMode: ProviderSourceMode = .oauth, + allowEmptyUsageForResetCreditEnrichment: Bool = false) throws -> ProviderFetchResult { let usageResponse = try JSONDecoder().decode(CodexUsageResponse.self, from: data) _ = sourceMode @@ -372,7 +426,8 @@ extension CodexOAuthFetchStrategy { usageResponse: usageResponse, resetCredits: resetCredits, credentials: credentials, - updatedAt: Date()) + updatedAt: Date(), + allowEmptyUsageForResetCreditEnrichment: allowEmptyUsageForResetCreditEnrichment) } static func _replaceWithCLIMonthlyLimitForTesting( @@ -389,10 +444,6 @@ extension CodexOAuthFetchStrategy { static func _shouldTryCLIForMonthlyLimitForTesting(_ result: ProviderFetchResult) -> Bool { self.shouldTryCLIForMonthlyLimit(result) } - - static func _shouldFetchResetCreditsForTesting(_ context: ProviderFetchContext) -> Bool { - self.shouldFetchResetCredits(context) - } } extension CodexProviderDescriptor { diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index cf5eee9d37..5d83a206de 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -94,7 +94,8 @@ struct CLISnapshotTests { @Test func `renders Codex limit reset credits`() { - let expiresAt = Date().addingTimeInterval(7200) + let now = Date() + let expiresAt = now.addingTimeInterval(7200) let resetCredits = CodexRateLimitResetCreditsSnapshot( credits: [ CodexRateLimitResetCredit( @@ -107,8 +108,18 @@ struct CLISnapshotTests { redeemedAt: nil, title: nil, description: nil), + CodexRateLimitResetCredit( + id: "expired-credit", + resetType: "codex_rate_limits", + status: .available, + grantedAt: Date(timeIntervalSince1970: 0), + expiresAt: now, + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil), ], - availableCount: 1, + availableCount: 99, updatedAt: Date(timeIntervalSince1970: 0)) let snapshot = UsageSnapshot( primary: .init(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), diff --git a/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift b/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift new file mode 100644 index 0000000000..356c1a5a50 --- /dev/null +++ b/Tests/CodexBarTests/CodexOAuthResetCreditFetchTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexOAuthResetCreditFetchTests { + @Test + func `app enrichment can rescue reset-credit-only O auth usage`() throws { + let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"# + let result = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: Self.credentials(), + allowEmptyUsageForResetCreditEnrichment: true) + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary == nil) + #expect(result.usage.codexResetCredits == nil) + #expect(result.credits == nil) + #expect(result.strategyID == "codex.oauth") + } + + @Test + func `app defers reset credit GET while CLI attempts it once on failure`() async throws { + let credentials = Self.credentials() + let recorder = CodexOAuthResetCreditFetchRecorder() + let fetcher: @Sendable (CodexOAuthCredentials) async throws -> CodexRateLimitResetCreditsSnapshot = { _ in + await recorder.recordRequest() + throw CodexOAuthFetchError.serverError(500, nil) + } + + let appResult = try await CodexOAuthFetchStrategy._fetchResetCreditsForTesting( + context: Self.context(runtime: .app), + credentials: credentials, + fetcher: fetcher) + #expect(appResult == nil) + #expect(await recorder.requestCount() == 0) + + let cliResult = try await CodexOAuthFetchStrategy._fetchResetCreditsForTesting( + context: Self.context(runtime: .cli), + credentials: credentials, + fetcher: fetcher) + #expect(cliResult == nil) + #expect(await recorder.requestCount() == 1) + } + + @Test + func `CLI reset credit GET preserves cancellation without retry`() async throws { + let recorder = CodexOAuthResetCreditFetchRecorder() + + await #expect(throws: CancellationError.self) { + _ = try await CodexOAuthFetchStrategy._fetchResetCreditsForTesting( + context: Self.context(runtime: .cli), + credentials: Self.credentials(), + fetcher: { _ in + await recorder.recordRequest() + throw CancellationError() + }) + } + #expect(await recorder.requestCount() == 1) + } + + @Test + func `reset credit inventory only O auth payload still returns usage result`() throws { + let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"# + let now = Date() + let resetCredits = CodexRateLimitResetCreditsSnapshot( + credits: [ + CodexRateLimitResetCredit( + id: "available-no-expiry", + resetType: "codex_rate_limits", + status: .available, + grantedAt: now, + expiresAt: nil, + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil), + ], + availableCount: 1, + updatedAt: now) + + let result = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: Self.credentials(), + resetCredits: resetCredits) + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary == nil) + #expect(result.usage.codexResetCredits?.availableInventory(at: now).count == 1) + #expect(result.credits == nil) + #expect(result.sourceLabel == "oauth") + } + + @Test + func `empty reset credits do not mask missing O auth usage`() { + let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"# + let resetCredits = CodexRateLimitResetCreditsSnapshot( + credits: [], + availableCount: 0, + updatedAt: Date()) + + #expect(throws: UsageError.self) { + try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: Self.credentials(), + resetCredits: resetCredits) + } + } + + @Test + func `O auth strategy defers app inventory and CLI follows credits flag`() { + let appContext = Self.context(runtime: .app, includeCredits: false, includeOptionalUsage: false) + let cliNoCreditsContext = Self.context(runtime: .cli, includeCredits: false, includeOptionalUsage: true) + let cliCreditsContext = Self.context(runtime: .cli, includeCredits: true, includeOptionalUsage: false) + + #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(appContext) == false) + #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliNoCreditsContext) == false) + #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliCreditsContext)) + } + + private static func context( + runtime: ProviderRuntime, + includeCredits: Bool = true, + includeOptionalUsage: Bool = false) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: runtime, + sourceMode: .auto, + includeCredits: includeCredits, + includeOptionalUsage: includeOptionalUsage, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + private static func credentials() -> CodexOAuthCredentials { + CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: "account-123", + lastRefresh: Date()) + } +} + +private actor CodexOAuthResetCreditFetchRecorder { + private var count = 0 + + func recordRequest() { + self.count += 1 + } + + func requestCount() -> Int { + self.count + } +} diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index 2a36905b5c..d6a338b038 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -83,6 +83,32 @@ struct CodexOAuthTests { #expect(creds.accountId == nil) } + @Test + func `reset-credit token load ignores an API key beside O auth tokens`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-reset-credit-oauth-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let json = """ + { + "OPENAI_API_KEY": "sk-test", + "tokens": { + "access_token": "oauth-access-token", + "refresh_token": "oauth-refresh-token", + "account_id": "account-123" + }, + "last_refresh": "2026-07-01T12:00:00Z" + } + """ + try Data(json.utf8).write(to: home.appendingPathComponent("auth.json")) + + let credentials = try CodexOAuthCredentialsStore.loadOAuthTokens(env: ["CODEX_HOME": home.path]) + + #expect(credentials.accessToken == "oauth-access-token") + #expect(credentials.refreshToken == "oauth-refresh-token") + #expect(credentials.accountId == "account-123") + } + @Test func `decodes credits balance string`() throws { let json = """ @@ -703,56 +729,6 @@ struct CodexOAuthTests { #expect(result.sourceLabel == "oauth") } - @Test - func `reset credits only O auth payload still returns usage result`() throws { - let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"# - let now = Date() - let resetCredits = CodexRateLimitResetCreditsSnapshot( - credits: [], - availableCount: 2, - updatedAt: now) - let creds = CodexOAuthCredentials( - accessToken: "access", - refreshToken: "refresh", - idToken: nil, - accountId: nil, - lastRefresh: now) - - let result = try CodexOAuthFetchStrategy._mapResultForTesting( - Data(json.utf8), - credentials: creds, - resetCredits: resetCredits) - - #expect(result.usage.primary == nil) - #expect(result.usage.secondary == nil) - #expect(result.usage.codexResetCredits?.availableCount == 2) - #expect(result.credits == nil) - #expect(result.sourceLabel == "oauth") - } - - @Test - func `empty reset credits do not mask missing O auth usage`() { - let json = #"{"rate_limit":{"primary_window":null,"secondary_window":null}}"# - let now = Date() - let resetCredits = CodexRateLimitResetCreditsSnapshot( - credits: [], - availableCount: 0, - updatedAt: now) - let creds = CodexOAuthCredentials( - accessToken: "access", - refreshToken: "refresh", - idToken: nil, - accountId: nil, - lastRefresh: now) - - #expect(throws: UsageError.self) { - try CodexOAuthFetchStrategy._mapResultForTesting( - Data(json.utf8), - credentials: creds, - resetCredits: resetCredits) - } - } - @Test func `auto mode only falls back from O auth on auth failures`() { let strategy = CodexOAuthFetchStrategy() @@ -777,23 +753,6 @@ struct CodexOAuthTests { context: context)) } - @Test - func `reset credits fetch follows app runtime and CLI credits flag`() { - let appContext = self.makeContext(includeCredits: false, includeOptionalUsage: false) - let cliNoCreditsContext = self.makeContext( - runtime: .cli, - includeCredits: false, - includeOptionalUsage: true) - let cliCreditsContext = self.makeContext( - runtime: .cli, - includeCredits: true, - includeOptionalUsage: false) - - #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(appContext)) - #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliNoCreditsContext) == false) - #expect(CodexOAuthFetchStrategy._shouldFetchResetCreditsForTesting(cliCreditsContext)) - } - @Test func `non 401 invalid grant refresh failure is treated as revoked`() { let data = Data(#"{"error":"invalid_grant"}"#.utf8) diff --git a/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift b/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift index cade97d1a5..c3e407cf4f 100644 --- a/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift +++ b/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift @@ -120,12 +120,132 @@ struct CodexRateLimitResetCreditsTests { } """ - let snapshot = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting(Data(json.utf8)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-01T00:00:00Z")) + let snapshot = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting( + Data(json.utf8), + now: now) #expect(snapshot.availableCount == 2) #expect(snapshot.credits.count == 4) #expect(snapshot.credits[0].resetType == "codex_rate_limits") #expect(snapshot.credits[3].status == .unknown("future_status")) - #expect(snapshot.nextExpiringAvailableCredit?.id == "RateLimitResetCredit_earlier") + #expect(snapshot.nextExpiringAvailableCredit?.id == CodexRateLimitResetCredit.stableID( + forProviderID: "RateLimitResetCredit_earlier")) + #expect(snapshot.credits.allSatisfy { !$0.id.contains("RateLimitResetCredit_") }) + + let usage = UsageSnapshot( + primary: nil, + secondary: nil, + codexResetCredits: snapshot, + updatedAt: now) + let encoded = try JSONEncoder().encode(usage) + let encodedText = try #require(String(data: encoded, encoding: .utf8)) + #expect(!encodedText.contains("RateLimitResetCredit_earlier")) + #expect(!String(reflecting: usage).contains("RateLimitResetCredit_earlier")) + + let roundTripped = try JSONDecoder().decode(UsageSnapshot.self, from: encoded) + #expect(roundTripped.codexResetCredits?.credits.map(\.id) == snapshot.credits.map(\.id)) + } + + @Test + func `available inventory keeps no-expiry credits and sorts deterministically`() { + let now = Date(timeIntervalSince1970: 1_788_134_400) + let tiedExpiry = now.addingTimeInterval(3600) + let snapshot = CodexRateLimitResetCreditsSnapshot( + credits: [ + Self.credit(id: "nil-b", status: .available, expiresAt: nil), + Self.credit(id: "expired", status: .available, expiresAt: now), + Self.credit(id: "finite-b", status: .available, expiresAt: tiedExpiry), + Self.credit(id: "redeemed", status: .redeemed, expiresAt: now.addingTimeInterval(7200)), + Self.credit(id: "nil-a", status: .available, expiresAt: nil), + Self.credit(id: "finite-a", status: .available, expiresAt: tiedExpiry), + ], + availableCount: 99, + updatedAt: now) + + let inventory = snapshot.availableInventory(at: now) + + #expect(inventory.count == 4) + let expectedFiniteIDs = ["finite-a", "finite-b"] + .map(CodexRateLimitResetCredit.stableID(forProviderID:)) + .sorted() + let expectedNoExpiryIDs = ["nil-a", "nil-b"] + .map(CodexRateLimitResetCredit.stableID(forProviderID:)) + .sorted() + #expect(inventory.credits.map(\.id) == expectedFiniteIDs + expectedNoExpiryIDs) + #expect(inventory.nextExpiringCredit?.id == expectedFiniteIDs.first) + } + + @Test + func `provider IDs always hash even when shaped like persisted stable IDs`() throws { + let canonicalLookingRawID = "codex-reset-credit-v1-" + String(repeating: "a", count: 64) + let json = """ + { + "credits": [{ + "id": "\(canonicalLookingRawID)", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-18T00:39:53Z", + "expires_at": null + }], + "available_count": 1 + } + """ + let now = Date(timeIntervalSince1970: 1_788_134_400) + + let decoded = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting( + Data(json.utf8), + now: now) + let decodedID = try #require(decoded.credits.first?.id) + let expectedID = CodexRateLimitResetCredit.stableID(forProviderID: canonicalLookingRawID) + + #expect(decodedID == expectedID) + #expect(decodedID != canonicalLookingRawID) + + let publicModel = Self.credit(id: canonicalLookingRawID, status: .available, expiresAt: nil) + #expect(publicModel.id == expectedID) + #expect(publicModel.id != canonicalLookingRawID) + + let encoded = try JSONEncoder().encode(publicModel) + let roundTripped = try JSONDecoder().decode(CodexRateLimitResetCredit.self, from: encoded) + #expect(roundTripped.id == expectedID) + + let ordinaryFirst = Self.credit(id: "ordinary-provider-id", status: .available, expiresAt: nil) + let ordinarySecond = Self.credit(id: "ordinary-provider-id", status: .available, expiresAt: nil) + #expect(ordinaryFirst.id == ordinarySecond.id) + #expect(ordinaryFirst.id != "ordinary-provider-id") + } + + @Test + func `reset credit GET preserves transport cancellation`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.httpMethod == "GET") + throw URLError(.cancelled) + } + + await #expect(throws: CancellationError.self) { + _ = try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits( + accessToken: "test-token", + accountId: "account-123", + env: ["CODEX_HOME": "/tmp/codexbar-reset-credit-cancellation-test"], + session: transport) + } + } + + private static func credit( + id: String, + status: CodexRateLimitResetCreditStatus, + expiresAt: Date?) -> CodexRateLimitResetCredit + { + CodexRateLimitResetCredit( + id: id, + resetType: "codex_rate_limits", + status: status, + grantedAt: Date(timeIntervalSince1970: 1_788_000_000), + expiresAt: expiresAt, + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil) } } diff --git a/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift b/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift new file mode 100644 index 0000000000..688a1de8db --- /dev/null +++ b/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift @@ -0,0 +1,107 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CodexResetCreditExpiryNotifierTests { + @Test + func `posts one bounded summary without persisting or logging raw credit IDs`() throws { + let suite = "CodexResetCreditExpiryNotifierTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let now = Date(timeIntervalSince1970: 1_781_726_400) + let rawID = "private-provider-credit-id" + var posts: [(prefix: String, title: String, body: String)] = [] + let notifier = CodexResetCreditExpiryNotifier(userDefaults: defaults) { prefix, title, body in + posts.append((prefix, title, body)) + } + let snapshot = CodexRateLimitResetCreditsSnapshot( + credits: [ + Self.credit(id: rawID, expiresAt: now.addingTimeInterval(86400)), + Self.credit(id: "no-expiry-private-id", expiresAt: nil), + ], + availableCount: 2, + updatedAt: now) + + notifier.postExpiringCreditsIfNeeded(snapshot: snapshot, resetStyle: .countdown, now: now) + notifier.postExpiringCreditsIfNeeded(snapshot: snapshot, resetStyle: .countdown, now: now) + + #expect(posts.count == 1) + #expect(posts[0].prefix == CodexResetCreditExpiryNotifier.notificationPrefix) + #expect(posts[0].title == "Limit Reset Credits") + #expect(posts[0].body == "1. Expires in 1d") + #expect(!posts[0].prefix.contains(rawID)) + #expect(!posts[0].body.contains(rawID)) + let fingerprints = try #require(defaults.stringArray( + forKey: CodexResetCreditExpiryNotifier.summaryFingerprintsKey)) + let fingerprint = try #require(fingerprints.first) + #expect(fingerprints.count == 1) + #expect(fingerprint.count == 64) + #expect(!fingerprint.contains(rawID)) + } + + @Test + func `switching account inventories does not repeat either notification`() throws { + let suite = "CodexResetCreditExpiryNotifierAccountTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let now = Date(timeIntervalSince1970: 1_781_726_400) + var postCount = 0 + let notifier = CodexResetCreditExpiryNotifier(userDefaults: defaults) { _, _, _ in + postCount += 1 + } + let firstAccount = CodexRateLimitResetCreditsSnapshot( + credits: [Self.credit(id: "first-account-credit", expiresAt: now.addingTimeInterval(86400))], + availableCount: 1, + updatedAt: now) + let secondAccount = CodexRateLimitResetCreditsSnapshot( + credits: [Self.credit(id: "second-account-credit", expiresAt: now.addingTimeInterval(172_800))], + availableCount: 1, + updatedAt: now) + + notifier.postExpiringCreditsIfNeeded(snapshot: firstAccount, resetStyle: .countdown, now: now) + notifier.postExpiringCreditsIfNeeded(snapshot: secondAccount, resetStyle: .countdown, now: now) + notifier.postExpiringCreditsIfNeeded(snapshot: firstAccount, resetStyle: .countdown, now: now) + notifier.postExpiringCreditsIfNeeded(snapshot: secondAccount, resetStyle: .countdown, now: now) + + #expect(postCount == 2) + #expect(defaults.stringArray(forKey: CodexResetCreditExpiryNotifier.summaryFingerprintsKey)?.count == 2) + } + + @Test + func `no-expiry inventory does not trigger an expiry notification`() throws { + let suite = "CodexResetCreditExpiryNotifierNoExpiryTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let now = Date(timeIntervalSince1970: 1_781_726_400) + var postCount = 0 + let notifier = CodexResetCreditExpiryNotifier(userDefaults: defaults) { _, _, _ in + postCount += 1 + } + + notifier.postExpiringCreditsIfNeeded( + snapshot: CodexRateLimitResetCreditsSnapshot( + credits: [Self.credit(id: "no-expiry", expiresAt: nil)], + availableCount: 1, + updatedAt: now), + resetStyle: .countdown, + now: now) + + #expect(postCount == 0) + #expect(defaults.stringArray(forKey: CodexResetCreditExpiryNotifier.summaryFingerprintsKey) == nil) + } + + private static func credit(id: String, expiresAt: Date?) -> CodexRateLimitResetCredit { + CodexRateLimitResetCredit( + id: id, + resetType: "codex_rate_limits", + status: .available, + grantedAt: Date(timeIntervalSince1970: 1_781_700_000), + expiresAt: expiresAt, + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil) + } +} diff --git a/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift b/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift new file mode 100644 index 0000000000..6aae6f23e2 --- /dev/null +++ b/Tests/CodexBarTests/CodexResetCreditOutcomeTests.swift @@ -0,0 +1,271 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CodexResetCreditOutcomeTests { + @Test + func `supplemental inventory skips stale credentials without issuing a request`() async throws { + let recorder = ResetCreditRequestRecorder() + let result = try await UsageStore._fetchCodexResetCreditsForTesting( + credentials: Self.credentials(lastRefresh: .distantPast), + request: { accessToken, accountID, environment in + await recorder.record(accessToken: accessToken, accountID: accountID, environment: environment) + return Self.resetSnapshot(id: "unexpected", now: Date()) + }) + + #expect(result == nil) + #expect(await recorder.count() == 0) + } + + @Test + func `supplemental inventory uses fresh credentials for one read only request`() async throws { + let recorder = ResetCreditRequestRecorder() + let now = Date() + let expected = Self.resetSnapshot(id: "fresh", now: now) + let result = try await UsageStore._fetchCodexResetCreditsForTesting( + credentials: Self.credentials(lastRefresh: now), + env: ["CODEX_HOME": "/tmp/account-a"], + request: { accessToken, accountID, environment in + await recorder.record(accessToken: accessToken, accountID: accountID, environment: environment) + return expected + }) + + #expect(result == expected) + #expect(await recorder.count() == 1) + #expect(await recorder.lastAccessToken() == "access") + #expect(await recorder.lastAccountID() == "account-123") + #expect(await recorder.lastEnvironment()["CODEX_HOME"] == "/tmp/account-a") + } + + @Test + func `embedded OAuth inventory prevents a duplicate supplemental GET`() async throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let embedded = Self.resetSnapshot(id: "embedded", now: now) + let recorder = ResetCreditFetchRecorder() + + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: embedded, now: now), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { env in + await recorder.record(env) + return Self.resetSnapshot(id: "supplemental", now: now) + }) + + #expect(try Self.usage(from: outcome).codexResetCredits == embedded) + #expect(await recorder.environments().isEmpty) + } + + @Test + func `supplemental inventory uses each scoped account environment once`() async throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let recorder = ResetCreditFetchRecorder() + let fetcher: UsageStore.CodexResetCreditsFetcher = { env in + await recorder.record(env) + let home = env["CODEX_HOME"] ?? "missing" + return Self.resetSnapshot(id: home, now: now) + } + + let first = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: fetcher) + let second = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now), + env: ["CODEX_HOME": "/tmp/account-b"], + fetcher: fetcher) + + #expect(try Self.usage(from: first).codexResetCredits?.credits.first?.id == + Self.resetSnapshot(id: "/tmp/account-a", now: now).credits.first?.id) + #expect(try Self.usage(from: second).codexResetCredits?.credits.first?.id == + Self.resetSnapshot(id: "/tmp/account-b", now: now).credits.first?.id) + #expect(await recorder.environments().compactMap { $0["CODEX_HOME"] } == [ + "/tmp/account-a", + "/tmp/account-b", + ]) + } + + @Test + func `failed supplemental GET clears inventory on a successful usage refresh`() async throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let recorder = ResetCreditFetchRecorder() + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { env in + await recorder.record(env) + throw ResetCreditFetchTestError.failed + }) + + #expect(try Self.usage(from: outcome).codexResetCredits == nil) + #expect(await recorder.environments().count == 1) + } + + @Test + func `single failed GET restores failure for reset-credit-only O auth usage`() async { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let recorder = ResetCreditFetchRecorder() + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now, primary: nil, strategyID: "codex.oauth"), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { env in + await recorder.record(env) + throw ResetCreditFetchTestError.failed + }) + + guard case let .failure(error) = outcome.result else { + Issue.record("Expected no-data failure") + return + } + #expect(error is UsageError) + #expect(await recorder.environments().count == 1) + } + + @Test + func `single GET rescues reset-credit-only O auth usage`() async throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let recorder = ResetCreditFetchRecorder() + let resetCredits = Self.resetSnapshot(id: "rescued", now: now) + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now, primary: nil, strategyID: "codex.oauth"), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { env in + await recorder.record(env) + return resetCredits + }) + + #expect(try Self.usage(from: outcome).codexResetCredits == resetCredits) + #expect(await recorder.environments().count == 1) + } + + @Test + func `supplemental GET cancellation remains a cancelled provider outcome`() async { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: nil, now: now), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { _ in throw CancellationError() }) + + guard case let .failure(error) = outcome.result else { + Issue.record("Expected cancellation failure") + return + } + #expect(error is CancellationError) + } + + @Test + func `display preference does not strip embedded inventory or issue a duplicate GET`() async throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let recorder = ResetCreditFetchRecorder() + let outcome = await UsageStore.attachingCodexResetCreditsIfNeeded( + to: Self.outcome(resetCredits: Self.resetSnapshot(id: "embedded", now: now), now: now), + env: ["CODEX_HOME": "/tmp/account-a"], + fetcher: { env in + await recorder.record(env) + return Self.resetSnapshot(id: "supplemental", now: now) + }) + + #expect(try Self.usage(from: outcome).codexResetCredits == Self.resetSnapshot(id: "embedded", now: now)) + #expect(await recorder.environments().isEmpty) + } + + private static func outcome( + resetCredits: CodexRateLimitResetCreditsSnapshot?, + now: Date, + primary: RateWindow? = nil, + strategyID: String = "test") -> ProviderFetchOutcome + { + let resolvedPrimary = strategyID == "codex.oauth" ? primary : primary ?? RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + return ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: UsageSnapshot( + primary: resolvedPrimary, + secondary: nil, + codexResetCredits: resetCredits, + updatedAt: now), + credits: nil, + dashboard: nil, + sourceLabel: "test", + strategyID: strategyID, + strategyKind: .cli)), + attempts: []) + } + + private static func resetSnapshot(id: String, now: Date) -> CodexRateLimitResetCreditsSnapshot { + CodexRateLimitResetCreditsSnapshot( + credits: [CodexRateLimitResetCredit( + id: id, + resetType: "codex_rate_limits", + status: .available, + grantedAt: now, + expiresAt: now.addingTimeInterval(86400), + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil)], + availableCount: 1, + updatedAt: now) + } + + private static func credentials(lastRefresh: Date?) -> CodexOAuthCredentials { + CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: "account-123", + lastRefresh: lastRefresh) + } + + private static func usage(from outcome: ProviderFetchOutcome) throws -> UsageSnapshot { + switch outcome.result { + case let .success(result): + result.usage + case let .failure(error): + throw error + } + } +} + +private actor ResetCreditRequestRecorder { + private var requests: [(accessToken: String, accountID: String?, environment: [String: String])] = [] + + func record(accessToken: String, accountID: String?, environment: [String: String]) { + self.requests.append((accessToken, accountID, environment)) + } + + func count() -> Int { + self.requests.count + } + + func lastAccessToken() -> String? { + self.requests.last?.accessToken + } + + func lastAccountID() -> String? { + self.requests.last?.accountID + } + + func lastEnvironment() -> [String: String] { + self.requests.last?.environment ?? [:] + } +} + +private actor ResetCreditFetchRecorder { + private var capturedEnvironments: [[String: String]] = [] + + func record(_ env: [String: String]) { + self.capturedEnvironments.append(env) + } + + func environments() -> [[String: String]] { + self.capturedEnvironments + } +} + +private enum ResetCreditFetchTestError: Error { + case failed +} diff --git a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift index fb097da5df..b6e1453b12 100644 --- a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift +++ b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift @@ -5,143 +5,116 @@ import Testing struct CodexResetCreditsMenuCardTests { @Test - func `reset credits render when optional usage is enabled`() throws { - let metadata = try #require(ProviderDefaults.metadata[.codex]) + func `presentation shows only available inventory in stable expiry order`() throws { let now = Date(timeIntervalSince1970: 1_781_726_400) - let usage = UsageSnapshot( - primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: nil, - codexResetCredits: CodexRateLimitResetCreditsSnapshot( - credits: [ - CodexRateLimitResetCredit( - id: "reset-1", - resetType: "codex_rate_limits", - status: .available, - grantedAt: now, - expiresAt: now.addingTimeInterval(86400), - redeemStartedAt: nil, - redeemedAt: nil, - title: "One free rate limit reset", - description: nil), - ], - availableCount: 1, - updatedAt: now), - updatedAt: now, - identity: ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: "user@example.com", - accountOrganization: nil, - loginMethod: "pro")) - - let model = UsageMenuCardView.Model.make(Self.input( - metadata: metadata, - snapshot: usage, - showOptionalUsage: true, - now: now)) + let snapshot = Self.snapshot( + now: now, + credits: [ + Self.credit(id: "no-expiry", status: .available, now: now, expiresIn: nil), + Self.credit(id: "late", status: .available, now: now, expiresIn: 172_800), + Self.credit(id: "redeemed", status: .redeemed, now: now, expiresIn: 43200), + Self.credit(id: "expired", status: .available, now: now, expiresIn: -1), + Self.credit(id: "early", status: .available, now: now, expiresIn: 86400), + ], + availableCount: 99) + + let model = try Self.model(snapshot: snapshot, now: now) + let presentation = try #require(model.codexResetCredits) - #expect(model.codexResetCreditsText == "1 available") - #expect(model.codexResetCreditsDetailText == "Next expires in 1d") + #expect(presentation.text == "3 available") + #expect(presentation.detailText == "Next expires in 1d") + #expect(presentation.items.map(\.expiryText) == ["Expires in 1d", "Expires in 2d", "No expiry"]) + #expect(CodexResetCreditsContent.expiryRows(presentation) == [ + "Expires in 1d", + "Expires in 2d", + "No expiry", + ]) + #expect(presentation.helpText == "1. Expires in 1d\n2. Expires in 2d\n3. No expiry") + #expect(presentation.accessibilityLabel.contains(presentation.helpText)) } @Test - func `reset credits plural count uses trimmed copy`() throws { - let metadata = try #require(ProviderDefaults.metadata[.codex]) + func `no-expiry reset remains visible without a next-expiry date`() throws { let now = Date(timeIntervalSince1970: 1_781_726_400) - let usage = UsageSnapshot( - primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: nil, - codexResetCredits: CodexRateLimitResetCreditsSnapshot( - credits: [ - CodexRateLimitResetCredit( - id: "reset-1", - resetType: "codex_rate_limits", - status: .available, - grantedAt: now, - expiresAt: now.addingTimeInterval(86400), - redeemStartedAt: nil, - redeemedAt: nil, - title: "One free rate limit reset", - description: nil), - CodexRateLimitResetCredit( - id: "reset-2", - resetType: "codex_rate_limits", - status: .available, - grantedAt: now, - expiresAt: now.addingTimeInterval(172_800), - redeemStartedAt: nil, - redeemedAt: nil, - title: "One free rate limit reset", - description: nil), - ], - availableCount: 2, - updatedAt: now), - updatedAt: now, - identity: ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: "user@example.com", - accountOrganization: nil, - loginMethod: "pro")) - - let model = UsageMenuCardView.Model.make(Self.input( - metadata: metadata, - snapshot: usage, - showOptionalUsage: true, - now: now)) + let model = try Self.model( + snapshot: Self.snapshot( + now: now, + credits: [Self.credit(id: "no-expiry", status: .available, now: now, expiresIn: nil)]), + now: now) + let presentation = try #require(model.codexResetCredits) - #expect(model.codexResetCreditsText == "2 available") + #expect(presentation.text == "1 available") + #expect(presentation.detailText == "No expiry") + #expect(presentation.items.map(\.expiryText) == ["No expiry"]) + #expect(model.hasUsageContent) } @Test - func `reset credits render when optional usage is disabled`() throws { - let metadata = try #require(ProviderDefaults.metadata[.codex]) + func `inventory respects absolute reset-time style`() throws { let now = Date(timeIntervalSince1970: 1_781_726_400) - let usage = UsageSnapshot( - primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: nil, - codexResetCredits: CodexRateLimitResetCreditsSnapshot( - credits: [ - CodexRateLimitResetCredit( - id: "reset-1", - resetType: "codex_rate_limits", - status: .available, - grantedAt: now, - expiresAt: now.addingTimeInterval(86400), - redeemStartedAt: nil, - redeemedAt: nil, - title: "One free rate limit reset", - description: nil), - CodexRateLimitResetCredit( - id: "reset-2", - resetType: "codex_rate_limits", - status: .available, - grantedAt: now, - expiresAt: now.addingTimeInterval(172_800), - redeemStartedAt: nil, - redeemedAt: nil, - title: "One free rate limit reset", - description: nil), - ], - availableCount: 2, - updatedAt: now), - updatedAt: now) + let expiresAt = now.addingTimeInterval(86400) + let model = try Self.model( + snapshot: Self.snapshot( + now: now, + credits: [Self.credit(id: "finite", status: .available, now: now, expiresIn: 86400)]), + resetStyle: .absolute, + now: now) + let presentation = try #require(model.codexResetCredits) + let formatted = UsageFormatter.resetDescription(from: expiresAt, now: now) - let model = UsageMenuCardView.Model.make(Self.input( - metadata: metadata, - snapshot: usage, + #expect(presentation.detailText == "Next expires \(formatted)") + #expect(presentation.items.map(\.expiryText) == ["Expires \(formatted)"]) + } + + @Test + func `optional usage preference does not hide reset inventory`() throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let model = try Self.model( + snapshot: Self.snapshot( + now: now, + credits: [Self.credit(id: "finite", status: .available, now: now, expiresIn: 86400)]), showOptionalUsage: false, - now: now)) + now: now) + + #expect(model.codexResetCredits?.text == "1 available") + #expect(model.codexResetCredits?.detailText == "Next expires in 1d") + } + + @Test + func `hosted usage model keeps reset inventory compatible with live refresh`() throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let model = try Self.model( + snapshot: Self.snapshot( + now: now, + credits: [Self.credit(id: "finite", status: .available, now: now, expiresIn: 86400)]), + now: now) + + #expect(model.codexResetCredits != nil) + #expect(model.hasCompatibleTrackedLayout(with: model)) + } + + @Test + func `empty filtered inventory does not create hosted reset rows`() throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let model = try Self.model( + snapshot: Self.snapshot( + now: now, + credits: [Self.credit(id: "expired", status: .available, now: now, expiresIn: -1)], + availableCount: 1), + now: now) - #expect(model.codexResetCreditsText == "2 available") - #expect(model.codexResetCreditsDetailText == "Next expires in 1d") + #expect(model.codexResetCredits == nil) + #expect(model.hasCompatibleTrackedLayout(with: model)) } - private static func input( - metadata: ProviderMetadata, + private static func model( snapshot: UsageSnapshot, - showOptionalUsage: Bool, - now: Date) -> UsageMenuCardView.Model.Input + showOptionalUsage: Bool = true, + resetStyle: ResetTimeDisplayStyle = .countdown, + now: Date) throws -> UsageMenuCardView.Model { - UsageMenuCardView.Model.Input( + let metadata = try #require(ProviderDefaults.metadata[.codex]) + return UsageMenuCardView.Model.make(UsageMenuCardView.Model.Input( provider: .codex, metadata: metadata, snapshot: snapshot, @@ -155,10 +128,43 @@ struct CodexResetCreditsMenuCardTests { isRefreshing: false, lastError: nil, usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, + resetTimeDisplayStyle: resetStyle, tokenCostUsageEnabled: false, showOptionalCreditsAndExtraUsage: showOptionalUsage, hidePersonalInfo: false, - now: now) + now: now)) + } + + private static func snapshot( + now: Date, + credits: [CodexRateLimitResetCredit], + availableCount: Int? = nil) -> UsageSnapshot + { + UsageSnapshot( + primary: nil, + secondary: nil, + codexResetCredits: CodexRateLimitResetCreditsSnapshot( + credits: credits, + availableCount: availableCount ?? credits.count, + updatedAt: now), + updatedAt: now) + } + + private static func credit( + id: String, + status: CodexRateLimitResetCreditStatus, + now: Date, + expiresIn: TimeInterval?) -> CodexRateLimitResetCredit + { + CodexRateLimitResetCredit( + id: id, + resetType: "codex_rate_limits", + status: status, + grantedAt: now.addingTimeInterval(-3600), + expiresAt: expiresIn.map(now.addingTimeInterval), + redeemStartedAt: nil, + redeemedAt: nil, + title: nil, + description: nil) } } diff --git a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift index b54e852e7f..943fd5746b 100644 --- a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift +++ b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift @@ -33,9 +33,24 @@ struct MenuCardHeightFingerprintTests { #expect(left != changedPercent) } + @Test + func `height fingerprint tracks reset-credit inventory shape`() { + let one = Self.model(resetCredits: CodexResetCreditsPresentation( + text: "1 available", + detailText: "Next expires in 1d", + items: [.init(expiryText: "Expires in 1d")])) + let two = Self.model(resetCredits: CodexResetCreditsPresentation( + text: "2 available", + detailText: "Next expires in 1d", + items: [.init(expiryText: "Expires in 1d"), .init(expiryText: "No expiry")])) + + #expect(one.heightFingerprint(section: "card") != two.heightFingerprint(section: "card")) + } + private static func model( percent: Double = 42, - percentStyle: UsageMenuCardView.Model.PercentStyle = .left) -> UsageMenuCardView.Model + percentStyle: UsageMenuCardView.Model.PercentStyle = .left, + resetCredits: CodexResetCreditsPresentation? = nil) -> UsageMenuCardView.Model { UsageMenuCardView.Model( provider: .codex, @@ -67,6 +82,7 @@ struct MenuCardHeightFingerprintTests { creditsScaleText: nil, creditsHintText: nil, creditsHintCopyText: nil, + codexResetCredits: resetCredits, providerCost: nil, tokenUsage: nil, placeholder: nil, diff --git a/docs/codex.md b/docs/codex.md index 54826f58f9..bfce642a6b 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -31,6 +31,11 @@ Usage source picker: - Reads OAuth tokens from `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`). - Refreshes access tokens when `last_refresh` is older than 8 days. - Calls `GET https://chatgpt.com/backend-api/wham/usage` (default) with `Authorization: Bearer `. +- The app reads reset-credit inventory once per refresh with a best-effort + `GET https://chatgpt.com/backend-api/wham/rate-limit-reset-credits` using the same account-scoped OAuth context; + the CLI requests it only when optional credits are included. +- The menu and provider settings list every still-available expiry, while the optional credits setting controls + nearing-expiry notifications. CodexBar does not redeem or modify reset credits. - `rate_limit.primary_window` / `secondary_window` map to the session/weekly lanes. - `additional_rate_limits[]` (model-specific limits such as GPT-5.3-Codex-Spark) map to named `UsageSnapshot.extraRateWindows` entries (Spark uses a stable `codex-spark` id / `Codex Spark` title). From 3191f129f99453b7b2720b5c3b3c219f1ed9a0d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 10:10:57 -0400 Subject: [PATCH 032/105] Show shorter cost comparison periods (#1816) * feat: add cost comparison periods * fix: finish cost comparison settings port * fix: show cost comparisons in inline dashboard --- CHANGELOG.md | 1 + .../InlineUsageDashboardContent.swift | 18 ++- .../CodexBar/MenuCardHeightFingerprint.swift | 1 + Sources/CodexBar/MenuCardView+Costs.swift | 19 +++ .../CodexBar/MenuCardView+ModelHelpers.swift | 3 +- .../CodexBar/MenuCardView+ModelInput.swift | 3 + Sources/CodexBar/MenuCardView.swift | 28 +++++ Sources/CodexBar/PreferencesDisplayPane.swift | 6 + .../Resources/ar.lproj/Localizable.strings | 2 + .../Resources/ca.lproj/Localizable.strings | 2 + .../Resources/de.lproj/Localizable.strings | 2 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/fa.lproj/Localizable.strings | 2 + .../Resources/fr.lproj/Localizable.strings | 2 + .../Resources/gl.lproj/Localizable.strings | 2 + .../Resources/id.lproj/Localizable.strings | 2 + .../Resources/it.lproj/Localizable.strings | 2 + .../Resources/ja.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../Resources/nl.lproj/Localizable.strings | 2 + .../Resources/pl.lproj/Localizable.strings | 2 + .../Resources/pt-BR.lproj/Localizable.strings | 2 + .../Resources/ru.lproj/Localizable.strings | 2 + .../Resources/sv.lproj/Localizable.strings | 2 + .../Resources/th.lproj/Localizable.strings | 2 + .../Resources/tr.lproj/Localizable.strings | 2 + .../Resources/uk.lproj/Localizable.strings | 2 + .../Resources/vi.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + Sources/CodexBar/SettingsStore+Defaults.swift | 8 ++ .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 3 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../StatusItemController+CostMenuCard.swift | 13 ++- .../StatusItemController+MenuCardModel.swift | 1 + Sources/CodexBarCore/CostUsageModels.swift | 67 +++++++++++ .../CostUsageWindowSummaryTests.swift | 80 +++++++++++++ .../MenuCardCostComparisonTests.swift | 109 ++++++++++++++++++ .../PreferencesPaneSmokeTests.swift | 2 + Tests/CodexBarTests/SettingsStoreTests.swift | 23 ++++ docs/cost-window-comparisons.md | 35 ++++++ 43 files changed, 458 insertions(+), 10 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageWindowSummaryTests.swift create mode 100644 Tests/CodexBarTests/MenuCardCostComparisonTests.swift create mode 100644 docs/cost-window-comparisons.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5657416a0a..b4c5e4a1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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! diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 136a0c8f65..0644d39444 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -184,7 +184,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 @@ -231,7 +234,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 } @@ -319,7 +325,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 @@ -354,6 +361,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))") } diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 54d0592e24..0bef57eb32 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -111,6 +111,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), diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 4da95b52b9..51aa6c25e9 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -101,6 +101,7 @@ extension UsageMenuCardView.Model { static func tokenUsageSection( provider: UsageProvider, enabled: Bool, + comparisonPeriodsEnabled: Bool, snapshot: CostUsageTokenSnapshot?, error: String?) -> TokenUsageSection? { @@ -143,11 +144,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: diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 910cceeb42..01406e2222 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -200,7 +200,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 } diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index f31333e59c..e5b2dc1846 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -22,6 +22,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? @@ -53,6 +54,7 @@ extension UsageMenuCardView.Model { tokenCostUsageEnabled: Bool, tokenCostInlineDashboardEnabled: Bool? = nil, tokenCostMenuSectionEnabled: Bool? = nil, + costComparisonPeriodsEnabled: Bool = false, showOptionalCreditsAndExtraUsage: Bool, copilotBudgetExtrasEnabled: Bool = false, sourceLabel: String? = nil, @@ -83,6 +85,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 9cadc51e12..7bcdeb6667 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -83,9 +83,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 { @@ -199,6 +216,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) @@ -717,6 +739,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) @@ -832,6 +859,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( diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 38a2b466f8..11546bed0f 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -241,6 +241,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/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index ea594812bc..788e792f41 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -431,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 استطلاعات في الخلفية."; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index c4a19dfeec..54770860a1 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -427,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ú."; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index b8d9c1fc13..1750dbb461 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -429,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“."; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index ea2f3d119d..4b5b472f22 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -431,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."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 0b8ff4a511..6df1177e20 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -432,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ú."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 065e9a94ca..e62f320240 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -431,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 ارائه دهندگان نظرسنجی در پس زمینه انجام می دهند."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index b62e4e8e00..5dfecb3073 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -431,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."; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 3604c7e51f..c716d497ee 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -428,6 +428,8 @@ "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ú."; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 0596adeb7c..368a6480d3 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -433,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."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 647f20d0b3..32ca01b05b 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -433,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."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 984a0e7b6a..44b6d8e5ea 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -428,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" = "自動更新はオフです。メニューの「更新」コマンドを使用してください。"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 61f34a0835..1e603e1cd6 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -426,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" = "자동 새로 고침이 꺼져 있습니다. 메뉴의 새로 고침 명령을 사용하세요."; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index cbaada05b8..fb53ca9c9e 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -431,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."; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index a6cbcf0549..09c348868d 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -433,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."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 7283aa36aa..73c4a73438 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -428,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."; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index d1d99a914b..62afcfdc6c 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -432,6 +432,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" = "Автообновление отключено; используйте команду меню «Обновить»."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index f819599ff9..03428d64ac 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -431,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."; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index b64368d757..f537c2d1b9 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -431,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 ผู้ให้บริการโพลในเบื้องหลัง"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index ff36b8a6b1..308b2740a1 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -431,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ğı."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 74a46b6524..e0bf19ce91 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -431,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" = "Автооновлення вимкнено; скористайтеся командою меню «Оновити»."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index fa3a84b6f7..ecc7afed71 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -427,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."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index e506486577..777c2f83b3 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -435,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" = "自动刷新已关闭;请使用菜单中的“刷新”命令。"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index f42532589c..dc257068f7 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -436,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" = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 612fb0f309..a550174def 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 { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index a22f411e31..fca59dfb84 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 diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 9432677d77..e13e568616 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -416,6 +416,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) @@ -494,6 +496,7 @@ extension SettingsStore { copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw, costUsageEnabled: costUsageEnabled, costUsageHistoryDays: costUsageHistoryDays, + costComparisonPeriodsEnabled: costComparisonPeriodsEnabled, costSummaryDisplayStyleRaw: costSummaryDisplayStyleRaw, hidePersonalInfo: hidePersonalInfo, randomBlinkEnabled: randomBlinkEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index e72d6d794a..567f5f3309 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 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+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 3d8572132b..df7a0734a9 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -112,6 +112,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/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 797faf4915..bee1022d83 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -1,5 +1,27 @@ import Foundation +public struct CostUsageWindowSummary: Sendable, Equatable { + public let days: Int + public let totalTokens: Int? + public let totalCostUSD: Double? + public let totalRequests: Int? + public let entryCount: Int + + public init( + days: Int, + totalTokens: Int?, + totalCostUSD: Double?, + totalRequests: Int?, + entryCount: Int) + { + self.days = days + self.totalTokens = totalTokens + self.totalCostUSD = totalCostUSD + self.totalRequests = totalRequests + self.entryCount = entryCount + } +} + public struct CostUsageTokenSnapshot: Sendable, Equatable { public let sessionTokens: Int? public let sessionCostUSD: Double? @@ -48,6 +70,37 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { Self.entry(in: self.daily, forLocalDayContaining: self.updatedAt, calendar: calendar) } + public func summary(forLastDays requestedDays: Int, calendar: Calendar = .current) -> CostUsageWindowSummary { + let days = max(1, requestedDays) + let today = calendar.startOfDay(for: self.updatedAt) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: today) ?? today + let startKey = CostUsageLocalDay.key(from: start, calendar: calendar) + let endKey = CostUsageLocalDay.key(from: today, calendar: calendar) + let entries = self.daily.filter { entry in + guard let dayKey = Self.localDayKey(for: entry.date, calendar: calendar) else { return false } + return dayKey >= startKey && dayKey <= endKey + } + let costs = entries.compactMap(\.costUSD) + let tokens = entries.compactMap(\.totalTokens) + let requests = entries.compactMap(\.requestCount) + return CostUsageWindowSummary( + days: days, + totalTokens: tokens.isEmpty ? nil : tokens.reduce(0, +), + totalCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), + totalRequests: requests.isEmpty ? nil : requests.reduce(0, +), + entryCount: entries.count) + } + + public func comparisonSummaries( + periods: [Int] = [7, 30, 90], + calendar: Calendar = .current) -> [CostUsageWindowSummary] + { + Array(Set(periods.map { max(1, $0) })) + .filter { $0 < self.historyDays } + .sorted() + .map { self.summary(forLastDays: $0, calendar: calendar) } + } + public static func latestEntry(in entries: [CostUsageDailyReport.Entry]) -> CostUsageDailyReport.Entry? { entries.compactMap { entry -> (entry: CostUsageDailyReport.Entry, date: Date)? in guard let date = CostUsageDateParser.parse(entry.date) else { return nil } @@ -78,6 +131,20 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { return CostUsageLocalDay.key(from: parsed, calendar: calendar) == dayKey } } + + private static func localDayKey(for rawDate: String, calendar: Calendar) -> String? { + let trimmed = rawDate.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count >= 10 { + let prefix = String(trimmed.prefix(10)) + if prefix.count == 10, prefix[prefix.index(prefix.startIndex, offsetBy: 4)] == "-", + prefix[prefix.index(prefix.startIndex, offsetBy: 7)] == "-" + { + return prefix + } + } + guard let parsed = CostUsageDateParser.parse(trimmed) else { return nil } + return CostUsageLocalDay.key(from: parsed, calendar: calendar) + } } public struct CostUsageProjectBreakdown: Sendable, Equatable { diff --git a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift new file mode 100644 index 0000000000..94b640ef10 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift @@ -0,0 +1,80 @@ +import CodexBarCore +import Foundation +import Testing + +struct CostUsageWindowSummaryTests { + @Test + func `summaries use calendar windows instead of the last nonempty rows`() { + let snapshot = Self.snapshot(historyDays: 90) + let summary = snapshot.summary(forLastDays: 7, calendar: Self.utcCalendar) + + #expect(summary.days == 7) + #expect(summary.entryCount == 2) + #expect(summary.totalCostUSD == 9) + #expect(summary.totalTokens == 900) + #expect(summary.totalRequests == 9) + } + + @Test + func `comparison periods are unique sorted and bounded by scanned history`() { + let snapshot = Self.snapshot(historyDays: 90) + + #expect(snapshot.comparisonSummaries(periods: [30, 7, 90, 7], calendar: Self.utcCalendar).map(\.days) == [ + 7, + 30, + ]) + } + + @Test + func `summary preserves unavailable totals as nil`() { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyDays: 30, + daily: [Self.entry(day: "2026-07-01", cost: nil, tokens: nil, requests: nil)], + updatedAt: Self.now) + + let summary = snapshot.summary(forLastDays: 7, calendar: Self.utcCalendar) + #expect(summary.totalCostUSD == nil) + #expect(summary.totalTokens == nil) + #expect(summary.totalRequests == nil) + } + + private static func snapshot(historyDays: Int) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 500, + sessionCostUSD: 5, + last30DaysTokens: 1000, + last30DaysCostUSD: 10, + historyDays: historyDays, + daily: [ + self.entry(day: "2026-06-01", cost: 1, tokens: 100, requests: 1), + self.entry(day: "2026-06-25", cost: 4, tokens: 400, requests: 4), + self.entry(day: "2026-07-01", cost: 5, tokens: 500, requests: 5), + ], + updatedAt: self.now) + } + + private static func entry(day: String, cost: Double?, tokens: Int?, requests: Int?) + -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + requestCount: requests, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + } + + private static let now = Date(timeIntervalSince1970: 1_782_864_000) // 2026-07-01 00:00:00 UTC + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} diff --git a/Tests/CodexBarTests/MenuCardCostComparisonTests.swift b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift new file mode 100644 index 0000000000..5e0ae3c9fc --- /dev/null +++ b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift @@ -0,0 +1,109 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardCostComparisonTests { + @Test + func `cost section adds shorter periods from the same history snapshot`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 400, + sessionCostUSD: 4, + last30DaysTokens: 1000, + last30DaysCostUSD: 10, + historyDays: 90, + daily: [ + Self.entry(day: "2026-06-01", cost: 1, tokens: 100), + Self.entry(day: "2026-06-25", cost: 2, tokens: 200), + Self.entry(day: "2026-07-01", cost: 4, tokens: 400), + ], + updatedAt: Date(timeIntervalSince1970: 1_782_864_000)) + + let section = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .claude, + enabled: true, + comparisonPeriodsEnabled: true, + snapshot: snapshot, + error: nil)) + + #expect(section.comparisonLines == [ + "Last 7 days: $6.00 · 600 tokens", + "Last 30 days: $6.00 · 600 tokens", + ]) + } + + @Test + func `comparison periods remain opt in`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 1, + sessionCostUSD: 1, + last30DaysTokens: 1, + last30DaysCostUSD: 1, + historyDays: 90, + daily: [], + updatedAt: Date()) + + let section = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .claude, + enabled: true, + comparisonPeriodsEnabled: false, + snapshot: snapshot, + error: nil)) + #expect(section.comparisonLines.isEmpty) + } + + @Test + func `inline dashboard shows enabled comparison periods`() throws { + let now = Date(timeIntervalSince1970: 1_783_123_200) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 400, + sessionCostUSD: 4, + last30DaysTokens: 1000, + last30DaysCostUSD: 10, + historyDays: 90, + daily: [ + Self.entry(day: "2026-06-01", cost: 1, tokens: 100), + Self.entry(day: "2026-06-25", cost: 2, tokens: 200), + Self.entry(day: "2026-07-01", cost: 4, tokens: 400), + ], + updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + costComparisonPeriodsEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.detailLines.prefix(2) == [ + "Last 7 days: $4.00 · 400 tokens", + "Last 30 days: $6.00 · 600 tokens", + ]) + } + + private static func entry(day: String, cost: Double, tokens: Int) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + } +} diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index 19b9568c36..7256275cd0 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -30,6 +30,8 @@ struct PreferencesPaneSmokeTests { settings.multiAccountMenuLayout = .stacked settings.hidePersonalInfo = true settings.resetTimesShowAbsolute = true + settings.costUsageEnabled = true + settings.costComparisonPeriodsEnabled = true settings.debugDisableKeychainAccess = true settings.claudeOAuthKeychainPromptMode = .always settings.refreshFrequency = .manual diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 31db735e16..6ddc25d59a 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -1448,6 +1448,29 @@ struct SettingsStoreTests { #expect(store.isProviderEnabled(provider: .alibaba, metadata: metadata)) } + @Test + func `cost comparison periods default off and persist`() throws { + let suite = "SettingsStoreTests-cost-comparison-periods" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(!storeA.costComparisonPeriodsEnabled) + storeA.costComparisonPeriodsEnabled = true + + let storeB = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(storeB.costComparisonPeriodsEnabled) + } + @Test func `cost summary display style defaults to both and persists`() throws { let suite = "SettingsStoreTests-cost-summary-display-style" diff --git a/docs/cost-window-comparisons.md b/docs/cost-window-comparisons.md new file mode 100644 index 0000000000..3351fa0e77 --- /dev/null +++ b/docs/cost-window-comparisons.md @@ -0,0 +1,35 @@ +# Cost window comparison decision + +Issues: [#1500](https://github.com/steipete/CodexBar/issues/1500), [#1708](https://github.com/steipete/CodexBar/issues/1708) + +## Proposed product shape + +Keep the existing history-window setting as the maximum local scan window. Add an opt-in **Show shorter comparison periods** preference, defaulting off. When enabled, the cost card adds fixed 7, 30, and 90-day totals that are shorter than the selected history window. + +Examples: + +- 30-day history: Today, Last 30 days, Last 7 days. +- 90-day history: Today, Last 90 days, Last 7 days, Last 30 days. +- 365-day history: Today, Last 365 days, Last 7 days, Last 30 days, Last 90 days. + +The implementation derives every comparison from the already-loaded daily report. It does not widen scans, add network requests, retain new data, or change provider source selection. Missing calendar days remain zero-usage days rather than making “last 7 days” mean “last 7 non-empty rows.” + +## Why this does not claim lifetime cost + +“All available local logs” and “lifetime since install” are different contracts. Local Codex, Claude, and Pi logs may be moved, pruned, excluded, or created before CodexBar was installed. The existing plan-utilization history is also capped and has no token or cost ledger. A 365-day total therefore cannot honestly be labeled a lifetime bill. + +A true #1708 implementation needs separate approval for an append-only local ledger with: + +- an explicit collection start date and completeness state; +- provider/account ownership and reset behavior; +- migration, retention, export, and deletion controls; +- privacy documentation and bounded storage tests; +- UI wording that separates observed utilization snapshots from estimated local-log cost. + +Recommendation: ship the opt-in comparison rows for #1500 independently. Keep #1708 open until the ledger/data-retention contract is approved; label any future scan-only total **Available local logs**, never **Lifetime**. + +## Maintainer choice + +1. **Recommended:** merge with the preference default off. Existing UI and scan cost stay unchanged until a user opts in. +2. Default the preference on. More useful immediately, but adds vertical menu density for existing users. +3. Keep the model only and revisit the presentation. This preserves tested calendar-window aggregation without shipping a new setting. From 2c56846ab842d49d90edb60fb21a4376ff3c0bbb Mon Sep 17 00:00:00 2001 From: cleanerzkp <104455005+cleanerzkp@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:59:59 +0200 Subject: [PATCH 033/105] Notify on Claude extra-window quota thresholds (Fable, Daily Routines) (#1887) * Notify on Claude extra-window quota thresholds Claude's model-scoped weekly windows (claude-weekly-scoped-*, e.g. the promotional Fable carve-out) and Daily Routines (claude-routines) reach the menu but were silent for quota-warning notifications. Only the session and weekly lanes fired, so you can hit 100% Fable with no warning. Add a generic extra-window notification lane mirroring the Antigravity special case: - Generic over the Claude extra-window ids, so a future carve-out (or Fable's return) surfaces automatically. - Reuses the existing weekly toggle/thresholds; no new settings surface. - Adds a windowID discriminator to QuotaWarningStateKey so sibling windows keep independent fired-threshold state. - Notification copy shows the real window name ("Fable only", "Daily Routines"); OS notification ids stay unique per window. - Prunes fired-threshold state when a scoped window disappears. Co-Authored-By: Claude Opus 4.8 (1M context) * Preserve extra-window warning state across transient extras misses A failed web-extras fetch returns a snapshot with empty extraRateWindows while the main usage data is intact. The unconditional prune treated that transient miss as every scoped window disappearing and cleared their fired-threshold state, so the next successful fetch re-posted an already-crossed Fable/Routines warning. Only prune when the refresh actually delivered extra-window data (at least one active window); a window that genuinely ends while others remain is still reconciled. Also derive the active-id set immutably after the transition loop. Addresses the Codex review feedback on #1887. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: harden Claude extra-window quota warnings * fix: reconcile Claude quota warning state --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../CodexBar/SessionQuotaNotifications.swift | 28 +- .../CodexBar/UsageStore+QuotaWarnings.swift | 63 +++- Sources/CodexBar/UsageStore.swift | 11 + .../ClaudeExtraWindowQuotaWarningTests.swift | 277 ++++++++++++++++++ .../QuotaWarningNotificationLogicTests.swift | 36 +++ 6 files changed, 408 insertions(+), 8 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b4c5e4a1c1..5bd93e8ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Repository: reject oversized tracked blobs and generated release/build artifacts during checks. Thanks @joeVenner! ### Fixed +- Claude: notify on model-scoped weekly and Daily Routines quota thresholds using independent warning state. Thanks @cleanerzkp! - 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! 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/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.swift b/Sources/CodexBar/UsageStore.swift index e392da775f..8de0b1bcb8 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -876,6 +876,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 { diff --git a/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift b/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift new file mode 100644 index 0000000000..0db3dc0d2c --- /dev/null +++ b/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift @@ -0,0 +1,277 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct ClaudeExtraWindowQuotaWarningTests { + private func makeSettings(suiteName: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + @MainActor + final class SessionQuotaNotifierSpy: SessionQuotaNotifying { + private(set) var quotaWarningPosts: [( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool)] = [] + + func post(transition _: SessionQuotaTransition, provider _: UsageProvider, badge _: NSNumber?) {} + + func postQuotaWarning( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool, + onScreenAlertEnabled: Bool) + { + self.quotaWarningPosts.append(( + event: event, + provider: provider, + soundEnabled: soundEnabled, + onScreenAlertEnabled: onScreenAlertEnabled)) + } + } + + @Test + func `claude scoped weekly and routines extra windows fire independent weekly warnings`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-independent") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: 55)) + + #expect(notifier.quotaWarningPosts.count == 2) + let fable = notifier.quotaWarningPosts.first { $0.event.windowID == "claude-weekly-scoped-fable" } + let routines = notifier.quotaWarningPosts.first { $0.event.windowID == "claude-routines" } + #expect(fable?.event.window == .weekly) + #expect(fable?.event.threshold == 50) + #expect(fable?.event.windowDisplayLabel == "Fable only") + #expect(routines?.event.threshold == 50) + #expect(routines?.event.windowDisplayLabel == "Daily Routines") + + // Each window keeps independent fired-threshold state instead of clobbering the shared weekly key. + let fableKey = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable") + let routinesKey = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .weekly, windowID: "claude-routines") + #expect(store.quotaWarningState[fableKey]?.firedThresholds.contains(50) == true) + #expect(store.quotaWarningState[routinesKey]?.firedThresholds.contains(50) == true) + } + + @Test + func `antigravity summary extra windows do not trigger the claude extra-window lane`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-antigravity-guard") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + func snapshot(used: Double) -> UsageSnapshot { + UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-model-weekly", + title: "Weekly", + window: RateWindow( + usedPercent: used, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()) + } + store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot(used: 40)) + store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot(used: 55)) + + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `claude scoped weekly window refires after recovering above threshold`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-refire") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + // 60% remaining -> 45% (fires 50) -> 60% (clears 50) -> 45% (refires 50). + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil)) + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil)) + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil)) + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil)) + + #expect(notifier.quotaWarningPosts.count == 2) + #expect(notifier.quotaWarningPosts.allSatisfy { $0.event.windowID == "claude-weekly-scoped-fable" }) + } + + @Test + func `claude extra-window fired state is pruned when a window disappears but others remain`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-prune") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: 55)) + let fableKey = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable") + let routinesKey = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .weekly, windowID: "claude-routines") + #expect(store.quotaWarningState[fableKey] != nil) + #expect(store.quotaWarningState[routinesKey] != nil) + + // Fable ends while Routines is still present: this refresh carries authoritative extras, so + // Fable's stale state is dropped and Routines is kept. + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: nil, routinesUsed: 55)) + #expect(store.quotaWarningState[fableKey] == nil) + #expect(store.quotaWarningState[routinesKey] != nil) + } + + @Test + func `disabling weekly warnings clears all claude extra-window state`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-disable") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: 55)) + let fableKey = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable") + let routinesKey = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .weekly, windowID: "claude-routines") + #expect(store.quotaWarningState[fableKey] != nil) + #expect(store.quotaWarningState[routinesKey] != nil) + + settings.setQuotaWarningWindowEnabled(.weekly, enabled: false) + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: nil, updatedAt: Date())) + #expect(store.quotaWarningState[fableKey] == nil) + #expect(store.quotaWarningState[routinesKey] == nil) + } + + @Test + func `claude extra-window state survives a transient extras miss without re-posting`() { + let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-transient-miss") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + // Fable crosses 50% and warns once. + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil)) + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil)) + #expect(notifier.quotaWarningPosts.count == 1) + let fableKey = UsageStore.QuotaWarningStateKey( + provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable") + + // A failed web-extras fetch delivers nil extras while the main snapshot is intact. The fired + // state must persist so the warning is not re-posted when extras recover. + store.handleQuotaWarningTransitions( + provider: .claude, + snapshot: UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: nil, updatedAt: Date())) + #expect(store.quotaWarningState[fableKey] != nil) + + store.handleQuotaWarningTransitions( + provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil)) + #expect(notifier.quotaWarningPosts.count == 1) + } + + private func claudeExtraWindowSnapshot(fableUsed: Double?, routinesUsed: Double?) -> UsageSnapshot { + var windows: [NamedRateWindow] = [] + if let fableUsed { + windows.append(NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: fableUsed, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil))) + } + if let routinesUsed { + windows.append(NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: routinesUsed, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil))) + } + return UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: windows, updatedAt: Date()) + } +} diff --git a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift index b0bd32c7f1..ba62c44176 100644 --- a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift +++ b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift @@ -46,6 +46,42 @@ struct QuotaWarningNotificationLogicTests { } } + @Test + func `quota warning copy uses the extra-window display label when provided`() { + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Claude", + window: .weekly, + threshold: 50, + currentRemaining: 45, + windowDisplayLabel: "Fable only") + + #expect(copy.title == "Claude Fable only quota low") + #expect(copy.body == "45% left. Reached your 50% Fable only warning threshold.") + } + } + + @Test + func `extra-window notification identifiers are independent`() { + let fable = QuotaWarningEvent( + window: .weekly, + threshold: 50, + currentRemaining: 45, + windowID: "claude-weekly-scoped-fable") + let routines = QuotaWarningEvent( + window: .weekly, + threshold: 50, + currentRemaining: 45, + windowID: "claude-routines") + + let ids = [fable, routines].map { + QuotaWarningNotificationLogic.notificationIDPrefix(provider: .claude, event: $0) + } + #expect(Set(ids).count == 2) + #expect(ids[0].contains("claude-weekly-scoped-fable")) + #expect(ids[1].contains("claude-routines")) + } + @Test func `quota warning copy follows Traditional Chinese app language`() { Self.withAppLanguage("zh-Hant") { From 8b417a066cb126f05a05261d2ff9c7a9a408a703 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 14:03:22 -0400 Subject: [PATCH 034/105] test: prevent keychain prompts during test runs (#1897) --- CHANGELOG.md | 1 + Scripts/test.sh | 7 ++ Sources/CodexBar/CookieHeaderStore.swift | 8 +- Sources/CodexBar/CopilotTokenStore.swift | 8 +- Sources/CodexBar/KeychainMigration.swift | 6 +- Sources/CodexBar/KimiK2TokenStore.swift | 8 +- Sources/CodexBar/KimiTokenStore.swift | 8 +- Sources/CodexBar/MiniMaxAPITokenStore.swift | 8 +- Sources/CodexBar/MiniMaxCookieStore.swift | 8 +- Sources/CodexBar/SyntheticTokenStore.swift | 8 +- Sources/CodexBar/ZaiTokenStore.swift | 8 +- .../BrowserCookieAccessGate.swift | 12 +-- Sources/CodexBarCore/KeychainAccessGate.swift | 10 +-- .../KeychainAccessPreflight.swift | 6 +- Sources/CodexBarCore/KeychainCacheStore.swift | 21 ++---- Sources/CodexBarCore/KeychainSecurity.swift | 74 +++++++++++++++++++ .../AlibabaCodingPlanCookieImporter.swift | 2 +- ...deOAuthCredentials+SecurityCLIReader.swift | 11 ++- .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 13 +++- .../ClaudeOAuthKeychainQueryTiming.swift | 2 +- .../Providers/Zed/ZedStatusProbe.swift | 2 +- ...entialsStoreIsolatedSecurityCLITests.swift | 22 ++++++ .../KeychainNoUIQueryTests.swift | 35 +++++++++ .../KeychainPromptSafetyAuditTests.swift | 40 ++++++++-- 24 files changed, 245 insertions(+), 83 deletions(-) create mode 100644 Sources/CodexBarCore/KeychainSecurity.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd93e8ce9..59278837f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Repository: reject oversized tracked blobs and generated release/build artifacts during checks. Thanks @joeVenner! ### Fixed +- Tests: block real Keychain and `security` CLI access by default so test runs cannot display password prompts. - Claude: notify on model-scoped weekly and Daily Routines quota thresholds using independent warning state. Thanks @cleanerzkp! - 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! 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/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/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/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/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/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/CodexBarCore/BrowserCookieAccessGate.swift b/Sources/CodexBarCore/BrowserCookieAccessGate.swift index 479c6e1270..0e10c92762 100644 --- a/Sources/CodexBarCore/BrowserCookieAccessGate.swift +++ b/Sources/CodexBarCore/BrowserCookieAccessGate.swift @@ -33,7 +33,7 @@ public enum BrowserCookieAccessGate { processName: String = ProcessInfo.processInfo.processName, environment: [String: String] = ProcessInfo.processInfo.environment) -> BrowserCookieStoreAccessDecision { - guard self.isRunningUnderTests(processName: processName, environment: environment), + guard KeychainTestSafety.isRunningUnderTests(processName: processName, environment: environment), environment[self.allowTestCookieAccessEnvironmentKey] != "1" else { return .allowed @@ -127,16 +127,6 @@ public enum BrowserCookieAccessGate { private static let safeStorageLabels: [(service: String, account: String)] = Browser.safeStorageLabels - private static func isRunningUnderTests( - processName: String, - environment: [String: String]) -> Bool - { - processName == "swiftpm-testing-helper" - || processName.hasSuffix("PackageTests") - || environment["XCTestConfigurationFilePath"] != nil - || environment["SWIFT_TESTING"] != nil - } - private static func normalizedPath(_ url: URL) -> String { url.standardizedFileURL.resolvingSymlinksInPath().path } diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index 9bb848d4b3..a25e19fc4b 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -59,15 +59,7 @@ public enum KeychainAccessGate { #if DEBUG private nonisolated(unsafe) static var forcesDisabledUnderTests: Bool { - self.isRunningUnderTests - && ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" - } - - private nonisolated(unsafe) static var isRunningUnderTests: Bool { - let processName = ProcessInfo.processInfo.processName - return processName == "swiftpm-testing-helper" - || processName.hasSuffix("PackageTests") - || ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + KeychainTestSafety.shouldBlockRealKeychainAccess() } #endif diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index 9c335d76c5..d003bf2249 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -100,6 +100,10 @@ public enum KeychainAccessPreflight { self.checkGenericPasswordOverride = override } + static var hasCheckGenericPasswordOverrideForTesting: Bool { + self.taskCheckGenericPasswordOverrideStore != nil || self.checkGenericPasswordOverride != nil + } + static func withCheckGenericPasswordOverrideForTesting( _ override: ((String, String?) -> Outcome)?, operation: () throws -> T) rethrows -> T @@ -137,7 +141,7 @@ public enum KeychainAccessPreflight { let query = self.makeGenericPasswordPreflightQuery(service: service, account: account) var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) switch status { case errSecSuccess: self.log.debug("Keychain preflight allowed", metadata: ["service": service]) diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index c43f02e111..9616cead8c 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -83,7 +83,7 @@ public enum KeychainCacheStore { KeychainNoUIQuery.apply(to: &query) var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) switch status { case errSecSuccess: guard let data = result as? Data, !data.isEmpty else { @@ -134,7 +134,7 @@ public enum KeychainCacheStore { ] KeychainNoUIQuery.apply(to: &query) - let updateStatus = SecItemUpdate( + let updateStatus = KeychainSecurity.update( query as CFDictionary, [kSecValueData as String: data] as CFDictionary) if updateStatus == errSecSuccess { @@ -153,7 +153,7 @@ public enum KeychainCacheStore { addQuery[kSecAttrAccess as String] = access } - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil) if addStatus != errSecSuccess { self.log.error("Keychain cache add failed (\(key.account)): \(addStatus)") } @@ -185,7 +185,7 @@ public enum KeychainCacheStore { kSecAttrAccount as String: key.account, ] KeychainNoUIQuery.apply(to: &query) - return self.clearResultForKeychainDeleteStatus(SecItemDelete(query as CFDictionary), key: key) + return self.clearResultForKeychainDeleteStatus(KeychainSecurity.delete(query as CFDictionary), key: key) #else return .failed #endif @@ -220,7 +220,7 @@ public enum KeychainCacheStore { KeychainNoUIQuery.apply(to: &query) var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) return self.keysResultForKeychainStatus(status, category: category, result: result) #else return .failed @@ -343,14 +343,9 @@ public enum KeychainCacheStore { #if DEBUG private static var shouldUseImplicitTestStore: Bool { - self.isRunningUnderTests && !self.canUseRealKeychain - } - - private static var isRunningUnderTests: Bool { - let processName = ProcessInfo.processInfo.processName - return processName == "swiftpm-testing-helper" - || processName.hasSuffix("PackageTests") - || ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + KeychainTestSafety.isRunningUnderTests( + processName: ProcessInfo.processInfo.processName, + environment: ProcessInfo.processInfo.environment) && !self.canUseRealKeychain } #else private static var shouldUseImplicitTestStore: Bool { diff --git a/Sources/CodexBarCore/KeychainSecurity.swift b/Sources/CodexBarCore/KeychainSecurity.swift new file mode 100644 index 0000000000..da72effa44 --- /dev/null +++ b/Sources/CodexBarCore/KeychainSecurity.swift @@ -0,0 +1,74 @@ +import Foundation + +enum KeychainTestSafety { + static let suppressAccessEnvironmentKey = "CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS" + static let allowAccessEnvironmentKey = "CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS" + + static func shouldBlockRealKeychainAccess( + processName: String = ProcessInfo.processInfo.processName, + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + if environment[self.allowAccessEnvironmentKey] == "1" { return false } + if environment[self.suppressAccessEnvironmentKey] == "1" { return true } + if environment[KeychainAccessGate.disableAccessEnvironmentKey] == "1" { return true } + return self.isRunningUnderTests(processName: processName, environment: environment) + } + + static func isRunningUnderTests( + processName: String, + environment: [String: String]) -> Bool + { + processName == "swiftpm-testing-helper" + || processName.hasSuffix("PackageTests") + || processName.hasSuffix(".xctest") + || environment["XCTestConfigurationFilePath"] != nil + || environment["XCTestBundlePath"] != nil + || environment["XCTestSessionIdentifier"] != nil + || environment["TESTING_LIBRARY_VERSION"] != nil + || environment["SWIFT_TESTING"] != nil + || environment["SWIFT_TESTING_ENABLED"] != nil + } +} + +#if os(macOS) +import Security + +/// The only first-party entry point for Security.framework item operations. +/// Test processes fail closed before touching the user's Keychain, even when a test enables +/// higher-level Keychain logic with `KeychainAccessGate.withTaskOverrideForTesting(false)`. +public enum KeychainSecurity { + public static func copyMatching( + _ query: CFDictionary, + _ result: UnsafeMutablePointer?) -> OSStatus + { + guard !KeychainTestSafety.shouldBlockRealKeychainAccess() else { + return errSecInteractionNotAllowed + } + return SecItemCopyMatching(query, result) + } + + public static func update(_ query: CFDictionary, _ attributesToUpdate: CFDictionary) -> OSStatus { + guard !KeychainTestSafety.shouldBlockRealKeychainAccess() else { + return errSecInteractionNotAllowed + } + return SecItemUpdate(query, attributesToUpdate) + } + + public static func add( + _ attributes: CFDictionary, + _ result: UnsafeMutablePointer?) -> OSStatus + { + guard !KeychainTestSafety.shouldBlockRealKeychainAccess() else { + return errSecInteractionNotAllowed + } + return SecItemAdd(attributes, result) + } + + public static func delete(_ query: CFDictionary) -> OSStatus { + guard !KeychainTestSafety.shouldBlockRealKeychainAccess() else { + return errSecInteractionNotAllowed + } + return SecItemDelete(query) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift index 608991c0dc..4ba1504fd1 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift @@ -373,7 +373,7 @@ enum AlibabaChromiumCookieFallbackImporter { ] var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift index 60708efdad..342e9dd0a0 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift @@ -272,6 +272,13 @@ extension ClaudeOAuthCredentialsStore { account: String?, environment: [String: String]) -> [String]? { + let isolatedKeychainPath = self.isolatedSecurityCLIKeychainPath(environment: environment) + if KeychainTestSafety.shouldBlockRealKeychainAccess(environment: environment), + isolatedKeychainPath == nil + { + return nil + } + var arguments = [ "find-generic-password", "-s", @@ -285,10 +292,10 @@ extension ClaudeOAuthCredentialsStore { let rawIsolatedPath = environment[self.isolatedSecurityCLIKeychainEnvironmentKey]? .trimmingCharacters(in: .whitespacesAndNewlines) if rawIsolatedPath != nil || KeychainAccessGate.isDisabledByEnvironment(environment) { - guard let isolatedPath = self.isolatedSecurityCLIKeychainPath(environment: environment) else { + guard let isolatedKeychainPath else { return nil } - arguments.append(isolatedPath) + arguments.append(isolatedKeychainPath) } return arguments } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index ebe55b51b5..0a8078cf2b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -1961,7 +1961,7 @@ public enum ClaudeOAuthCredentialsStore { var result: AnyObject? let startedAtNs = DispatchTime.now().uptimeNanoseconds - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) let durationMs = Double(DispatchTime.now().uptimeNanoseconds - startedAtNs) / 1_000_000.0 self.log.debug( "Claude keychain data read result", @@ -2023,7 +2023,7 @@ public enum ClaudeOAuthCredentialsStore { var result: AnyObject? let startedAtNs = DispatchTime.now().uptimeNanoseconds - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) let durationMs = Double(DispatchTime.now().uptimeNanoseconds - startedAtNs) / 1_000_000.0 self.log.debug( "Claude keychain legacy data read result", @@ -2300,6 +2300,15 @@ extension ClaudeOAuthCredentialsStore { } private static func shouldShowClaudeKeychainPreAlert() -> Bool { + #if DEBUG + // Synthetic Claude Keychain fixtures must not fall through to the real preflight. Tests that explicitly + // override the preflight still exercise its prompt-policy branches. + if self.hasTaskKeychainTestingOverride, + !KeychainAccessPreflight.hasCheckGenericPasswordOverrideForTesting + { + return false + } + #endif let mode = ClaudeOAuthKeychainPromptPreference.current() guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return false } return switch KeychainAccessPreflight.checkGenericPassword(service: self.claudeKeychainService, account: nil) { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainQueryTiming.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainQueryTiming.swift index 8fb570430e..eb499383a2 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainQueryTiming.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainQueryTiming.swift @@ -8,7 +8,7 @@ enum ClaudeOAuthKeychainQueryTiming { static func copyMatching(_ query: [String: Any]) -> (status: OSStatus, result: AnyObject?, durationMs: Double) { var result: AnyObject? let startedAtNs = DispatchTime.now().uptimeNanoseconds - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) let durationMs = Double(DispatchTime.now().uptimeNanoseconds - startedAtNs) / 1_000_000.0 return (status, result, durationMs) } diff --git a/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift b/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift index 4527c992da..dddacef7bf 100644 --- a/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift @@ -276,7 +276,7 @@ public struct ZedKeychainCredentialsReader: ZedCredentialsReading, Sendable { private func credentials(from query: [String: Any]) throws -> ZedCredentials? { var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) switch status { case errSecSuccess: break diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift index 31bc939006..e219a413aa 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift @@ -5,6 +5,28 @@ import Testing #if os(macOS) @Suite(.serialized) struct ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests { + @Test + func `safety blocks security CLI access to the login keychain`() { + let blockedEnvironment = [KeychainTestSafety.suppressAccessEnvironmentKey: "1"] + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: blockedEnvironment) == nil) + + let explicitOptIn = [ + KeychainTestSafety.suppressAccessEnvironmentKey: "1", + KeychainTestSafety.allowAccessEnvironmentKey: "1", + ] + let expectedArguments = [ + "find-generic-password", + "-s", + "Claude Code-credentials", + "-w", + ] + #expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments( + account: nil, + environment: explicitOptIn) == expectedArguments) + } + @Test func `isolated security CLI keychain requires global keychain disable`() { let keychainPath = "/tmp/codexbar-fixtures/verify.keychain-db" diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift index 58591543f0..9c527c5313 100644 --- a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -58,5 +58,40 @@ struct KeychainNoUIQueryTests { let status = SecItemCopyMatching(query as CFDictionary, &result) #expect(status == errSecItemNotFound || status == errSecInteractionNotAllowed) } + + @Test + func `processes block every Security item operation before system access`() { + guard ProcessInfo.processInfo.environment[KeychainTestSafety.allowAccessEnvironmentKey] != "1" else { + return + } + + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess()) + + let empty = [:] as CFDictionary + var result: CFTypeRef? + #expect(KeychainSecurity.copyMatching(empty, &result) == errSecInteractionNotAllowed) + #expect(KeychainSecurity.update(empty, empty) == errSecInteractionNotAllowed) + #expect(KeychainSecurity.add(empty, nil) == errSecInteractionNotAllowed) + #expect(KeychainSecurity.delete(empty) == errSecInteractionNotAllowed) + } + + @Test + func `safety recognizes runner variants and explicit controls`() { + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess( + processName: "swiftpm-testing-helper", + environment: [:])) + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess( + processName: "CodexBarPackageTests.xctest", + environment: [:])) + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess( + processName: "future-test-runner", + environment: [KeychainTestSafety.suppressAccessEnvironmentKey: "1"])) + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess( + processName: "CodexBar", + environment: [:]) == false) + #expect(KeychainTestSafety.shouldBlockRealKeychainAccess( + processName: "swiftpm-testing-helper", + environment: [KeychainTestSafety.allowAccessEnvironmentKey: "1"]) == false) + } } #endif diff --git a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift index 3386d3bb77..5ee1024b02 100644 --- a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift +++ b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift @@ -10,6 +10,14 @@ struct KeychainPromptSafetyAuditTests { #expect(agents.contains("use parser tests, stubs, test stores, or `KeychainNoUIQuery`")) } + @Test + func `default test runner explicitly suppresses real keychain access`() throws { + let script = try Self.readRepoFile("Scripts/test.sh") + + #expect(script.contains("CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS")) + #expect(script.contains("export CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1")) + } + @Test func `live TTY integration tests are opt in`() throws { let ttyTests = try Self.readRepoFile("Tests/CodexBarTests/TTYIntegrationTests.swift") @@ -96,15 +104,30 @@ struct KeychainPromptSafetyAuditTests { } @Test - func `tests do not call SecItemCopyMatching except no UI query coverage`() throws { + func `tests do not call Security item APIs except no UI query coverage`() throws { + let securityItemCalls = ["SecItemCopyMatching", "SecItemUpdate", "SecItemAdd", "SecItemDelete"] let offenders = try Self.swiftTestFiles().filter { file in let text = try Self.readFile(file) - return text.contains("SecItemCopyMatching") + return securityItemCalls.contains(where: text.contains) && !file.path.hasSuffix("Tests/CodexBarTests/KeychainNoUIQueryTests.swift") && !file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift") } - #expect(offenders.isEmpty, "Unexpected direct SecItemCopyMatching in tests: \(offenders.map(\.path))") + #expect(offenders.isEmpty, "Unexpected direct Security item access in tests: \(offenders.map(\.path))") + } + + @Test + func `production source routes Security item APIs through the test safety gateway`() throws { + let securityItemCalls = ["SecItemCopyMatching", "SecItemUpdate", "SecItemAdd", "SecItemDelete"] + let offenders = try Self.swiftFiles( + under: Self.repoRoot().appendingPathComponent("Sources", isDirectory: true)) + .filter { file in + guard !file.path.hasSuffix("Sources/CodexBarCore/KeychainSecurity.swift") else { return false } + let text = try Self.readFile(file) + return securityItemCalls.contains(where: text.contains) + } + + #expect(offenders.isEmpty, "Security item access bypasses KeychainSecurity: \(offenders.map(\.path))") } private static func repoRoot() -> URL { @@ -128,8 +151,14 @@ struct KeychainPromptSafetyAuditTests { private static func swiftTestFiles(excludingSelf: Bool = false) throws -> [URL] { let testsRoot = self.repoRoot().appendingPathComponent("Tests/CodexBarTests", isDirectory: true) + return try self.swiftFiles(under: testsRoot).filter { file in + !(excludingSelf && file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift")) + } + } + + private static func swiftFiles(under root: URL) throws -> [URL] { guard let enumerator = FileManager.default.enumerator( - at: testsRoot, + at: root, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else { return [] } @@ -138,9 +167,6 @@ struct KeychainPromptSafetyAuditTests { for case let file as URL in enumerator where file.pathExtension == "swift" { let values = try file.resourceValues(forKeys: [.isRegularFileKey]) if values.isRegularFile == true { - if excludingSelf, file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift") { - continue - } files.append(file) } } From cfd747f8d232babb3e44cd277bcb5c45e4b36f97 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 14:40:10 -0400 Subject: [PATCH 035/105] Fix slow Claude CLI failure refreshes (#1899) * fix: skip redundant Claude status probes * test: stabilize local cost windows --- CHANGELOG.md | 1 + .../Providers/Claude/ClaudeStatusProbe.swift | 15 +++++++++++++++ .../MenuCardCostComparisonTests.swift | 6 +++++- Tests/CodexBarTests/TTYIntegrationTests.swift | 16 +++++++++++++--- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59278837f0..256390d4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### Fixed - Tests: block real Keychain and `security` CLI access by default so test runs cannot display password prompts. - 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! diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index 3d84951890..4912f2cc6f 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -107,6 +107,9 @@ public struct ClaudeStatusProbe: Sendable { Self.log.debug("Claude CLI /usage looked like startup output; retrying once") usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: max(timeout, 14)) } + // `/status` only enriches a valid usage snapshot with identity. Terminal usage errors and loading stalls + // cannot be repaired by it, so fail now instead of paying for another interactive CLI round trip. + try Self.validateUsageBeforeStatusProbe(usage) let status = try? await Self.capture(subcommand: "/status", binary: resolved, timeout: min(timeout, 12)) let snap = try Self.parse(text: usage, statusText: status) @@ -331,6 +334,18 @@ public struct ClaudeStatusProbe: Sendable { || self.usageCaptureHasSubscriptionNotice(normalized) } + private static func validateUsageBeforeStatusProbe(_ text: String) throws { + let clean = TextParsing.stripANSICodes(text) + if let usageError = self.extractUsageError(text: clean) { + throw ClaudeStatusProbeError.parseFailed(usageError) + } + + let latestUsagePanel = self.trimToLatestUsagePanel(clean) + if self.isUsageStillLoading(text: latestUsagePanel ?? clean) { + throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.") + } + } + private static func extractPercent(labelSubstrings: [String], context: LabelSearchContext) -> Int? { for label in labelSubstrings { if let value = self.extractPercent(labelSubstring: label, context: context) { return value } diff --git a/Tests/CodexBarTests/MenuCardCostComparisonTests.swift b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift index 5e0ae3c9fc..48ce6c996d 100644 --- a/Tests/CodexBarTests/MenuCardCostComparisonTests.swift +++ b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift @@ -17,7 +17,7 @@ struct MenuCardCostComparisonTests { Self.entry(day: "2026-06-25", cost: 2, tokens: 200), Self.entry(day: "2026-07-01", cost: 4, tokens: 400), ], - updatedAt: Date(timeIntervalSince1970: 1_782_864_000)) + updatedAt: Self.localNoon(year: 2026, month: 7, day: 1)) let section = try #require(UsageMenuCardView.Model.tokenUsageSection( provider: .claude, @@ -106,4 +106,8 @@ struct MenuCardCostComparisonTests { modelsUsed: nil, modelBreakdowns: nil) } + + private static func localNoon(year: Int, month: Int, day: Int) -> Date { + Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))! + } } diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift index a17e3c3f9b..dc3ea5e86e 100644 --- a/Tests/CodexBarTests/TTYIntegrationTests.swift +++ b/Tests/CodexBarTests/TTYIntegrationTests.swift @@ -74,8 +74,13 @@ struct TTYIntegrationTests { @Test func `claude pty usage stops on subscription notice`() async throws { - let cli = try Self.makeSubscriptionNoticeClaudeCLI() - defer { Task { await ClaudeCLISession.shared.reset() } } + let logURL = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString).log") + let cli = try Self.makeSubscriptionNoticeClaudeCLI(logURL: logURL) + defer { + try? FileManager.default.removeItem(at: logURL) + Task { await ClaudeCLISession.shared.reset() } + } do { try await ClaudeCLISession.withIsolatedSessionForTesting { @@ -87,6 +92,10 @@ struct TTYIntegrationTests { } catch { #expect(Bool(false), "Unexpected error: \(error)") } + + let commands = try String(contentsOf: logURL, encoding: .utf8) + #expect(commands.contains("/usage")) + #expect(!commands.contains("/status")) } private static func makeSlowUsageClaudeCLI() throws -> URL { @@ -117,7 +126,7 @@ struct TTYIntegrationTests { return url } - private static func makeSubscriptionNoticeClaudeCLI() throws -> URL { + private static func makeSubscriptionNoticeClaudeCLI(logURL: URL) throws -> URL { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) @@ -125,6 +134,7 @@ struct TTYIntegrationTests { let script = """ #!/bin/sh while IFS= read -r line; do + printf '%s\\n' "$line" >> '\(logURL.path)' case "$line" in *"/usage"*) printf '%s\\n' 'You are currently using your subscription to power your Claude Code usage' From f47cbb71ae9f4f64abe3167a1930ce2769d74e70 Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Sat, 4 Jul 2026 20:11:33 +0100 Subject: [PATCH 036/105] fix: guard Mistral billing cost overflow (#1889) Co-authored-by: JoeVenner --- CHANGELOG.md | 1 + .../Mistral/MistralUsageFetcher.swift | 42 ++++---- .../MistralUsageParserTests.swift | 95 +++++++++++++++++++ 3 files changed, 117 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 256390d4e7..6f10eed38a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixed - 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! diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift index 812b83adc1..4d48888094 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -220,7 +220,7 @@ public enum MistralUsageFetcher { totalInput += input totalOutput += output totalCached += cached - totalCost += cost + Self.accumulateFiniteCost(cost, into: &totalCost) Self.addDailyEntries( modelName: modelName, data: modelData, @@ -235,7 +235,7 @@ public enum MistralUsageFetcher { if let models = category?.models { for (modelName, modelData) in models { let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) - totalCost += cost + Self.accumulateFiniteCost(cost, into: &totalCost) Self.addDailyEntries( modelName: modelName, data: modelData, @@ -250,7 +250,7 @@ public enum MistralUsageFetcher { if let models = billing.librariesApi?.pages?.models { for (modelName, modelData) in models { let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) - totalCost += cost + Self.accumulateFiniteCost(cost, into: &totalCost) Self.addDailyEntries( modelName: modelName, data: modelData, @@ -262,7 +262,7 @@ public enum MistralUsageFetcher { if let models = billing.librariesApi?.tokens?.models { for (modelName, modelData) in models { let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) - totalCost += cost + Self.accumulateFiniteCost(cost, into: &totalCost) Self.addDailyEntries( modelName: modelName, data: modelData, @@ -277,7 +277,7 @@ public enum MistralUsageFetcher { if let models { for (modelName, modelData) in models { let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) - totalCost += cost + Self.accumulateFiniteCost(cost, into: &totalCost) Self.addDailyEntries( modelName: modelName, data: modelData, @@ -316,7 +316,8 @@ public enum MistralUsageFetcher { guard let metric = price.billingMetric, let group = price.billingGroup, let priceStr = price.price, - let value = Double(priceStr) + let value = Double(priceStr), + value.isFinite else { continue } let key = "\(metric)::\(group)" index[key] = value @@ -336,28 +337,19 @@ public enum MistralUsageFetcher { for entry in data.input ?? [] { let tokens = entry.valuePaid ?? entry.value ?? 0 totalInput += tokens - if let metric = entry.billingMetric, let group = entry.billingGroup { - let pricePerToken = prices["\(metric)::\(group)"] ?? 0 - totalCost += Double(tokens) * pricePerToken - } + Self.accumulateFiniteCost(Self.cost(for: entry, units: tokens, prices: prices), into: &totalCost) } for entry in data.output ?? [] { let tokens = entry.valuePaid ?? entry.value ?? 0 totalOutput += tokens - if let metric = entry.billingMetric, let group = entry.billingGroup { - let pricePerToken = prices["\(metric)::\(group)"] ?? 0 - totalCost += Double(tokens) * pricePerToken - } + Self.accumulateFiniteCost(Self.cost(for: entry, units: tokens, prices: prices), into: &totalCost) } for entry in data.cached ?? [] { let tokens = entry.valuePaid ?? entry.value ?? 0 totalCached += tokens - if let metric = entry.billingMetric, let group = entry.billingGroup { - let pricePerToken = prices["\(metric)::\(group)"] ?? 0 - totalCost += Double(tokens) * pricePerToken - } + Self.accumulateFiniteCost(Self.cost(for: entry, units: tokens, prices: prices), into: &totalCost) } return (totalInput, totalOutput, totalCached, totalCost) @@ -424,7 +416,15 @@ public enum MistralUsageFetcher { private static func cost(for entry: MistralUsageEntry, units: Int, prices: [String: Double]) -> Double { guard let metric = entry.billingMetric, let group = entry.billingGroup else { return 0 } - return Double(units) * (prices["\(metric)::\(group)"] ?? 0) + let cost = Double(units) * (prices["\(metric)::\(group)"] ?? 0) + return cost.isFinite ? cost : 0 + } + + fileprivate static func accumulateFiniteCost(_ cost: Double, into total: inout Double) { + guard cost.isFinite else { return } + let updatedTotal = total + cost + guard updatedTotal.isFinite else { return } + total = updatedTotal } private static func displayModelName(_ raw: String, entry: MistralUsageEntry) -> String { @@ -477,9 +477,9 @@ private struct DailyAccumulator { cost: Double, countsTokens: Bool) { - self.cost += cost + MistralUsageFetcher.accumulateFiniteCost(cost, into: &self.cost) var model = self.models[modelName] ?? ModelAccumulator(name: modelName) - model.cost += cost + MistralUsageFetcher.accumulateFiniteCost(cost, into: &model.cost) guard countsTokens else { self.models[modelName] = model return diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index fdb53aabf2..6fb3ef367b 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -47,6 +47,101 @@ struct MistralUsageParserTests { #expect(snapshot.totalCost > 0) } + @Test(arguments: ["NaN", "Infinity", "1e308"]) + func `ignores prices that produce nonfinite costs`(price: String) async throws { + let json = """ + { + "completion": { + "models": { + "mistral-small": { + "input": [{ + "billing_metric": "tokens", + "billing_group": "input", + "timestamp": "2026-07-04", + "value": 2 + }] + } + } + }, + "prices": [{ + "billing_metric": "tokens", + "billing_group": "input", + "price": "\(price)" + }] + } + """ + let transport = ProviderHTTPTransportHandler { request in + #expect(request.url?.path == "/api/billing/v2/usage") + #expect(request.value(forHTTPHeaderField: "Cookie") == "ory_session_test=abc") + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(json.utf8), response) + } + + let snapshot = try await MistralUsageFetcher.fetchUsage( + cookieHeader: "ory_session_test=abc", + csrfToken: nil, + transport: transport) + + #expect(snapshot.totalCost == 0) + #expect(snapshot.totalCost.isFinite) + #expect(snapshot.daily.first?.cost == 0) + #expect(snapshot.daily.first?.models.first?.cost == 0) + } + + @Test + func `keeps cost totals finite when individually valid costs overflow their sum`() throws { + let json = """ + { + "completion": { + "models": { + "mistral-small": { + "input": [ + { + "billing_metric": "tokens", + "billing_group": "input", + "timestamp": "2026-07-04", + "value": 1 + }, + { + "billing_metric": "tokens", + "billing_group": "input", + "timestamp": "2026-07-04", + "value": 1 + } + ] + }, + "mistral-large": { + "input": [{ + "billing_metric": "tokens", + "billing_group": "input", + "timestamp": "2026-07-04", + "value": 1 + }] + } + } + }, + "prices": [{ + "billing_metric": "tokens", + "billing_group": "input", + "price": "1e308" + }] + } + """ + + let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: Date()) + + #expect(snapshot.totalCost == 1e308) + #expect(snapshot.totalCost.isFinite) + #expect(snapshot.daily.first?.cost == 1e308) + #expect(snapshot.daily.first?.models.count == 2) + #expect(snapshot.daily.first?.models.allSatisfy { $0.cost == 1e308 } == true) + } + @Test func `parses empty response with no usage`() throws { let data = try #require(Self.emptyResponseJSON.data(using: .utf8)) From c5cf41c94411bd783aaf0ebf16320eb65eaf63f6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 15:22:49 -0400 Subject: [PATCH 037/105] refactor: compact reset credit expiries (#1902) --- CHANGELOG.md | 1 + .../CodexBar/MenuCardHeightFingerprint.swift | 5 +- .../MenuCardView+CodexResetCredits.swift | 64 +++++++++---------- .../PreferencesProviderDetailView.swift | 11 +++- .../CodexResetCreditsMenuCardTests.swift | 26 +++++--- .../MenuCardHeightFingerprintTests.swift | 9 +-- 6 files changed, 63 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f10eed38a..12c38d787b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - 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 diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 0bef57eb32..a3aea4ccfb 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -123,10 +123,7 @@ extension CodexResetCreditsPresentation { fileprivate var heightFingerprint: String { MenuCardHeightFingerprint.join([ MenuCardHeightFingerprint.field("text", self.text), - MenuCardHeightFingerprint.field("detail", self.detailText), - MenuCardHeightFingerprint.join(self.items.map { - MenuCardHeightFingerprint.field("expiry", $0.expiryText) - }), + MenuCardHeightFingerprint.field("expirySummary", self.expirySummaryText), ]) } } diff --git a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift index 8f90608e4f..fa6b2d4da3 100644 --- a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift +++ b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift @@ -3,13 +3,20 @@ import SwiftUI 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)" @@ -30,19 +37,10 @@ struct CodexResetCreditsPresentation: Equatable { let inventory = snapshot.availableInventory(at: now) guard !inventory.credits.isEmpty else { return nil } let items = inventory.credits.map { credit in - CodexResetCreditPresentationItem( - expiryText: Self.expiryText(for: credit, resetStyle: resetStyle, now: now)) + Self.presentationItem(for: credit, resetStyle: resetStyle, now: now) } - let detailText = inventory.nextExpiringCredit.flatMap { credit in - credit.expiresAt.map { expiresAt in - String( - format: L("Next expires %@"), - Self.formattedTime(expiresAt, resetStyle: resetStyle, now: now)) - } - } ?? (inventory.credits.allSatisfy { $0.expiresAt == nil } ? L("No expiry") : nil) return CodexResetCreditsPresentation( text: Self.availableText(count: inventory.count), - detailText: detailText, items: items) } @@ -50,15 +48,21 @@ struct CodexResetCreditsPresentation: Equatable { count == 1 ? L("1 available") : String(format: L("%d available"), count) } - private static func expiryText( + private static func presentationItem( for credit: CodexRateLimitResetCredit, resetStyle: ResetTimeDisplayStyle, - now: Date) -> String + now: Date) -> CodexResetCreditPresentationItem { - guard let expiresAt = credit.expiresAt else { return L("No expiry") } - return String( - format: L("Expires %@"), - Self.formattedTime(expiresAt, resetStyle: resetStyle, now: now)) + 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( @@ -80,36 +84,30 @@ struct CodexResetCreditsContent: View { let presentation: CodexResetCreditsPresentation @Environment(\.menuItemHighlighted) private var isHighlighted - nonisolated static func expiryRows(_ presentation: CodexResetCreditsPresentation) -> [String] { - presentation.items.map(\.expiryText) - } - var body: some View { VStack(alignment: .leading, spacing: 4) { Text(L("Limit Reset Credits")) .font(.body) .fontWeight(.medium) .lineLimit(1) - HStack(alignment: .firstTextBaseline) { + 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 = self.presentation.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) } - } - ForEach(Array(Self.expiryRows(self.presentation).enumerated()), id: \.offset) { index, expiryText in - Text("\(index + 1). \(expiryText)") - .font(.caption) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .trailing) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .accessibilityHidden(true) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index a27e18d7b7..71c4a62f0c 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -481,12 +481,17 @@ private struct ProviderCodexResetCreditsInlineRow: View { .font(.footnote) .foregroundStyle(.secondary) } - ForEach(Array(self.presentation.items.enumerated()), id: \.offset) { _, item in - Text(item.expiryText) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: "clock") + .font(.caption2) + Text(self.presentation.expirySummaryText) .font(.caption) .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .trailing) + .lineLimit(1) + .minimumScaleFactor(0.8) } + .frame(maxWidth: .infinity, alignment: .trailing) + .accessibilityHidden(true) } .padding(.vertical, 2) .accessibilityElement(children: .combine) diff --git a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift index b6e1453b12..c288149d44 100644 --- a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift +++ b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift @@ -22,13 +22,8 @@ struct CodexResetCreditsMenuCardTests { let presentation = try #require(model.codexResetCredits) #expect(presentation.text == "3 available") - #expect(presentation.detailText == "Next expires in 1d") #expect(presentation.items.map(\.expiryText) == ["Expires in 1d", "Expires in 2d", "No expiry"]) - #expect(CodexResetCreditsContent.expiryRows(presentation) == [ - "Expires in 1d", - "Expires in 2d", - "No expiry", - ]) + #expect(presentation.expirySummaryText == "1d · 2d · No expiry") #expect(presentation.helpText == "1. Expires in 1d\n2. Expires in 2d\n3. No expiry") #expect(presentation.accessibilityLabel.contains(presentation.helpText)) } @@ -44,8 +39,8 @@ struct CodexResetCreditsMenuCardTests { let presentation = try #require(model.codexResetCredits) #expect(presentation.text == "1 available") - #expect(presentation.detailText == "No expiry") #expect(presentation.items.map(\.expiryText) == ["No expiry"]) + #expect(presentation.expirySummaryText == "No expiry") #expect(model.hasUsageContent) } @@ -62,8 +57,8 @@ struct CodexResetCreditsMenuCardTests { let presentation = try #require(model.codexResetCredits) let formatted = UsageFormatter.resetDescription(from: expiresAt, now: now) - #expect(presentation.detailText == "Next expires \(formatted)") #expect(presentation.items.map(\.expiryText) == ["Expires \(formatted)"]) + #expect(presentation.expirySummaryText == formatted) } @Test @@ -77,7 +72,20 @@ struct CodexResetCreditsMenuCardTests { now: now) #expect(model.codexResetCredits?.text == "1 available") - #expect(model.codexResetCredits?.detailText == "Next expires in 1d") + #expect(model.codexResetCredits?.expirySummaryText == "1d") + } + + @Test + func `compact expiry summary caps visible dates`() throws { + let now = Date(timeIntervalSince1970: 1_781_726_400) + let credits = (1...6).map { day in + Self.credit(id: "day-\(day)", status: .available, now: now, expiresIn: Double(day * 86400)) + } + let model = try Self.model(snapshot: Self.snapshot(now: now, credits: credits), now: now) + + let presentation = try #require(model.codexResetCredits) + #expect(presentation.expirySummaryText == "1d · 2d · 3d · 4d · +2") + #expect(presentation.helpText.split(separator: "\n").count == 6) } @Test diff --git a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift index 943fd5746b..7e2ec20c12 100644 --- a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift +++ b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift @@ -37,12 +37,13 @@ struct MenuCardHeightFingerprintTests { func `height fingerprint tracks reset-credit inventory shape`() { let one = Self.model(resetCredits: CodexResetCreditsPresentation( text: "1 available", - detailText: "Next expires in 1d", - items: [.init(expiryText: "Expires in 1d")])) + items: [.init(expiryText: "Expires in 1d", compactExpiryText: "1d")])) let two = Self.model(resetCredits: CodexResetCreditsPresentation( text: "2 available", - detailText: "Next expires in 1d", - items: [.init(expiryText: "Expires in 1d"), .init(expiryText: "No expiry")])) + items: [ + .init(expiryText: "Expires in 1d", compactExpiryText: "1d"), + .init(expiryText: "No expiry", compactExpiryText: "No expiry"), + ])) #expect(one.heightFingerprint(section: "card") != two.heightFingerprint(section: "card")) } From 52cad2991acb80c61a8607556d6fafca0d3a3392 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 12:23:57 -0700 Subject: [PATCH 038/105] chore: prepare 0.39.0 release --- CHANGELOG.md | 2 +- version.env | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c38d787b..5f9a9963bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.38.2 — Unreleased +## 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! diff --git a/version.env b/version.env index b181b2916c..c4ed0ce496 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.38.2 -BUILD_NUMBER=96 +MARKETING_VERSION=0.39.0 +BUILD_NUMBER=97 From d79592c5e7c94dc7157377ed491aedabf2e45e93 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 15:36:29 -0400 Subject: [PATCH 039/105] fix: keep Alibaba Safe Storage keychain read non-interactive (#1900) --- CHANGELOG.md | 1 + .../Alibaba/AlibabaCodingPlanCookieImporter.swift | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9a9963bb..81aad895e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - 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! diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift index 4ba1504fd1..1eb1e1f469 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift @@ -364,13 +364,18 @@ enum AlibabaChromiumCookieFallbackImporter { } private static func safeStoragePassword(service: String, account: String) -> String? { - let query: [String: Any] = [ + // The preflight classifies prompt-requiring items as .interactionRequired, but its + // .notFound (gate disabled) and .failure outcomes still reach this read. Honor the + // access gate and keep the read strictly non-interactive so it can never prompt. + guard !KeychainAccessGate.isDisabled else { return nil } + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, ] + KeychainNoUIQuery.apply(to: &query) var result: AnyObject? let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) From e437044c32ba1bae3d64b9884522bdcd76fff916 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 13:01:12 -0700 Subject: [PATCH 040/105] docs: update appcast for 0.39.0 --- appcast.xml | 60 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/appcast.xml b/appcast.xml index f1543b0880..febccdf2ca 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,42 @@ CodexBar + + 0.39.0 + Sat, 04 Jul 2026 13:01:10 -0700 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 97 + 0.39.0 + 14.0 + CodexBar 0.39.0 +

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!
  • +
+

View full changelog

+]]>
+ +
0.38.1 Sat, 04 Jul 2026 10:21:12 +0100 @@ -122,30 +158,6 @@ ]]> - - 0.37.2 - Mon, 22 Jun 2026 10:42:03 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 92 - 0.37.2 - 14.0 - CodexBar 0.37.2 -

Added

-
    -
  • Diagnostics: write redacted provider reports to a file with platform and app-version context. Thanks @Yuxin-Qiao!
  • -
  • CLI server: report the startup build version from /health so clients can detect stale helper processes after updates. Thanks @enieuwy!
  • -
-

Fixed

-
    -
  • Claude: pause background CLI usage probes briefly after rate limiting while keeping manual refresh available. Thanks @kiranmagic7!
  • -
  • Codex OAuth: publish refreshed auth.json credentials with private file permissions already applied. Thanks @Hinotoi-agent!
  • -
  • Provider endpoints: reject unsafe Deepgram, z.ai, and Xiaomi MiMo overrides before attaching credentials. Thanks @Hinotoi-agent!
  • -
  • Azure OpenAI: reject unsafe endpoint overrides before attaching API keys while keeping invalid configurations visible with an actionable error. Thanks @Hinotoi-agent!
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From 23e2d30d3cbeb44384f81a686eab191cfc5530eb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 13:15:06 -0700 Subject: [PATCH 041/105] chore: start 0.39.1 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81aad895e3..b7f3efb8d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.39.1 — Unreleased + ## 0.39.0 — 2026-07-04 ### Added diff --git a/version.env b/version.env index c4ed0ce496..0a44da7e14 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.39.0 -BUILD_NUMBER=97 +MARKETING_VERSION=0.39.1 +BUILD_NUMBER=98 From 2e325b86ac30efdf7139abf3aec0316e0d145fb8 Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Sun, 5 Jul 2026 00:41:22 +0100 Subject: [PATCH 042/105] Guard Mistral credit balances (#1890) --- .../Providers/Mistral/MistralModels.swift | 3 ++- .../Mistral/MistralUsageFetcher.swift | 8 ++++++- .../MistralUsageParserTests.swift | 24 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index 20e2821b20..a337501e75 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -287,7 +287,8 @@ public struct MistralCreditsSnapshot: Codable, Equatable, Sendable { } public var availableAmount: Double { - max(0, self.walletAmount + self.creditNotesAmount - self.ongoingUsageBalance) + let amount = self.walletAmount + self.creditNotesAmount - self.ongoingUsageBalance + return amount.isFinite ? max(0, amount) : 0 } public var formattedAvailableAmount: String { diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift index 4d48888094..520cc4b694 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -154,11 +154,17 @@ public enum MistralUsageFetcher { throw MistralUsageError.parseFailed(error.localizedDescription) } - return MistralCreditsSnapshot( + let snapshot = MistralCreditsSnapshot( walletAmount: response.walletAmount, creditNotesAmount: response.creditNotesAmount ?? 0, ongoingUsageBalance: response.ongoingUsageBalance ?? 0, currency: response.currency) + let amounts = [snapshot.walletAmount, snapshot.creditNotesAmount, snapshot.ongoingUsageBalance] + let available = snapshot.walletAmount + snapshot.creditNotesAmount - snapshot.ongoingUsageBalance + guard amounts.allSatisfy(\.isFinite), available.isFinite else { + throw MistralUsageError.parseFailed("Invalid credit amount") + } + return snapshot } static func vibeCookieHeader(csrfToken: String) throws -> String { diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index 6fb3ef367b..3858d56cde 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -189,6 +189,30 @@ struct MistralUsageParserTests { #expect(credits.formattedAvailableAmount == "$0.00") } + @Test + func `rejects credit amounts whose sum overflows`() throws { + let json = """ + { + "wallet_amount": 1e308, + "credit_notes_amount": 1e308, + "ongoing_usage_balance": 0, + "currency": "USD" + } + """ + + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.parseCredits(data: Data(json.utf8)) + } + + let credits = MistralCreditsSnapshot( + walletAmount: 1e308, + creditNotesAmount: 1e308, + ongoingUsageBalance: 0, + currency: "USD") + #expect(credits.availableAmount == 0) + #expect(credits.formattedAvailableAmount == "$0.00") + } + @Test func `fetches credits from dashboard endpoint with existing web session`() async throws { let json = """ From f837c33b95afb3e3b00eec219ebfb89407ceb683 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 16:41:40 -0700 Subject: [PATCH 043/105] docs: note Mistral credit validation --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f3efb8d7..8291319fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.39.1 — Unreleased +### Fixed +- 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 From e677087a1b1e0a6047f82ac7a2de39b9a8c340a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 19:42:41 -0400 Subject: [PATCH 044/105] ci: modernize build toolchains (#1905) * ci: modernize build toolchains * ci: keep macOS tests on proven runner --- .github/workflows/ci.yml | 48 +++++++++++++++++--------- .github/workflows/release-cli.yml | 39 +++++++++++++++------ .github/workflows/upstream-monitor.yml | 6 ++-- .swiftformat | 5 +-- Scripts/install_lint_tools.sh | 22 +++++++----- 5 files changed, 79 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2afaeda982..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 @@ -94,18 +96,20 @@ jobs: 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 @@ -149,7 +153,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" lint-build-test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 needs: - changes @@ -157,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: | @@ -203,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: | @@ -213,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 @@ -235,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" @@ -250,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/.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/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" From 4f281ab3d8669a48a8dc5ef8b2fc0879298fe89b Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Sun, 5 Jul 2026 01:39:22 +0100 Subject: [PATCH 045/105] Add Mistral widget support (#1891) --- .../CodexBarWidgetProvider.swift | 5 +++- .../CodexBarWidgetProviderTests.swift | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index cf18920cc8..77b6f46707 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -16,6 +16,7 @@ enum ProviderChoice: String, AppEnum { case kilo case opencode case opencodego + case mistral static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") @@ -32,6 +33,7 @@ enum ProviderChoice: String, AppEnum { .kilo: DisplayRepresentation(title: "Kilo"), .opencode: DisplayRepresentation(title: "OpenCode"), .opencodego: DisplayRepresentation(title: "OpenCode Go"), + .mistral: DisplayRepresentation(title: "Mistral"), ] var provider: UsageProvider { @@ -48,6 +50,7 @@ enum ProviderChoice: String, AppEnum { case .kilo: .kilo case .opencode: .opencode case .opencodego: .opencodego + case .mistral: .mistral } } @@ -94,7 +97,7 @@ enum ProviderChoice: String, AppEnum { case .doubao: return nil // Doubao not yet supported in widgets case .sakana: return nil // Sakana AI not yet supported in widgets case .abacus: return nil // Abacus AI not yet supported in widgets - case .mistral: return nil // Mistral not yet supported in widgets + case .mistral: self = .mistral case .deepseek: return nil // DeepSeek not yet supported in widgets case .codebuff: return nil // Codebuff not yet supported in widgets case .crof: return nil // Crof not yet supported in widgets diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 25785e6e55..d7d20572a5 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -259,6 +259,12 @@ struct CodexBarWidgetProviderTests { #expect(ProviderChoice.opencodego.provider == .opencodego) } + @Test + func `provider choice supports Mistral`() { + #expect(ProviderChoice(provider: .mistral) == .mistral) + #expect(ProviderChoice.mistral.provider == .mistral) + } + @Test func `provider choice excludes unsupported Chutes widgets`() { #expect(ProviderChoice(provider: .chutes) == nil) @@ -307,6 +313,24 @@ struct CodexBarWidgetProviderTests { #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.alibabatokenplan]) } + @Test + func `supported providers keep Mistral when it is the only enabled provider`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let entry = WidgetSnapshot.ProviderEntry( + provider: .mistral, + updatedAt: now, + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + let snapshot = WidgetSnapshot(entries: [entry], enabledProviders: [.mistral], generatedAt: now) + + #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.mistral]) + } + @Test func `codex weekly only widget rows omit session`() { let now = Date(timeIntervalSince1970: 1_700_000_000) From 19f03ad96e929acb995724ff285fdd70d2104354 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 17:39:57 -0700 Subject: [PATCH 046/105] docs: note Mistral widget support --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8291319fb2..71ad961eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.39.1 — Unreleased +### Added +- Widgets: make Mistral available in provider selection and switching. Thanks @joeVenner! + ### Fixed - Mistral: reject non-finite and overflowing credit balances before they can reach menu, CLI, or widget formatting. Thanks @joeVenner! From 17347d847b155dfb8ef0ca5bd63dece241654f71 Mon Sep 17 00:00:00 2001 From: Zihao Qi <35388022+Zihao-Qi@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:05:22 -0500 Subject: [PATCH 047/105] Settings window layout (#1901) * Fix settings sidebar width * Improve settings window layout * Polish settings window sidebar * fix: cap settings detail width * fix: preserve settings window resizing * fix: keep settings window resizable --------- Co-authored-by: Peter Steinberger --- Sources/CodexBar/CodexbarApp.swift | 2 +- Sources/CodexBar/PreferencesAboutPane.swift | 1 + .../CodexBar/PreferencesAdvancedPane.swift | 1 + Sources/CodexBar/PreferencesDisplayPane.swift | 1 + Sources/CodexBar/PreferencesGeneralPane.swift | 1 + .../PreferencesProviderDetailView.swift | 1 + Sources/CodexBar/PreferencesSidebar.swift | 8 +- Sources/CodexBar/PreferencesView.swift | 184 ++++++++++++------ .../SettingsWindowAppearanceTests.swift | 74 +++---- 9 files changed, 177 insertions(+), 96 deletions(-) diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 026ede64f9..7f3cc03913 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) { 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 11546bed0f..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() } diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 11572ec8e2..720a325377 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -155,5 +155,6 @@ struct GeneralPane: View { } .formStyle(.grouped) .toggleStyle(.switch) + .scrollContentBackground(.hidden) } } diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 71c4a62f0c..24893ae865 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -183,6 +183,7 @@ struct ProviderDetailView: View { } } .formStyle(.grouped) + .scrollContentBackground(.hidden) } } 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/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift b/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift index 83c6974ee7..423c31f641 100644 --- a/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift +++ b/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift @@ -6,10 +6,10 @@ import Testing @MainActor struct SettingsWindowAppearanceTests { @Test - func `settings always keeps both navigation columns visible`() { - #expect(PreferencesView.visibleColumnVisibility(for: .detailOnly) == .doubleColumn) - #expect(PreferencesView.visibleColumnVisibility(for: .automatic) == .doubleColumn) - #expect(PreferencesView.visibleColumnVisibility(for: .doubleColumn) == .doubleColumn) + func `settings sidebar uses a fixed noncollapsible width`() { + #expect(SettingsPane.sidebarWidth == 260) + #expect(SettingsPane.windowMinWidth > SettingsPane.sidebarWidth) + #expect(SettingsPane.detailMaxWidth > SettingsPane.windowMinWidth - SettingsPane.sidebarWidth) } @Test @@ -47,7 +47,7 @@ struct SettingsWindowAppearanceTests { } @Test - func `settings window sizing restores a collapsed sidebar without private identifiers`() { + func `settings window sizing does not mutate content split views`() { let window = NSWindow( contentRect: NSRect(x: 120, y: 160, width: 180, height: 140), styleMask: [.titled], @@ -66,34 +66,7 @@ struct SettingsWindowAppearanceTests { SettingsWindowSizing.enforceMinimumSize(window) #expect(window.frame.width >= window.minSize.width) - #expect(sidebar.frame.width >= SettingsPane.sidebarWidth) - } - - @Test - func `settings window sizing repairs the outer navigation split`() { - let window = NSWindow( - contentRect: NSRect(x: 120, y: 160, width: SettingsPane.windowWidth, height: SettingsPane.windowHeight), - styleMask: [.titled], - backing: .buffered, - defer: false) - let outerSplit = NSSplitView( - frame: NSRect(x: 0, y: 0, width: SettingsPane.windowWidth, height: SettingsPane.windowHeight)) - outerSplit.isVertical = true - let sidebar = NSView(frame: NSRect(x: 0, y: 0, width: 0, height: SettingsPane.windowHeight)) - let detail = NSView( - frame: NSRect(x: 0, y: 0, width: SettingsPane.windowWidth, height: SettingsPane.windowHeight)) - let nestedSplit = NSSplitView(frame: NSRect(x: 0, y: 0, width: 300, height: 300)) - nestedSplit.isVertical = true - nestedSplit.addSubview(NSView(frame: .zero)) - nestedSplit.addSubview(NSView(frame: .zero)) - detail.addSubview(nestedSplit) - outerSplit.addSubview(sidebar) - outerSplit.addSubview(detail) - window.contentView = outerSplit - - SettingsWindowSizing.enforceMinimumSize(window) - - #expect(sidebar.frame.width >= SettingsPane.sidebarWidth) + #expect(sidebar.frame.width == 0) } @Test @@ -126,6 +99,41 @@ struct SettingsWindowAppearanceTests { #expect(window.viewsNeedDisplay) } + @Test + func `bridge updates window title without pulsing appearance on pane changes`() { + let resetCapture = ResetCapture() + let bridge = SettingsWindowAppearanceView { resetCapture.actions.append($0) } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false) + window.contentView = bridge + resetCapture.actions.removeAll() + + bridge.refreshWindowAppearance(for: .light, windowTitle: "Display") + #expect(resetCapture.actions.count == 1) + + bridge.refreshWindowAppearance(for: .light, windowTitle: "General") + + #expect(window.title == "General") + #expect(resetCapture.actions.count == 1) + } + + @Test + func `settings window style remains resizable`() { + let bridge = SettingsWindowAppearanceView() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false) + + window.contentView = bridge + + #expect(window.styleMask.contains(.resizable)) + } + @Test func `repeated theme updates cannot leave an explicit appearance`() { let resetCapture = ResetCapture() From 5c608332e37c60aab5ca6508ade22f39568c9c46 Mon Sep 17 00:00:00 2001 From: Ivan Kononov <48845890+konon4@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:46:37 +0500 Subject: [PATCH 048/105] Improve long reset countdown precision (#1892) * Reset countdown: always break down days into d/h/m, not just d/h resetCountdownDescription previously dropped minutes once the countdown crossed into days (e.g. "in 1d 2h"), even though the hours+minutes case one tier down already showed both ("in 3h 31m"). Extend the days branch to match: show whichever of hours/minutes are non-zero, so "Weekly: Resets in 5d 37m" (0 whole hours) doesn't silently round down to "5d". * Fix swiftformat redundant-parens lint violation in reset countdown tests * fix: keep reset countdowns compact --------- Co-authored-by: Peter Steinberger --- Sources/CodexBarCore/UsageFormatter.swift | 1 + Tests/CodexBarTests/UsageFormatterTests.swift | 32 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index e5ce49d4ba..5cea548025 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -112,6 +112,7 @@ public enum UsageFormatter { if days > 0 { if hours > 0 { return "in \(days)d \(hours)h" } + if minutes > 0 { return "in \(days)d \(minutes)m" } return "in \(days)d" } if hours > 0 { diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 09a9e00ee5..9927243649 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -214,12 +214,40 @@ struct UsageFormatterTests { } @Test - func `reset countdown days and hours`() { + func `reset countdown caps days with hours at two units`() { let now = Date(timeIntervalSince1970: 1_000_000) - let reset = now.addingTimeInterval((26 * 3600) + 10) + let reset = now.addingTimeInterval((26 * 3600) + (1 * 60)) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 2h") } + @Test + func `reset countdown days and exact hours`() { + let now = Date(timeIntervalSince1970: 1_000_000) + let reset = now.addingTimeInterval(26 * 3600) + #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 2h") + } + + @Test + func `reset countdown days and minutes without whole hours`() { + let now = Date(timeIntervalSince1970: 1_000_000) + let reset = now.addingTimeInterval((24 * 3600) + (5 * 60)) + #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 5m") + } + + @Test + func `reset countdown exact days`() { + let now = Date(timeIntervalSince1970: 1_000_000) + let reset = now.addingTimeInterval(2 * 24 * 3600) + #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 2d") + } + + @Test + func `reset countdown rounds the last minute into a day`() { + let now = Date(timeIntervalSince1970: 1_000_000) + let reset = now.addingTimeInterval((24 * 3600) - 59) + #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d") + } + @Test func `reset countdown exact hour`() { let now = Date(timeIntervalSince1970: 1_000_000) From 37e42252c37d6a8f2bec23a44bfe389c84db28db Mon Sep 17 00:00:00 2001 From: FND <141281206+FNDEVVE@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:34:04 +0200 Subject: [PATCH 049/105] Add Devin extra usage balance Add and validate Devin extra-usage balances across menus, CLI, widgets, and snapshots. Co-authored-by: FND --- Sources/CodexBar/MenuCardView+Costs.swift | 2 +- Sources/CodexBar/MenuCardView.swift | 1 + .../Devin/DevinProviderImplementation.swift | 11 ++ .../CodexBar/UsageStore+WidgetSnapshot.swift | 10 +- Sources/CodexBarCLI/CLIRenderer.swift | 25 ++++- .../Providers/Devin/DevinUsageSnapshot.swift | 29 ++++- Sources/CodexBarCore/WidgetSnapshot.swift | 5 +- .../CodexBarWidgetProvider.swift | 5 +- .../CodexBarWidget/CodexBarWidgetViews.swift | 85 +++++++++++--- Tests/CodexBarTests/CLISnapshotTests.swift | 27 +++++ .../CodexBarWidgetProviderTests.swift | 104 ++++++++++++++++++ .../DevinUsageFetcherTests.swift | 74 +++++++++++++ .../UsageStoreWidgetSnapshotTests.swift | 48 ++++++++ 13 files changed, 406 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 51aa6c25e9..dbddeec183 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -283,7 +283,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"), diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 7bcdeb6667..fe74cc53c3 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -842,6 +842,7 @@ 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 input.provider == .sakana { 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/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index ea2c35db79..a226fb4175 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -68,6 +68,13 @@ extension UsageStore { creditsRemaining = nil codeReviewRemaining = nil } + let providerCost: ProviderCostSnapshot? = if provider == .devin, + self.settings.showOptionalCreditsAndExtraUsage + { + snapshot?.providerCost + } else { + nil + } return WidgetSnapshot.ProviderEntry( provider: provider, @@ -79,7 +86,8 @@ extension UsageStore { creditsRemaining: creditsRemaining, codeReviewRemainingPercent: codeReviewRemaining, tokenUsage: tokenUsage, - dailyUsage: dailyUsage) + dailyUsage: dailyUsage, + providerCost: providerCost) } private nonisolated static func widgetTokenUsageSummary( diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 69f6978acf..4fdd12e14d 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -39,6 +39,11 @@ enum CLIRenderer { self.appendClawRouterUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendDeepgramLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendAmpBalanceLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) + self.appendDevinOverageBalanceLine( + provider: provider, + snapshot: snapshot, + useColor: context.useColor, + lines: &lines) self.appendLimitsUnavailableLine( provider: provider, snapshot: snapshot, @@ -112,7 +117,11 @@ enum CLIRenderer { return } - guard provider != .clawrouter, let cost = snapshot.providerCost else { return } + guard + provider != .clawrouter, + let cost = snapshot.providerCost, + !(provider == .devin && cost.period == "Extra usage balance") + else { return } // Fallback to cost/quota display if no primary rate window. let label = cost.currencyCode == "Quota" ? "Quota" : "Cost" let value = "\(String(format: "%.1f", cost.used)) / \(String(format: "%.1f", cost.limit))" @@ -148,6 +157,20 @@ enum CLIRenderer { lines.append(self.labelValueLine("Balance", value: usage.balanceDetail, useColor: useColor)) } + private static func appendDevinOverageBalanceLine( + provider: UsageProvider, + snapshot: UsageSnapshot, + useColor: Bool, + lines: inout [String]) + { + guard provider == .devin, + let cost = snapshot.providerCost, + cost.period == "Extra usage balance" + else { return } + let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + lines.append(self.labelValueLine("Extra usage", value: balance, useColor: useColor)) + } + private static func appendCrossModelUsageLines( snapshot: UsageSnapshot, useColor: Bool, diff --git a/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift index 53bbcc50c4..68a09aeaa5 100644 --- a/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift @@ -41,19 +41,22 @@ public struct DevinUsageSnapshot: Sendable, Equatable { public let planName: String? public let organization: String? public let updatedAt: Date + public let overageBalance: Double? public init( daily: DevinQuotaWindow?, weekly: DevinQuotaWindow?, planName: String?, organization: String?, - updatedAt: Date) + updatedAt: Date, + overageBalance: Double? = nil) { self.daily = daily self.weekly = weekly self.planName = planName self.organization = organization self.updatedAt = updatedAt + self.overageBalance = overageBalance } public func toUsageSnapshot() -> UsageSnapshot { @@ -76,9 +79,18 @@ public struct DevinUsageSnapshot: Sendable, Equatable { accountEmail: nil, accountOrganization: self.organization, loginMethod: self.planName) + let providerCost: ProviderCostSnapshot? = self.overageBalance.map { + ProviderCostSnapshot( + used: $0, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: self.updatedAt) + } return UsageSnapshot( primary: primary, secondary: secondary, + providerCost: providerCost, updatedAt: self.updatedAt, identity: identity) } @@ -103,7 +115,20 @@ public enum DevinUsageParser { weekly: weekly, planName: self.findPlanName(in: object), organization: self.displayOrganization(from: organization), - updatedAt: now) + updatedAt: now, + overageBalance: self.findOverageBalance(in: object)) + } + + private static func findOverageBalance(in object: Any) -> Double? { + guard let dictionary = object as? [String: Any] else { return nil } + if let value = self.nonnegativeFiniteDouble(dictionary["overage_balance"]) { return value } + if let cents = self.nonnegativeFiniteDouble(dictionary["overage_balance_cents"]) { return cents / 100.0 } + return nil + } + + private static func nonnegativeFiniteDouble(_ value: Any?) -> Double? { + guard let value = self.double(value), value.isFinite, value >= 0 else { return nil } + return value } private static func currentQuotaWindows(_ dictionary: [String: Any]) diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index 80f1d3065d..686b65debf 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -24,6 +24,7 @@ public struct WidgetSnapshot: Codable, Sendable { public let codeReviewRemainingPercent: Double? public let tokenUsage: TokenUsageSummary? public let dailyUsage: [DailyUsagePoint] + public let providerCost: ProviderCostSnapshot? public init( provider: UsageProvider, @@ -35,7 +36,8 @@ public struct WidgetSnapshot: Codable, Sendable { creditsRemaining: Double?, codeReviewRemainingPercent: Double?, tokenUsage: TokenUsageSummary?, - dailyUsage: [DailyUsagePoint]) + dailyUsage: [DailyUsagePoint], + providerCost: ProviderCostSnapshot? = nil) { self.provider = provider self.updatedAt = updatedAt @@ -47,6 +49,7 @@ public struct WidgetSnapshot: Codable, Sendable { self.codeReviewRemainingPercent = codeReviewRemainingPercent self.tokenUsage = tokenUsage self.dailyUsage = dailyUsage + self.providerCost = providerCost } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 77b6f46707..4e4cd56ec0 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -12,6 +12,7 @@ enum ProviderChoice: String, AppEnum { case antigravity case zai case copilot + case devin case minimax case kilo case opencode @@ -29,6 +30,7 @@ enum ProviderChoice: String, AppEnum { .antigravity: DisplayRepresentation(title: "Antigravity"), .zai: DisplayRepresentation(title: "z.ai"), .copilot: DisplayRepresentation(title: "Copilot"), + .devin: DisplayRepresentation(title: "Devin"), .minimax: DisplayRepresentation(title: "MiniMax"), .kilo: DisplayRepresentation(title: "Kilo"), .opencode: DisplayRepresentation(title: "OpenCode"), @@ -46,6 +48,7 @@ enum ProviderChoice: String, AppEnum { case .antigravity: .antigravity case .zai: .zai case .copilot: .copilot + case .devin: .devin case .minimax: .minimax case .kilo: .kilo case .opencode: .opencode @@ -71,7 +74,7 @@ enum ProviderChoice: String, AppEnum { case .zai: self = .zai case .factory: return nil // Factory not yet supported in widgets case .copilot: self = .copilot - case .devin: return nil // Devin not yet supported in widgets + case .devin: self = .devin case .minimax: self = .minimax case .manus: return nil // Manus not yet supported in widgets case .vertexai: return nil // Vertex AI not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 79ed8dee99..ad450e00ce 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -164,7 +164,7 @@ private struct CompactMetricView: View { let metric: CompactMetric var body: some View { - let display = self.display + let display = CompactMetricFormatter.display(for: self.entry, metric: self.metric) VStack(alignment: .leading, spacing: 8) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) VStack(alignment: .leading, spacing: 2) { @@ -183,26 +183,40 @@ private struct CompactMetricView: View { } .padding(12) } +} + +struct CompactMetricDisplay: Equatable { + let value: String + let label: String + let detail: String? +} - private var display: (value: String, label: String, detail: String?) { - switch self.metric { +enum CompactMetricFormatter { + static func display(for entry: WidgetSnapshot.ProviderEntry, metric: CompactMetric) -> CompactMetricDisplay { + switch metric { case .credits: - let value = self.entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" - return (value, "Credits left", nil) + if let cost = WidgetBalanceFormatter.extraUsageCost(for: entry) { + return CompactMetricDisplay( + value: WidgetFormat.currency(cost.used, code: cost.currencyCode), + label: "Extra usage balance", + detail: nil) + } + let value = entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" + return CompactMetricDisplay(value: value, label: "Credits left", detail: nil) case .todayCost: - let value = self.entry.tokenUsage.map { token in + let value = entry.tokenUsage.map { token in token.sessionCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" } ?? "—" - let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) - let label = self.entry.tokenUsage.map { "\($0.sessionLabel) cost" } ?? "Today cost" - return (value, label, detail) + let detail = entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) + let label = entry.tokenUsage.map { "\($0.sessionLabel) cost" } ?? "Today cost" + return CompactMetricDisplay(value: value, label: label, detail: detail) case .last30DaysCost: - let value = self.entry.tokenUsage.map { token in + let value = entry.tokenUsage.map { token in token.last30DaysCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" } ?? "—" - let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) - let label = self.entry.tokenUsage.map { "\($0.last30DaysLabel) cost" } ?? "30d cost" - return (value, label, detail) + let detail = entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) + let label = entry.tokenUsage.map { "\($0.last30DaysLabel) cost" } ?? "30d cost" + return CompactMetricDisplay(value: value, label: label, detail: detail) } } } @@ -358,6 +372,9 @@ private struct SwitcherSmallUsageView: View { tokens: token.sessionTokens, currencyCode: token.currencyCode)) } + if let balance = extraUsageBalanceLine(for: entry) { + balance + } } } } @@ -387,6 +404,9 @@ private struct SwitcherMediumUsageView: View { tokens: token.sessionTokens, currencyCode: token.currencyCode)) } + if let balance = extraUsageBalanceLine(for: entry) { + balance + } } } } @@ -427,6 +447,9 @@ private struct SwitcherLargeUsageView: View { currencyCode: token.currencyCode)) } } + if let balance = extraUsageBalanceLine(for: entry) { + balance + } UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) .frame(height: 50) } @@ -462,6 +485,9 @@ private struct SmallUsageView: View { tokens: token.sessionTokens, currencyCode: token.currencyCode)) } + if let balance = extraUsageBalanceLine(for: entry) { + balance + } } .padding(12) } @@ -493,6 +519,9 @@ private struct MediumUsageView: View { tokens: token.sessionTokens, currencyCode: token.currencyCode)) } + if let balance = extraUsageBalanceLine(for: entry) { + balance + } } .padding(12) } @@ -535,6 +564,9 @@ private struct LargeUsageView: View { currencyCode: token.currencyCode)) } } + if let balance = extraUsageBalanceLine(for: entry) { + balance + } UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) .frame(height: 50) } @@ -918,6 +950,33 @@ enum WidgetColors { } } +struct WidgetBalanceLine: Equatable { + let title: String + let value: String +} + +enum WidgetBalanceFormatter { + static func extraUsageCost(for entry: WidgetSnapshot.ProviderEntry) -> ProviderCostSnapshot? { + guard entry.provider == .devin, + let cost = entry.providerCost, + cost.period == "Extra usage balance" + else { return nil } + return cost + } + + static func extraUsageBalance(for entry: WidgetSnapshot.ProviderEntry) -> WidgetBalanceLine? { + guard let cost = self.extraUsageCost(for: entry) else { return nil } + return WidgetBalanceLine( + title: "Extra usage", + value: "Balance: \(WidgetFormat.currency(cost.used, code: cost.currencyCode))") + } +} + +private func extraUsageBalanceLine(for entry: WidgetSnapshot.ProviderEntry) -> ValueLine? { + guard let line = WidgetBalanceFormatter.extraUsageBalance(for: entry) else { return nil } + return ValueLine(title: line.title, value: line.value) +} + enum WidgetFormat { static func percent(_ value: Double?) -> String { guard let value else { return "—" } diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index 5d83a206de..9a17aa67d6 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -1103,4 +1103,31 @@ struct CLISnapshotTests { #expect(output.contains("Tokens:")) #expect(output.contains("MCP:")) } + + @Test + func `devin overage balance without primary window omits generic cost line`() { + let snap = UsageSnapshot( + primary: nil, + secondary: .init(usedPercent: 42, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 48.0, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: Date(timeIntervalSince1970: 0)), + updatedAt: Date(timeIntervalSince1970: 0)) + let output = CLIRenderer.renderText( + provider: .devin, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Devin (devin)", + status: nil, + useColor: false, + resetStyle: .absolute)) + #expect(output.contains("Extra usage: $48.00")) + #expect(!output.contains("Cost:")) + #expect(!output.contains(" / 0.0")) + } } diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index d7d20572a5..6ca492a0bb 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -259,6 +259,110 @@ struct CodexBarWidgetProviderTests { #expect(ProviderChoice.opencodego.provider == .opencodego) } + @Test + func `provider choice supports devin`() { + #expect(ProviderChoice(provider: .devin) == .devin) + #expect(ProviderChoice.devin.provider == .devin) + } + + @Test + func `widget entry carries devin overage balance through providerCost`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .devin, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + providerCost: ProviderCostSnapshot( + used: 48.0, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000))) + let encoded = try? JSONEncoder().encode(entry) + #expect(encoded != nil) + let decoded = encoded.flatMap { try? JSONDecoder().decode(WidgetSnapshot.ProviderEntry.self, from: $0) } + #expect(decoded?.providerCost?.period == "Extra usage balance") + #expect(decoded?.providerCost?.used == 48.0) + #expect(decoded?.providerCost?.limit == 0) + #expect(decoded?.provider == .devin) + } + + @Test + func `widget balance formatter renders devin extra usage balance`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .devin, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + providerCost: ProviderCostSnapshot( + used: 48.0, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000))) + let line = WidgetBalanceFormatter.extraUsageBalance(for: entry) + #expect(line?.title == "Extra usage") + #expect(line?.value.hasPrefix("Balance: ") == true) + #expect(line?.value.contains("48") == true) + } + + @Test + func `compact credits render Devin extra usage balance`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .devin, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + providerCost: ProviderCostSnapshot( + used: 48.0, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000))) + + let display = CompactMetricFormatter.display(for: entry, metric: .credits) + + #expect(display.value.contains("48")) + #expect(display.label == "Extra usage balance") + #expect(display.detail == nil) + } + + @Test + func `widget balance formatter does not leak another provider balance`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .factory, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [], + providerCost: ProviderCostSnapshot( + used: 12.0, + limit: 100.0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000))) + #expect(WidgetBalanceFormatter.extraUsageBalance(for: entry) == nil) + } + @Test func `provider choice supports Mistral`() { #expect(ProviderChoice(provider: .mistral) == .mistral) diff --git a/Tests/CodexBarTests/DevinUsageFetcherTests.swift b/Tests/CodexBarTests/DevinUsageFetcherTests.swift index 7590df20c0..4745fce77a 100644 --- a/Tests/CodexBarTests/DevinUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DevinUsageFetcherTests.swift @@ -53,6 +53,80 @@ struct DevinUsageFetcherTests { #expect(snapshot.weekly?.resetsAt?.timeIntervalSince1970 == 1_781_424_000) } + @Test + func `parses overage balance into extra usage provider cost`() throws { + let response: [String: Any] = [ + "daily_percentage": 12, + "weekly_percentage": 42, + "overage_balance": 70.87, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.overageBalance == 70.87) + let cost = try #require(snapshot.toUsageSnapshot().providerCost) + #expect(cost.used == 70.87) + #expect(cost.limit == 0) + #expect(cost.currencyCode == "USD") + #expect(cost.period == "Extra usage balance") + #expect(cost.updatedAt == Self.now) + } + + @Test + func `parses overage balance cents into extra usage provider cost`() throws { + let response: [String: Any] = [ + "daily_percentage": 12, + "weekly_percentage": 42, + "overage_balance_cents": 7087, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.overageBalance == 70.87) + #expect(snapshot.toUsageSnapshot().providerCost?.period == "Extra usage balance") + } + + @Test + func `omits provider cost when overage balance is absent`() throws { + let response: [String: Any] = [ + "daily_percentage": 12, + "weekly_percentage": 42, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.overageBalance == nil) + #expect(snapshot.toUsageSnapshot().providerCost == nil) + } + + @Test(arguments: ["-1", "Infinity", "NaN"]) + func `omits invalid overage balances`(_ balance: String) throws { + let response: [String: Any] = [ + "daily_percentage": 12, + "weekly_percentage": 42, + "overage_balance": balance, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.overageBalance == nil) + #expect(snapshot.toUsageSnapshot().providerCost == nil) + } + + @Test(arguments: ["-1", "Infinity", "NaN"]) + func `omits invalid overage balance cents`(_ balance: String) throws { + let response: [String: Any] = [ + "daily_percentage": 12, + "weekly_percentage": 42, + "overage_balance_cents": balance, + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.overageBalance == nil) + #expect(snapshot.toUsageSnapshot().providerCost == nil) + } + @Test func `keeps weekly quota when current plan hides daily quota`() throws { let response: [String: Any] = [ diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index b106855d95..77c6ca659e 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -252,4 +252,52 @@ struct UsageStoreWidgetSnapshotTests { #expect(entry.tokenUsage?.sessionTokens == 4200) #expect(entry.tokenUsage?.last30DaysTokens == 42000) } + + @Test(arguments: [true, false]) + func `widget snapshot respects extra usage visibility for Devin`(_ showsExtraUsage: Bool) async throws { + let suite = "UsageStoreWidgetSnapshotTests-devin-extra-usage-\(showsExtraUsage)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.showOptionalCreditsAndExtraUsage = showsExtraUsage + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 48, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: updatedAt), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .devin, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)), + provider: .devin) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "devin-extra-usage-visibility-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .devin }) + #expect((entry.providerCost != nil) == showsExtraUsage) + } } From 68f5f9156a058bd2da8b9959f92ee674c1ee873e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 20:34:42 -0700 Subject: [PATCH 050/105] docs: update 0.39.1 changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ad961eb7..e1c44fb651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,14 @@ ## 0.39.1 — Unreleased ### Added +- 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! + ### Fixed +- 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 From ec83fcb6f8ed853aab272a67aa48bc322a8d2b42 Mon Sep 17 00:00:00 2001 From: Zihao Qi <35388022+Zihao-Qi@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:25:24 -0500 Subject: [PATCH 051/105] fix: preserve external language overrides (#1895) Preserve externally managed language overrides while migrating CodexBar-owned legacy preferences. Co-authored-by: Zihaoqi --- Sources/CodexBar/CodexbarApp.swift | 8 +- Sources/CodexBar/Localization.swift | 54 ++++++++--- Sources/CodexBar/PreferencesGeneralPane.swift | 63 ++++++++----- Sources/CodexBar/SettingsStore+Defaults.swift | 3 +- .../LocalizationLanguageCatalogTests.swift | 92 +++++++++++++++++++ .../PreferencesPaneSmokeTests.swift | 32 +++++++ 6 files changed, 208 insertions(+), 44 deletions(-) diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 7f3cc03913..ae6078a3dc 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -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/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/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 720a325377..6065075f9f 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -33,31 +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 .russian: L("language_russian") - case .indonesian: L("language_indonesian") - case .polish: L("language_polish") - case .persian: L("language_persian") - case .thai: L("language_thai") - case .galician: L("language_galician") - 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" } } } @@ -71,7 +84,7 @@ struct GeneralPane: View { Section { Picker(selection: self.$settings.appLanguage) { ForEach(AppLanguage.allCases) { option in - Text(option.label).tag(option.rawValue) + Text(verbatim: option.label).tag(option.rawValue) } } label: { SettingsRowLabel(L("language_title"), subtitle: L("language_subtitle")) diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index a550174def..724eabaa58 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -736,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 { @@ -744,6 +744,7 @@ extension SettingsStore { } UserDefaults.standard.removeObject(forKey: "AppleLanguages") } + resetCodexBarLocalizationCache() } } diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 315491ad7e..24aa785646 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -85,6 +85,87 @@ struct LocalizationLanguageCatalogTests { #expect(AppLanguage.galician.rawValue == "gl") } + @Test + func `language picker labels use stable native names`() { + let expected: [AppLanguage: String] = [ + .system: "System", + .english: "English", + .chineseSimplified: "简体中文", + .chineseTraditional: "繁體中文", + .japanese: "日本語", + .spanish: "Español", + .portugueseBrazilian: "Português (Brasil)", + .korean: "한국어", + .german: "Deutsch", + .french: "Français", + .arabic: "العربية", + .italian: "Italiano", + .vietnamese: "Tiếng Việt", + .dutch: "Nederlands", + .turkish: "Türkçe", + .ukrainian: "Українська", + .russian: "Русский", + .indonesian: "Bahasa Indonesia", + .polish: "Polski", + .persian: "فارسی", + .thai: "ไทย", + .galician: "Galego", + .catalan: "Català", + .swedish: "Svenska", + ] + + #expect(expected.count == AppLanguage.allCases.count) + + let japaneseLabels = CodexBarLocalizationOverride.$appLanguage.withValue("ja") { + Dictionary(uniqueKeysWithValues: AppLanguage.allCases.map { ($0, $0.label) }) + } + let arabicLabels = CodexBarLocalizationOverride.$appLanguage.withValue("ar") { + Dictionary(uniqueKeysWithValues: AppLanguage.allCases.map { ($0, $0.label) }) + } + + #expect(japaneseLabels == expected) + #expect(arabicLabels == expected) + } + + @Test + func `system language preserves an external Apple Languages override`() { + Self.withTemporaryDefaults(for: #function) { defaults, _ in + defaults.set(["de"], forKey: "AppleLanguages") + + AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned( + storedAppLanguage: "", + defaults: defaults) + + #expect(defaults.stringArray(forKey: "AppleLanguages") == ["de"]) + } + } + + @Test + func `matching legacy language override is cleared`() { + Self.withTemporaryDefaults(for: #function) { defaults, suiteName in + defaults.set(["ja"], forKey: "AppleLanguages") + + AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned( + storedAppLanguage: "ja", + defaults: defaults) + + #expect(defaults.persistentDomain(forName: suiteName)?["AppleLanguages"] == nil) + } + } + + @Test + func `unrelated external language override is preserved`() { + Self.withTemporaryDefaults(for: #function) { defaults, _ in + defaults.set(["de"], forKey: "AppleLanguages") + + AppLanguagePreferenceMigration.clearLegacyOverrideIfOwned( + storedAppLanguage: "ja", + defaults: defaults) + + #expect(defaults.stringArray(forKey: "AppleLanguages") == ["de"]) + } + } + @Test func `new language bundles include representative native labels`() throws { let root = URL(fileURLWithPath: #filePath) @@ -472,4 +553,15 @@ struct LocalizationLanguageCatalogTests { #expect(rendered.contains("7일간")) #expect(rendered.contains("3개 서비스")) } + + private static func withTemporaryDefaults( + for testName: String, + _ body: (UserDefaults, String) -> Void) + { + let suiteName = "LocalizationLanguageCatalogTests.\(testName).\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + body(defaults, suiteName) + } } diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index 7256275cd0..a555edaa26 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -115,6 +115,38 @@ struct PreferencesPaneSmokeTests { } } + @Test + func `language preference clears stale app level AppleLanguages override`() { + let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") + let previousAppleLanguages = UserDefaults.standard.object(forKey: "AppleLanguages") + defer { + if let previousLanguage { + UserDefaults.standard.set(previousLanguage, forKey: "appLanguage") + } else { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + if let previousAppleLanguages { + UserDefaults.standard.set(previousAppleLanguages, forKey: "AppleLanguages") + } else { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } + } + + let staleOverride = ["zz-StaleLanguageOverride"] + UserDefaults.standard.set(staleOverride, forKey: "AppleLanguages") + + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language-system") + settings.appLanguage = "ko" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "ko") + #expect(UserDefaults.standard.object(forKey: "AppleLanguages") as? [String] != staleOverride) + + settings.appLanguage = "" + + #expect(UserDefaults.standard.object(forKey: "appLanguage") == nil) + #expect(UserDefaults.standard.object(forKey: "AppleLanguages") as? [String] != staleOverride) + } + @Test func `german app language resolves localized labels`() { let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language-de") From 71a768b58e47d75d1f486355d9790d06f599033a Mon Sep 17 00:00:00 2001 From: Alekstodo <123273271+Alekstodo@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:46:55 +0200 Subject: [PATCH 052/105] feat: distinguish quota warning markers (#1906) Render quota warnings as prominent punch-out markers while retaining separate subtle workday ticks, with deterministic overlap precedence and accessible descriptions. Co-authored-by: Aleks todo --- .../CodexBar/MenuCardHeightFingerprint.swift | 3 +- .../MenuCardQuotaWarningMarkers.swift | 50 ------ Sources/CodexBar/MenuCardView+MiniMax.swift | 17 +- .../CodexBar/MenuCardView+ModelHelpers.swift | 1 + Sources/CodexBar/MenuCardView.swift | 25 ++- .../PreferencesProviderDetailView.swift | 3 +- Sources/CodexBar/UsageProgressBar.swift | 155 +++++++++++++++--- .../MenuCardModelCodexProjectionTests.swift | 11 +- .../MenuCardQuotaWarningMarkerTests.swift | 80 ++++++++- .../MiniMaxMenuCardModelPlanTests.swift | 2 + 10 files changed, 257 insertions(+), 90 deletions(-) diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index a3aea4ccfb..834e678008 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -89,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)", ]) } } 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/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 01406e2222..030e13fd47 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) } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index fe74cc53c3..cb5d3292c0 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 } @@ -462,7 +465,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) @@ -1514,7 +1518,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( @@ -1559,10 +1568,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/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 24893ae865..c4bcacb2f5 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -425,7 +425,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 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/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift index 506f4b23e2..58fa3abeda 100644 --- a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift +++ b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift @@ -127,14 +127,15 @@ struct MenuCardModelCodexProjectionTests { now: now)) let weekly = try #require(model.metrics.first { $0.id == "secondary" }) - #expect(weekly.warningMarkerPercents == [20.0, 40.0, 60.0, 80.0]) + #expect(weekly.warningMarkerPercents.isEmpty) + #expect(weekly.workdayMarkerPercents == [20.0, 40.0, 60.0, 80.0]) let session = try #require(model.metrics.first { $0.id == "primary" }) #expect(session.warningMarkerPercents.isEmpty) } @Test - func `codex weekly lane workday markers merge with quota warning markers`() throws { + func `codex weekly lane keeps workday and quota warning markers separate`() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let metadata = try #require(ProviderDefaults.metadata[.codex]) let identity = ProviderIdentitySnapshot( @@ -193,7 +194,8 @@ struct MenuCardModelCodexProjectionTests { now: now)) let weekly = try #require(model.metrics.first { $0.id == "secondary" }) - #expect(weekly.warningMarkerPercents == [20.0, 40.0, 50.0, 60.0, 80.0]) + #expect(weekly.warningMarkerPercents == [50.0]) + #expect(weekly.workdayMarkerPercents == [20.0, 40.0, 60.0, 80.0]) } @Test @@ -256,7 +258,8 @@ struct MenuCardModelCodexProjectionTests { now: now)) let weekly = try #require(model.metrics.first { $0.id == "secondary" }) - #expect(weekly.warningMarkerPercents == [20.0, 40.0, 60.0, 80.0]) + #expect(weekly.warningMarkerPercents.isEmpty) + #expect(weekly.workdayMarkerPercents == [20.0, 40.0, 60.0, 80.0]) } @Test diff --git a/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift index 89c8ca9e16..744f6fa7c0 100644 --- a/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift +++ b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift @@ -13,18 +13,90 @@ struct MenuCardQuotaWarningMarkerTests { } @Test - func `quota warning marker geometry is inset and hairline`() { + func `quota warning marker geometry matches pace stripe edges`() { let rect = UsageProgressBar.warningMarkerRect( x: 50, size: CGSize(width: 100, height: 6), scale: 2) + let stripe = UsageProgressBar.warningMarkerStripeRect( + rect, + scale: 2) + + #expect(rect.width == 5) + #expect(rect.height == 6) + #expect(rect.minY == 0) + #expect(rect.maxY == 6) + #expect(abs(rect.midX - 50) <= 0.5) + #expect(stripe.width == 1) + #expect(stripe.height == rect.height) + #expect(abs(stripe.midX - rect.midX) <= 0.001) + #expect(stripe.minX > rect.minX) + #expect(stripe.maxX < rect.maxX) + } + + @Test + func `quota warning marker geometry stays centered across display scales`() { + let scales: [CGFloat] = [1, 2, 3] + + for scale in scales { + let rect = UsageProgressBar.warningMarkerRect( + x: 33, + size: CGSize(width: 100, height: 6), + scale: scale) + let stripe = UsageProgressBar.warningMarkerStripeRect( + rect, + scale: scale) + + #expect(rect.minY == 0) + #expect(rect.height == 6) + #expect(rect.width == 5) + #expect(stripe.width == 1) + #expect(stripe.height == rect.height) + #expect(abs(stripe.midX - rect.midX) <= 1 / scale) + #expect(stripe.minX > rect.minX) + #expect(stripe.maxX < rect.maxX) + } + } + + @Test + func `workday boundary is a subtle lower tick`() { + let rect = UsageProgressBar.workdayMarkerRect( + x: 50, + size: CGSize(width: 100, height: 6), + scale: 2) - #expect(rect.width == 1) - #expect(rect.height < 6) - #expect(rect.minY > 0) + #expect(rect.width == 0.5) + #expect(rect.height == 3) + #expect(rect.minY == 3) #expect(abs(rect.midX - 50) <= 0.5) } + @Test + func `quota warning wins when marker kinds overlap`() { + let markers = UsageProgressBar.resolvedMarkers( + warningPercents: [50, 80], + workdayPercents: [20, 50, 60]) + + #expect(markers == [ + .init(percent: 20, kind: .workdayBoundary), + .init(percent: 50, kind: .quotaWarning), + .init(percent: 60, kind: .workdayBoundary), + .init(percent: 80, kind: .quotaWarning), + ]) + } + + @Test + func `marker resolver removes edges duplicates and invalid values`() { + let markers = UsageProgressBar.resolvedMarkers( + warningPercents: [-10, 0, 50, 50, 100, 120], + workdayPercents: [Double.nan, 25, 25]) + + #expect(markers == [ + .init(percent: 25, kind: .workdayBoundary), + .init(percent: 50, kind: .quotaWarning), + ]) + } + @Test func `omits quota warning markers for disabled windows`() throws { let now = Date() diff --git a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift index 23740cb502..11a80863d4 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift @@ -151,9 +151,11 @@ struct MiniMaxMenuCardModelPlanTests { showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]], + workDaysPerWeek: 5, now: now)) #expect(model.metrics.map(\.warningMarkerPercents) == [[50, 80], [50, 80]]) + #expect(model.metrics.map(\.workdayMarkerPercents) == [[], [20, 40, 60, 80]]) } @Test From f1de7006027e1a47dbe235f8efa81201186205b1 Mon Sep 17 00:00:00 2001 From: FND <141281206+FNDEVVE@users.noreply.github.com> Date: Sun, 5 Jul 2026 07:11:55 +0200 Subject: [PATCH 053/105] feat: add cost chart scale labels (#1898) Add compact cost scale labels to detailed history charts, inline usage bars, and widgets. Keep labels truthful to the plotted domain, reserve layout space to avoid peak-bar overlap, and use consistent currency formatting across surfaces. Co-authored-by: FND --- .../CodexBar/CostHistoryChartMenuView.swift | 99 +++++++++++++----- .../InlineUsageDashboardContent.swift | 73 +++++++++---- Sources/CodexBarCore/UsageChartScale.swift | 16 +++ Sources/CodexBarCore/UsageFormatter.swift | 10 ++ .../CodexBarWidget/CodexBarWidgetViews.swift | 60 ++++++++--- .../CodexBarWidgetProviderTests.swift | 24 +++++ .../CostHistoryChartMenuViewTests.swift | 37 +++++++ ...InlineCostHistoryDashboardLabelTests.swift | 98 +++++++++++++++++ .../CodexBarTests/UsageChartScaleTests.swift | 23 ++++ Tests/CodexBarTests/UsageFormatterTests.swift | 10 ++ docs/screenshots/cost-chart-yaxis-labels.png | Bin 0 -> 247405 bytes 11 files changed, 385 insertions(+), 65 deletions(-) create mode 100644 Sources/CodexBarCore/UsageChartScale.swift create mode 100644 Tests/CodexBarTests/UsageChartScaleTests.swift create mode 100644 docs/screenshots/cost-chart-yaxis-labels.png diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index ae6cea50ae..ad887cbeaf 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -100,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")) @@ -128,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) @@ -287,8 +290,7 @@ 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 @@ -311,7 +313,6 @@ struct CostHistoryChartMenuView: View { { var height = self.verticalPadding * 2 height += self.chartHeight - height += self.axisLabelAreaHeight height += self.outerSpacing height += self.detailBlockHeight(rows: rows) if hasTotal { @@ -361,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] = [] @@ -423,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) @@ -740,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 { @@ -765,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( diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 0644d39444..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 { @@ -378,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: [ @@ -398,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( @@ -441,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: [ @@ -458,6 +463,8 @@ extension UsageMenuCardView.Model { ], points: points, detailLines: details) + model.currencyCode = "USD" + return model } private static func crossModelInlineDashboard(_ usage: CrossModelUsageSnapshot) -> InlineUsageDashboardModel? { @@ -474,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: [ @@ -494,6 +501,8 @@ extension UsageMenuCardView.Model { ], points: points, detailLines: []) + model.currencyCode = usage.currency + return model } private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? { @@ -529,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: [ @@ -549,6 +558,8 @@ extension UsageMenuCardView.Model { ], points: points, detailLines: details) + model.currencyCode = "USD" + return model } private static func zaiInlineDashboard(modelUsage: ZaiModelUsageData, now: Date) -> InlineUsageDashboardModel? { @@ -832,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/CodexBarCore/UsageChartScale.swift b/Sources/CodexBarCore/UsageChartScale.swift new file mode 100644 index 0000000000..81c7eec6b7 --- /dev/null +++ b/Sources/CodexBarCore/UsageChartScale.swift @@ -0,0 +1,16 @@ +import Foundation + +public struct UsageChartScale: Equatable, Sendable { + public let maximum: Double + + public init(values: [Double]) { + self.maximum = values + .filter { $0.isFinite && $0 > 0 } + .max() ?? 0 + } + + public func fraction(for value: Double) -> Double { + guard self.maximum > 0, value.isFinite, value > 0 else { return 0 } + return min(value / self.maximum, 1) + } +} diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 5cea548025..75699be4ae 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -244,6 +244,16 @@ public enum UsageFormatter { value.formatted(.currency(code: currencyCode).locale(Locale(identifier: "en_US"))) } + public static func compactCurrencyString(_ value: Double, currencyCode: String) -> String { + if value != 0, abs(value) < 1 { + return self.currencyString(value, currencyCode: currencyCode) + } + return value.formatted( + .currency(code: currencyCode) + .precision(.fractionLength(0)) + .locale(Locale(identifier: "en_US"))) + } + public static func tokenCountString(_ value: Int) -> String { let absValue = abs(value) let sign = value < 0 ? "-" : "" diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index ad450e00ce..9da2649e02 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -450,7 +450,10 @@ private struct SwitcherLargeUsageView: View { if let balance = extraUsageBalanceLine(for: entry) { balance } - UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) + UsageHistoryChart( + points: self.entry.dailyUsage, + color: WidgetColors.color(for: self.entry.provider), + currencyCode: self.entry.tokenUsage?.currencyCode) .frame(height: 50) } } @@ -567,7 +570,10 @@ private struct LargeUsageView: View { if let balance = extraUsageBalanceLine(for: entry) { balance } - UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) + UsageHistoryChart( + points: self.entry.dailyUsage, + color: WidgetColors.color(for: self.entry.provider), + currencyCode: self.entry.tokenUsage?.currencyCode) .frame(height: 50) } .padding(12) @@ -724,7 +730,10 @@ private struct HistoryView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) + UsageHistoryChart( + points: self.entry.dailyUsage, + color: WidgetColors.color(for: self.entry.provider), + currencyCode: self.entry.tokenUsage?.currencyCode) .frame(height: self.isLarge ? 90 : 60) if let token = entry.tokenUsage { ValueLine( @@ -809,27 +818,50 @@ private struct ValueLine: View { private struct UsageHistoryChart: View { let points: [WidgetSnapshot.DailyUsagePoint] let color: Color + let currencyCode: String? var body: some View { + let isCostMode = UsageHistoryChartMode.isCostMode(self.points) let values = self.points.map { point -> Double in - if let cost = point.costUSD { return cost } + if isCostMode { return point.costUSD ?? 0 } return Double(point.totalTokens ?? 0) } - let maxValue = values.max() ?? 0 - HStack(alignment: .bottom, spacing: 2) { - ForEach(values.indices, id: \.self) { index in - let value = values[index] - let height = maxValue > 0 ? CGFloat(value / maxValue) : 0 - RoundedRectangle(cornerRadius: 2) - .fill(self.color.opacity(0.85)) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: height, anchor: .bottom) - .animation(.easeOut(duration: 0.2), value: height) + let scale = UsageChartScale(values: values) + VStack(alignment: .trailing, spacing: 2) { + if isCostMode, + let currencyCode = self.currencyCode, + scale.maximum > 0 + { + Text(UsageFormatter.compactCurrencyString(scale.maximum, currencyCode: currencyCode)) + .font(.caption2) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + .allowsTightening(true) + } + GeometryReader { geometry in + HStack(alignment: .bottom, spacing: 2) { + ForEach(values.indices, id: \.self) { index in + let fraction = scale.fraction(for: values[index]) + RoundedRectangle(cornerRadius: 2) + .fill(self.color.opacity(0.85)) + .frame(maxWidth: .infinity) + .frame(height: max(fraction > 0 ? 2 : 0, CGFloat(fraction) * geometry.size.height)) + .animation(.easeOut(duration: 0.2), value: fraction) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } } } } +enum UsageHistoryChartMode { + static func isCostMode(_ points: [WidgetSnapshot.DailyUsagePoint]) -> Bool { + !points.isEmpty && points.allSatisfy { $0.costUSD != nil } + } +} + enum WidgetColors { // swiftlint:disable:next cyclomatic_complexity static func color(for provider: UsageProvider) -> Color { diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 6ca492a0bb..1a059d7ed0 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -770,3 +770,27 @@ struct CodexBarWidgetProviderTests { return WidgetSnapshot(entries: [entry], generatedAt: entry.updatedAt) } } + +extension CodexBarWidgetProviderTests { + @Test + func `usage history chart mode requires every point to expose cost`() { + let costPoints = [ + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-01", totalTokens: 100, costUSD: 1.2), + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-02", totalTokens: 200, costUSD: 2.4), + ] + let tokenPoints = [ + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-01", totalTokens: 100, costUSD: nil), + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-02", totalTokens: 200, costUSD: nil), + ] + let mixedPoints = [ + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-01", totalTokens: 100, costUSD: 1.2), + WidgetSnapshot.DailyUsagePoint(dayKey: "2026-07-02", totalTokens: 200, costUSD: nil), + ] + let emptyPoints: [WidgetSnapshot.DailyUsagePoint] = [] + + #expect(UsageHistoryChartMode.isCostMode(costPoints) == true) + #expect(UsageHistoryChartMode.isCostMode(tokenPoints) == false) + #expect(UsageHistoryChartMode.isCostMode(mixedPoints) == false) + #expect(UsageHistoryChartMode.isCostMode(emptyPoints) == false) + } +} diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index 20840f1251..9718028687 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -163,6 +163,43 @@ struct CostHistoryChartMenuViewTests { daily: daily) == .hidden) } + @Test + @MainActor + func `y-axis tick values are empty for flat or no data`() { + #expect(CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 0).isEmpty) + #expect(CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: -1).isEmpty) + } + + @Test + @MainActor + func `y-axis tick values use two ticks for small ranges`() { + let ticks = CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 0.50) + #expect(ticks == [0, 0.50]) + } + + @Test + @MainActor + func `y-axis tick values use three ticks for ranges at or above one dollar`() { + let ticks = CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 12.0) + #expect(ticks == [0, 6.0, 12.0]) + + let large = CostHistoryChartMenuView._yAxisTickValuesForTesting(maxCostUSD: 1000.0) + #expect(large == [0, 500.0, 1000.0]) + } + + @Test(arguments: [ + (0.0, "$0"), + (12.56, "$13"), + (0.50, "$0.50"), + ]) + @MainActor + func `y-axis cost labels preserve cents only for nonzero sub-dollar values`( + value: Double, + expected: String) + { + #expect(CostHistoryChartMenuView._yAxisCostStringForTesting(value) == expected) + } + @Test @MainActor func `cost history total card height grows with rows and the total line`() { diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift index ab0385c909..08869b079d 100644 --- a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -170,4 +170,102 @@ struct InlineCostHistoryDashboardLabelTests { #expect(model.inlineUsageDashboard?.kpis[1].title == "This month") #expect(model.inlineUsageDashboard?.kpis[2].title == "This month tokens") } + + @Test + func `costHistoryInlineDashboard sets currencyCode from snapshot`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + currencyCode: "USD", + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let dashboard = try #require(model.inlineUsageDashboard) + #expect(dashboard.currencyCode == "USD") + } + + @Test + func `token-only inline dashboard leaves currencyCode nil`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.zai]) + let modelUsage = ZaiModelUsageData( + xTime: ["2023-11-17 00:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-test", tokensUsage: [123]), + ]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + zaiUsage: ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: nil, + planName: nil, + modelUsage: modelUsage, + updatedAt: now), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .zai, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + let dashboard = try #require(model.inlineUsageDashboard) + #expect(dashboard.currencyCode == nil) + } } diff --git a/Tests/CodexBarTests/UsageChartScaleTests.swift b/Tests/CodexBarTests/UsageChartScaleTests.swift new file mode 100644 index 0000000000..9d144a8cac --- /dev/null +++ b/Tests/CodexBarTests/UsageChartScaleTests.swift @@ -0,0 +1,23 @@ +import CodexBarCore +import Testing + +struct UsageChartScaleTests { + @Test + func `sub dollar maximum fills the chart`() { + let scale = UsageChartScale(values: [0.10, 0.25, 0.50]) + + #expect(scale.maximum == 0.50) + #expect(scale.fraction(for: 0.50) == 1) + #expect(scale.fraction(for: 0.25) == 0.5) + } + + @Test + func `scale ignores invalid and nonpositive values`() { + let scale = UsageChartScale(values: [.nan, .infinity, -10, 0, 4]) + + #expect(scale.maximum == 4) + #expect(scale.fraction(for: .nan) == 0) + #expect(scale.fraction(for: -1) == 0) + #expect(scale.fraction(for: 8) == 1) + } +} diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 9927243649..e33ed09d2b 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -372,6 +372,16 @@ struct UsageFormatterTests { #expect(result == "$0.00") } + @Test(arguments: [ + (0.0, "$0"), + (0.50, "$0.50"), + (12.56, "$13"), + (1515.0, "$1,515"), + ]) + func `compact currency keeps cents only below one unit`(value: Double, expected: String) { + #expect(UsageFormatter.compactCurrencyString(value, currencyCode: "USD") == expected) + } + @Test func `currency string handles non USD currencies`() { // FormatStyle handles all currencies with proper symbols diff --git a/docs/screenshots/cost-chart-yaxis-labels.png b/docs/screenshots/cost-chart-yaxis-labels.png new file mode 100644 index 0000000000000000000000000000000000000000..14ad4d6cc773db826ad6ad09e537926f88500d08 GIT binary patch literal 247405 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

6ldT*OFpAanRW5@fgz8sVrWPK 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 Date: Sun, 5 Jul 2026 12:13:49 +0300 Subject: [PATCH 054/105] feat: show raw Codex credit totals Show raw Codex credit totals with calendar-correct Today and 30-day totals, stable presentation-state regression coverage, and totals-only summary support. Co-authored-by: kalista78 --- CHANGELOG.md | 1 + .../UsageBreakdownChartMenuView.swift | 87 +++++++++++-- .../CodexBarCore/OpenAIDashboardModels.swift | 108 +++++++++++++++ .../OpenAIDashboardModelsTests.swift | 123 ++++++++++++++++++ .../UsageBreakdownChartMenuViewTests.swift | 30 +++++ 5 files changed, 341 insertions(+), 8 deletions(-) create mode 100644 Tests/CodexBarTests/UsageBreakdownChartMenuViewTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c44fb651..9063f9e7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.39.1 — Unreleased ### Added +- Codex dashboard: show calendar-correct raw Today and 30-day credit totals without converting credits to billed dollars. Thanks @avenoxai! - 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! 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/CodexBarCore/OpenAIDashboardModels.swift b/Sources/CodexBarCore/OpenAIDashboardModels.swift index 1a0652a396..d3c0fb05be 100644 --- a/Sources/CodexBarCore/OpenAIDashboardModels.swift +++ b/Sources/CodexBarCore/OpenAIDashboardModels.swift @@ -193,6 +193,114 @@ public struct OpenAIDashboardDailyBreakdown: Codable, Equatable, Sendable { totalCreditsUsed: total) } } + + public static func recentUsageSummary( + from breakdown: [OpenAIDashboardDailyBreakdown], + historyDays: Int = 30, + now: Date = Date(), + calendar: Calendar = .current) -> OpenAIDashboardUsageBreakdownSummary + { + let days = max(1, min(historyDays, 365)) + var dayCalendar = Calendar(identifier: .gregorian) + dayCalendar.timeZone = calendar.timeZone + let today = dayCalendar.startOfDay(for: now) + let start = dayCalendar.date(byAdding: .day, value: -(days - 1), to: today) ?? today + let startKey = Self.dayKey(from: start, calendar: dayCalendar) + let todayKey = Self.dayKey(from: today, calendar: dayCalendar) + let recent = self.removingSkillUsageServices(from: breakdown) + .compactMap { self.sanitized($0, startKey: startKey, todayKey: todayKey, calendar: dayCalendar) } + .sorted { $0.day < $1.day } + let todayCredits = recent.first(where: { $0.day == todayKey })?.totalCreditsUsed + ?? (recent.isEmpty ? nil : 0) + let totalCredits = self.finiteSum(recent.map(\.totalCreditsUsed)) + return OpenAIDashboardUsageBreakdownSummary( + historyDays: days, + todayCredits: todayCredits, + totalCredits: totalCredits, + daily: recent) + } + + private static func sanitized( + _ day: OpenAIDashboardDailyBreakdown, + startKey: String, + todayKey: String, + calendar: Calendar) -> OpenAIDashboardDailyBreakdown? + { + guard self.date(fromDayKey: day.day, calendar: calendar) != nil, + day.day >= startKey, + day.day <= todayKey + else { return nil } + + if day.services.isEmpty { + guard day.totalCreditsUsed.isFinite, day.totalCreditsUsed > 0 else { return nil } + return day + } + + let services = day.services.filter { $0.creditsUsed.isFinite && $0.creditsUsed > 0 } + guard !services.isEmpty, + let total = self.finiteSum(services.map(\.creditsUsed)), + total > 0 + else { return nil } + return OpenAIDashboardDailyBreakdown(day: day.day, services: services, totalCreditsUsed: total) + } + + private static func finiteSum(_ values: [Double]) -> Double? { + var total = 0.0 + for value in values { + let next = total + value + guard next.isFinite else { return nil } + total = next + } + return values.isEmpty ? nil : total + } + + private static func date(fromDayKey key: String, calendar: Calendar) -> Date? { + let parts = key.split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) + else { return nil } + let components = DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: year, + month: month, + day: day, + hour: 12) + guard let date = calendar.date(from: components) else { return nil } + let resolved = calendar.dateComponents([.year, .month, .day], from: date) + guard resolved.year == year, resolved.month == month, resolved.day == day else { return nil } + return date + } + + private static func dayKey(from date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String( + format: "%04d-%02d-%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0) + } +} + +public struct OpenAIDashboardUsageBreakdownSummary: Equatable, Sendable { + public let historyDays: Int + public let todayCredits: Double? + public let totalCredits: Double? + public let daily: [OpenAIDashboardDailyBreakdown] + + public init( + historyDays: Int, + todayCredits: Double?, + totalCredits: Double?, + daily: [OpenAIDashboardDailyBreakdown]) + { + self.historyDays = historyDays + self.todayCredits = todayCredits + self.totalCredits = totalCredits + self.daily = daily + } } public struct OpenAIDashboardServiceUsage: Codable, Equatable, Sendable { diff --git a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift index b6547e14fe..037d4c8cbe 100644 --- a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift @@ -3,6 +3,16 @@ import Foundation import Testing struct OpenAIDashboardModelsTests { + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static func utcDate(year: Int, month: Int, day: Int) -> Date { + self.utcCalendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12))! + } + @Test func `removes skill usage services from usage breakdown`() { let breakdown = [ @@ -91,4 +101,117 @@ struct OpenAIDashboardModelsTests { totalCreditsUsed: 4), ]) } + + @Test + func `recent credit totals use calendar days and exclude future rows`() { + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [ + .init(day: "2026-05-31", services: [], totalCreditsUsed: 100), + .init(day: "2026-06-01", services: [], totalCreditsUsed: 1), + .init(day: "2026-06-29", services: [], totalCreditsUsed: 2), + .init(day: "2026-06-30", services: [], totalCreditsUsed: 3), + .init(day: "2026-07-01", services: [], totalCreditsUsed: 200), + ], + historyDays: 30, + now: Self.utcDate(year: 2026, month: 6, day: 30), + calendar: Self.utcCalendar) + + #expect(summary.historyDays == 30) + #expect(summary.todayCredits == 3) + #expect(summary.totalCredits == 6) + #expect(summary.daily.map(\.day) == ["2026-06-01", "2026-06-29", "2026-06-30"]) + } + + @Test + func `recent credit totals preserve gaps and sanitize invalid values`() throws { + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [ + .init( + day: "2026-06-20", + services: [ + .init(service: "CLI", creditsUsed: 4), + .init(service: "bad", creditsUsed: .nan), + .init(service: "negative", creditsUsed: -2), + ], + totalCreditsUsed: 999), + .init(day: "2026-06-31", services: [], totalCreditsUsed: 9), + .init(day: "2026-06-30", services: [], totalCreditsUsed: .infinity), + ], + now: Self.utcDate(year: 2026, month: 6, day: 30), + calendar: Self.utcCalendar) + + #expect(summary.todayCredits == 0) + #expect(summary.totalCredits == 4) + let day = try #require(summary.daily.first) + #expect(day.day == "2026-06-20") + #expect(day.totalCreditsUsed == 4) + #expect(day.services.map(\.service) == ["CLI"]) + } + + @Test + func `recent credit totals report zero when history has no row for today`() { + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [ + .init(day: "2026-06-29", services: [], totalCreditsUsed: 4), + ], + now: Self.utcDate(year: 2026, month: 6, day: 30), + calendar: Self.utcCalendar) + + #expect(summary.todayCredits == 0) + #expect(summary.totalCredits == 4) + #expect(summary.daily.map(\.day) == ["2026-06-29"]) + } + + @Test + func `recent credit totals fail closed on overflow`() { + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [ + .init( + day: "2026-06-30", + services: [ + .init(service: "CLI", creditsUsed: Double.greatestFiniteMagnitude), + .init(service: "Desktop App", creditsUsed: Double.greatestFiniteMagnitude), + ], + totalCreditsUsed: 1), + ], + now: Self.utcDate(year: 2026, month: 6, day: 30), + calendar: Self.utcCalendar) + + #expect(summary.daily.isEmpty) + #expect(summary.todayCredits == nil) + #expect(summary.totalCredits == nil) + } + + @Test + func `recent credit totals respect the selected timezone`() throws { + var pacific = Calendar(identifier: .gregorian) + pacific.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-01T06:30:00Z")) + + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [ + .init(day: "2026-06-30", services: [], totalCreditsUsed: 7), + .init(day: "2026-07-01", services: [], totalCreditsUsed: 11), + ], + now: now, + calendar: pacific) + + #expect(summary.todayCredits == 7) + #expect(summary.totalCredits == 7) + #expect(summary.daily.map(\.day) == ["2026-06-30"]) + } + + @Test + func `recent credit totals keep Gregorian dashboard keys with a non Gregorian system calendar`() throws { + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + + let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( + from: [.init(day: "2026-06-30", services: [], totalCreditsUsed: 7)], + now: Self.utcDate(year: 2026, month: 6, day: 30), + calendar: buddhist) + + #expect(summary.todayCredits == 7) + #expect(summary.totalCredits == 7) + } } diff --git a/Tests/CodexBarTests/UsageBreakdownChartMenuViewTests.swift b/Tests/CodexBarTests/UsageBreakdownChartMenuViewTests.swift new file mode 100644 index 0000000000..99325f09fb --- /dev/null +++ b/Tests/CodexBarTests/UsageBreakdownChartMenuViewTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import CodexBar + +@Suite("Usage breakdown chart menu") +@MainActor +struct UsageBreakdownChartMenuViewTests { + @Test + func `valid totals remain visible when service rows are absent`() { + #expect( + UsageBreakdownChartMenuView.presentationState( + hasSummary: true, + hasChartPoints: false) == .totalsOnly) + } + + @Test + func `service rows select the chart presentation`() { + #expect( + UsageBreakdownChartMenuView.presentationState( + hasSummary: true, + hasChartPoints: true) == .chart) + } + + @Test + func `missing totals and service rows select the empty presentation`() { + #expect( + UsageBreakdownChartMenuView.presentationState( + hasSummary: false, + hasChartPoints: false) == .empty) + } +} From 7807679535394caa78b75526caf433057377648f Mon Sep 17 00:00:00 2001 From: Donnie Fiander <44792682+DonnieFi@users.noreply.github.com> Date: Sun, 5 Jul 2026 07:18:07 -0300 Subject: [PATCH 055/105] feat: support Cursor on Linux Add Linux Cursor usage through the signed-in Cursor app token and XDG state paths, with an explicit manual-cookie web source. Preserve the existing macOS browser/WebKit behavior and credential precedence. Co-authored-by: red --- CHANGELOG.md | 1 + Sources/CodexBarCLI/CLIUsageCommand.swift | 8 +++ .../Cursor/CursorProviderDescriptor.swift | 2 +- .../Providers/Cursor/CursorStatusProbe.swift | 68 +++++++++++++++++-- TestsLinux/CursorLinuxTests.swift | 66 ++++++++++++++++++ docs/cursor.md | 12 +++- 6 files changed, 147 insertions(+), 10 deletions(-) create mode 100644 TestsLinux/CursorLinuxTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9063f9e7da..280ee12b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Codex dashboard: show calendar-correct raw Today and 30-day credit totals without converting credits to billed dollars. Thanks @avenoxai! +- 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! diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 7f822a3b1b..e92adb45fc 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -606,6 +606,14 @@ extension CodexBarCLI { { return false } + #if os(Linux) + if provider == .cursor, + settings?.cursor?.cookieSource != .off + { + // Linux uses Cursor app auth and manual cookies; browser import remains macOS-only. + return false + } + #endif if provider == .sakana, sourceMode == .auto || sourceMode == .web, environment.map({ SakanaSettingsReader.cookieHeader(environment: $0) != nil }) == true diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index c6f1ff35fa..983cae2dc9 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -33,7 +33,7 @@ public enum CursorProviderDescriptor { supportsTokenCost: false, noDataMessage: { "Cursor cost summary is not supported." }), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .cli], + sourceModes: [.auto, .cli, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CursorStatusFetchStrategy()] })), cli: ProviderCLIConfig( name: "cursor", diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 6bb82fa037..bff2b00cb8 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -5,12 +5,18 @@ import FoundationNetworking import SweetCookieKit #if canImport(SQLite3) import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 #endif -#if os(macOS) +#if os(macOS) || os(Linux) +#if os(macOS) private let cursorCookieImportOrder: BrowserCookieImportOrder = ProviderDefaults.metadata[.cursor]?.browserCookieOrder ?? Browser.defaultImportOrder +#endif + +#if os(macOS) // MARK: - Cursor Cookie Importer @@ -186,6 +192,7 @@ public enum CursorCookieImporter { } } } +#endif // MARK: - Cursor API Models @@ -408,10 +415,7 @@ protocol CursorAppAuthSessionProviding: Sendable { } struct CursorAppAuthStore: CursorAppAuthSessionProviding { - private static let defaultDBPath: String = { - let home = NSHomeDirectory() - return "\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb" - }() + private static let defaultDBPath: String = Self.resolveDefaultDBPath() private let dbPath: String @@ -419,6 +423,36 @@ struct CursorAppAuthStore: CursorAppAuthSessionProviding { self.dbPath = dbPath ?? Self.defaultDBPath } + static func resolveDefaultDBPath( + home: String = NSHomeDirectory(), + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> String + { + #if os(macOS) + _ = environment + _ = fileManager + return "\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + #elseif os(Linux) + let configHome = environment[CodexBarConfigStore.xdgConfigHomeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let expandedConfigHome = configHome.map { ($0 as NSString).expandingTildeInPath } + let base: String = if let expandedConfigHome, + !expandedConfigHome.isEmpty, + (expandedConfigHome as NSString).isAbsolutePath + { + expandedConfigHome + } else { + "\(home)/.config" + } + return "\(base)/Cursor/User/globalStorage/state.vscdb" + #else + _ = home + _ = environment + _ = fileManager + return "" + #endif + } + func loadSession() throws -> CursorAppAuthSession? { guard FileManager.default.fileExists(atPath: self.dbPath) else { return nil } @@ -705,15 +739,25 @@ public enum CursorStatusProbeError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case .notLoggedIn: + #if os(macOS) "Not logged in to Cursor. Please log in via the CodexBar menu." + #else + "Not logged in to Cursor. Sign in to the Cursor app on this machine or paste a Cookie header copied " + + "from cursor.com into ~/.config/codexbar/config.json (legacy: ~/.codexbar/config.json)." + #endif case let .networkError(msg): "Cursor API error: \(msg)" case let .parseFailed(msg): "Could not parse Cursor usage: \(msg)" case .noSessionCookie: + #if os(macOS) "No Cursor session found. \(Self.safariFullDiskAccessHint) " + "Please log in to cursor.com in \(cursorCookieImportOrder.loginHint). " + "You can also sign in to Cursor from the CodexBar menu (Add / switch account)." + #else + "No Cursor session found. Sign in to the Cursor app on this machine or paste a Cookie header copied " + + "from cursor.com into ~/.config/codexbar/config.json (legacy: ~/.codexbar/config.json)." + #endif } } } @@ -860,7 +904,7 @@ public struct CursorStatusProbe: Sendable { baseURL: baseURL, timeout: timeout, browserDetection: browserDetection, - browserCookieImportOrder: cursorCookieImportOrder, + browserCookieImportOrder: Self.defaultBrowserCookieImportOrder, urlSession: urlSession, appAuthStore: CursorAppAuthStore()) } @@ -869,7 +913,7 @@ public struct CursorStatusProbe: Sendable { baseURL: URL = URL(string: "https://cursor.com")!, timeout: TimeInterval = 15.0, browserDetection: BrowserDetection, - browserCookieImportOrder: BrowserCookieImportOrder = cursorCookieImportOrder, + browserCookieImportOrder: BrowserCookieImportOrder = Self.defaultBrowserCookieImportOrder, urlSession: any ProviderHTTPTransport = ProviderHTTPClient.shared, appAuthStore: any CursorAppAuthSessionProviding) { @@ -927,6 +971,7 @@ public struct CursorStatusProbe: Sendable { } } + #if os(macOS) // Try each browser in order. The first browser that *has* session cookie names is not always valid // (e.g. stale Chrome tokens); keep trying until the API accepts a session or we run out of browsers. let browserCandidates = self.browserCookieImportOrder.cookieImportCandidates(using: self.browserDetection) @@ -965,6 +1010,7 @@ public struct CursorStatusProbe: Sendable { case let .exhausted(error): firstRecoverableError = error ?? firstRecoverableError } + #endif // Fall back to stored session cookies (from "Add Account" login flow) if allowCachedSessions { @@ -1022,6 +1068,7 @@ public struct CursorStatusProbe: Sendable { throw CursorStatusProbeError.noSessionCookie } + #if os(macOS) enum ImportedSessionFetchOutcome { case succeeded(CursorStatusSnapshot) case tryNextBrowser @@ -1104,6 +1151,7 @@ public struct CursorStatusProbe: Sendable { return .failed(.networkError(error.localizedDescription)) } } + #endif private func fetchWithCookieHeader( _ cookieHeader: String, @@ -1363,6 +1411,12 @@ public struct CursorStatusProbe: Sendable { requestsUsed: requestsUsed, requestsLimit: requestsLimit) } + + #if os(macOS) + private static let defaultBrowserCookieImportOrder: BrowserCookieImportOrder = cursorCookieImportOrder + #else + private static let defaultBrowserCookieImportOrder: BrowserCookieImportOrder = [] + #endif } #else diff --git a/TestsLinux/CursorLinuxTests.swift b/TestsLinux/CursorLinuxTests.swift new file mode 100644 index 0000000000..f78acb1ba5 --- /dev/null +++ b/TestsLinux/CursorLinuxTests.swift @@ -0,0 +1,66 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct CursorLinuxTests { + @Test + func `Cursor database path honors absolute XDG config home`() { + let path = CursorAppAuthStore.resolveDefaultDBPath( + home: "/home/test", + environment: ["XDG_CONFIG_HOME": "/custom/config"]) + #expect(path == "/custom/config/Cursor/User/globalStorage/state.vscdb") + } + + @Test + func `Cursor database path falls back to dot config`() { + let path = CursorAppAuthStore.resolveDefaultDBPath( + home: "/home/test", + environment: [:]) + #expect(path == "/home/test/.config/Cursor/User/globalStorage/state.vscdb") + } + + @Test + func `Cursor database path rejects relative XDG config home`() { + let path = CursorAppAuthStore.resolveDefaultDBPath( + home: "/home/test", + environment: ["XDG_CONFIG_HOME": "relative/config"]) + #expect(path == "/home/test/.config/Cursor/User/globalStorage/state.vscdb") + } + + @Test + func `Cursor automatic source does not require macOS web support`() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .auto, manualCookieHeader: nil)))) + } + + @Test + func `Cursor descriptor accepts explicit web source`() { + #expect(CursorProviderDescriptor.descriptor.fetchPlan.sourceModes.contains(.web)) + } + + @Test + func `Cursor manual cookie does not require macOS web support`() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init( + cookieSource: .manual, + manualCookieHeader: "WorkosCursorSessionToken=test")))) + } + + @Test + func `disabled Cursor web source still requires macOS web support`() { + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .off, manualCookieHeader: nil)))) + } +} +#endif diff --git a/docs/cursor.md b/docs/cursor.md index d303fdfe96..24be1e7b59 100644 --- a/docs/cursor.md +++ b/docs/cursor.md @@ -31,8 +31,11 @@ Cursor is primarily web-backed. Usage is fetched via browser cookies or a stored 4) **Cursor.app local auth** (last fallback) - Reads Cursor.app's VS Code-style global state DB for the local app bearer token. - - File: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`. + - File: + - macOS: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` + - Linux: `$XDG_CONFIG_HOME/Cursor/User/globalStorage/state.vscdb` (default `~/.config/Cursor/...`) - Used only after cookie/session sources fail so existing account-selection precedence stays stable. + - On Linux, this is the primary automatic source because browser import and the WebKit login flow are macOS-only. - Derives Cursor's first-party web-session cookie, then uses the same usage and account endpoints as browser sessions. - Account identity comes from that authenticated session; cached app profile fields are not mixed across accounts. @@ -53,8 +56,13 @@ Manual option: - Chrome/Chromium forks: `~/Library/Application Support/Google/Chrome/*/Cookies` - Firefox: `~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite` +## Linux CLI +- `codexbar usage --provider cursor` reads the signed-in Cursor app's access token from the Linux global state DB and reuses the same `cursor.com` usage endpoints as macOS. +- Automatic browser cookie import and the in-app WebKit login flow remain macOS-only. +- Manual cookie headers from `~/.config/codexbar/config.json` (or legacy `~/.codexbar/config.json`) work on Linux. + ## Local storage footprint -When **Settings → Advanced → Track provider local storage** is enabled, CodexBar measures: +When **Settings → Advanced → Track provider local storage** is enabled on macOS, CodexBar measures: - `~/Library/Application Support/Cursor` - `~/Library/Application Support/Caches/cursor-updater` - `~/.cursor` From 9a0028442e3508d9d2a9728bcd88ef68e7217135 Mon Sep 17 00:00:00 2001 From: Sailesh Sivakumar <32437884+ss251@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:24:42 +0530 Subject: [PATCH 056/105] fix: separate Claude history after account switching (#1903) * fix: separate Claude history after in-place account switch (#1886) Background polls keep Claude-Keychain access gated (#1848), so the credential cache can serve a previous account's stale credential after an in-place /login switch on the shared ~/.claude, mis-attributing usage to the previous account's plan-utilization history bucket. Corroborate the served history owner against the active account in ~/.claude.json (prompt-free, never gated) and quarantine the sample on a positive mismatch; recover on the next user-initiated refresh. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: only bind Claude account owner on trustworthy user-initiated reads (#1886) A background poll can serve a previous account's stale credential (the keychain freshness sync is gated off in background), so binding an unmapped owner to the currently-active account on a background first sighting could poison the owner->accountUuid map for upgraded users who already have owner-keyed history. Gate the first-sighting binding on ProviderInteractionContext == .userInitiated, where the freshness sync guarantees the served owner reflects the active account. Background polls still honor an existing binding (quarantine on mismatch) but never create one; unmapped background reads fail open. Addresses the Codex and ClawSweeper P2 on the empty-map upgrade path. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: corroborate Claude history ownership * fix: harden Claude history ownership * fix: scope Claude switch quarantine * fix: distinguish Claude keychain mismatch * fix: close Claude account binding race * fix: require stable Claude account across fetch * fix: preserve Claude token rotation history * fix: confirm Claude history bindings * fix: preserve file-backed Claude history * fix: distinguish file-only Claude history * fix: distinguish absent Claude keychain * fix: preserve absent Claude keychain evidence * fix: bootstrap default Claude history --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Peter Steinberger --- .../UsageStore+CodexResetCredits.swift | 5 +- .../CodexBar/UsageStore+PlanUtilization.swift | 265 ++++++++++++++- Sources/CodexBar/UsageStore+Refresh.swift | 123 +++++-- .../ClaudeOAuthCredentialModels.swift | 25 ++ .../ClaudeOAuth/ClaudeOAuthCredentials.swift | 49 ++- .../Claude/ClaudeProviderDescriptor.swift | 5 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 54 +++- .../Providers/ProviderFetchPlan.swift | 14 +- ...deOAuthHistoryCredentialRoutingTests.swift | 24 ++ ...ilizationClaudeIdentityBoundaryTests.swift | 305 ++++++++++++++++++ ...rePlanUtilizationClaudeIdentityTests.swift | 288 +++++++++++++---- 11 files changed, 1042 insertions(+), 115 deletions(-) create mode 100644 Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift diff --git a/Sources/CodexBar/UsageStore+CodexResetCredits.swift b/Sources/CodexBar/UsageStore+CodexResetCredits.swift index ea2535a2f4..f9d51ab328 100644 --- a/Sources/CodexBar/UsageStore+CodexResetCredits.swift +++ b/Sources/CodexBar/UsageStore+CodexResetCredits.swift @@ -118,7 +118,10 @@ extension ProviderFetchOutcome { strategyID: result.strategyID, strategyKind: result.strategyKind, claudeOAuthKeychainPersistentRefHash: result.claudeOAuthKeychainPersistentRefHash, - claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier)), + claudeOAuthHistoryOwnerIdentifier: result.claudeOAuthHistoryOwnerIdentifier, + claudeOAuthKeychainCredentialMismatch: result.claudeOAuthKeychainCredentialMismatch, + claudeOAuthKeychainCredentialAbsent: result.claudeOAuthKeychainCredentialAbsent, + claudeOAuthKeychainCredentialUnavailable: result.claudeOAuthKeychainCredentialUnavailable)), attempts: self.attempts) } 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+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index ac0e0b8917..875450b534 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( @@ -192,12 +193,10 @@ extension UsageStore { } 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, @@ -212,7 +211,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 { @@ -228,7 +231,8 @@ extension UsageStore { context: ProviderRefreshOutcomeContext( generation: generation, codexExpectedGuard: codexExpectedGuard, - claudeOAuthHistoryPersistentRefHash: claudeOAuthHistoryPersistentRefHash)) + claudeOAuthHistoryPersistentRefHash: claudeOAuthHistoryPersistentRefHash, + claudeOAuthActiveAccountObservation: claudeOAuthActiveAccountObservation)) } private func applyProviderRefreshOutcome( @@ -303,6 +307,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] { @@ -365,6 +378,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( @@ -396,48 +418,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 @@ -459,9 +510,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 diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift index 490a1ae333..1b3401c8f6 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift @@ -186,6 +186,31 @@ public enum ClaudeOAuthCredentialSource: String, Sendable { case claudeKeychain } +enum ClaudeKeychainCredentialMatch: Equatable, Sendable { + case notApplicable + case absent + case unavailable + case mismatch + case matched(persistentRefHash: String) + + var persistentRefHash: String? { + guard case let .matched(persistentRefHash) = self else { return nil } + return persistentRefHash + } + + var isMismatch: Bool { + self == .mismatch + } + + var isAbsent: Bool { + self == .absent + } + + var isUnavailable: Bool { + self == .unavailable + } +} + public struct ClaudeOAuthCredentialRecord: Sendable { public let credentials: ClaudeOAuthCredentials public let owner: ClaudeOAuthCredentialOwner diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 0a8078cf2b..cab2e1837c 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -1351,10 +1351,26 @@ public enum ClaudeOAuthCredentialsStore { public static func matchingClaudeKeychainPersistentRefHashWithoutPrompt( for record: ClaudeOAuthCredentialRecord) -> String? { - guard record.owner == .claudeCLI else { return nil } - return self.matchingClaudeKeychainPersistentRefHash( - for: record, - evidence: self.newestClaudeKeychainCredentialEvidenceWithoutPrompt()) + self.claudeKeychainCredentialMatchWithoutPrompt(for: record).persistentRefHash + } + + static func claudeKeychainCredentialMatchWithoutPrompt( + for record: ClaudeOAuthCredentialRecord) -> ClaudeKeychainCredentialMatch + { + guard record.owner == .claudeCLI else { return .notApplicable } + let evidence: ClaudeKeychainCredentialEvidence + switch self.newestClaudeKeychainCredentialEvidenceWithoutPrompt() { + case .unavailable: + return .unavailable + case .value(nil): + return .absent + case let .value(value?): + evidence = value + } + guard evidence.credentials.accessToken == record.credentials.accessToken else { + return .mismatch + } + return .matched(persistentRefHash: evidence.persistentRefHash) } private static func matchingClaudeKeychainPersistentRefHash( @@ -1370,24 +1386,25 @@ public enum ClaudeOAuthCredentialsStore { } private static func newestClaudeKeychainCredentialEvidenceWithoutPrompt() - -> ClaudeKeychainCredentialEvidence? + -> ClaudeKeychainProbe { #if DEBUG if let store = self.taskClaudeKeychainOverrideStore { + guard store.data != nil || store.fingerprint != nil else { return .value(nil) } return self.makeClaudeKeychainCredentialEvidence( data: store.data, - persistentRefHash: store.fingerprint?.persistentRefHash) + persistentRefHash: store.fingerprint?.persistentRefHash).map { .value($0) } ?? .unavailable } let overrideData = self.taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride let overrideFingerprint = self.taskClaudeKeychainFingerprintOverride ?? self.claudeKeychainFingerprintOverride if overrideData != nil || overrideFingerprint != nil { return self.makeClaudeKeychainCredentialEvidence( data: overrideData, - persistentRefHash: overrideFingerprint?.persistentRefHash) + persistentRefHash: overrideFingerprint?.persistentRefHash).map { .value($0) } ?? .unavailable } if self.taskSecurityCLIReadOverride != nil || self.securityCLIReadOverride != nil { // A security(1) result cannot be bound to a persistent reference without an exact candidate read. - return nil + return .unavailable } #endif @@ -1396,33 +1413,35 @@ public enum ClaudeOAuthCredentialsStore { let newest: ClaudeKeychainCandidate? switch self.claudeKeychainCandidatesProbeWithoutPrompt(promptMode: promptMode) { case .unavailable: - return nil + return .unavailable case let .value(candidates): if let first = candidates.first { newest = first } else { switch self.claudeKeychainLegacyCandidateProbeWithoutPrompt(promptMode: promptMode) { case .unavailable: - return nil + return .unavailable case let .value(candidate): newest = candidate } } } - guard let newest, - let persistentRefHash = self.sha256Prefix(newest.persistentRef), + guard let newest else { return .value(nil) } + guard let persistentRefHash = self.sha256Prefix(newest.persistentRef), let data = try? self.loadClaudeKeychainData( candidate: newest, allowKeychainPrompt: false, promptMode: promptMode) else { - return nil + return .unavailable } - return self.makeClaudeKeychainCredentialEvidence( + guard let evidence = self.makeClaudeKeychainCredentialEvidence( data: data, persistentRefHash: persistentRefHash) + else { return .unavailable } + return .value(evidence) #else - return nil + return .unavailable #endif } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 8d372e4836..4ce6f1f67a 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -381,7 +381,10 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { strategyID: self.id, strategyKind: self.kind, claudeOAuthKeychainPersistentRefHash: usage.oauthKeychainPersistentRefHash, - claudeOAuthHistoryOwnerIdentifier: usage.oauthHistoryOwnerIdentifier) + claudeOAuthHistoryOwnerIdentifier: usage.oauthHistoryOwnerIdentifier, + claudeOAuthKeychainCredentialMismatch: usage.oauthKeychainCredentialMismatch, + claudeOAuthKeychainCredentialAbsent: usage.oauthKeychainCredentialAbsent, + claudeOAuthKeychainCredentialUnavailable: usage.oauthKeychainCredentialUnavailable) } func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 34fe3d8ba1..0d4c9e190b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -27,6 +27,12 @@ public struct ClaudeUsageSnapshot: Sendable { public let oauthKeychainPersistentRefHash: String? /// One-way, high-entropy ownership evidence derived from the exact credential used for this OAuth fetch. public let oauthHistoryOwnerIdentifier: String? + /// True when a prompt-free comparison proved this credential differs from Claude Code's Keychain entry. + public let oauthKeychainCredentialMismatch: Bool + /// True when a prompt-free probe proved Claude Code has no Keychain credential. + public let oauthKeychainCredentialAbsent: Bool + /// True when a Claude CLI credential won routing but the prompt-free Keychain comparison was unavailable. + public let oauthKeychainCredentialUnavailable: Bool public init( primary: RateWindow, @@ -41,7 +47,10 @@ public struct ClaudeUsageSnapshot: Sendable { loginMethod: String?, rawText: String?, oauthKeychainPersistentRefHash: String? = nil, - oauthHistoryOwnerIdentifier: String? = nil) + oauthHistoryOwnerIdentifier: String? = nil, + oauthKeychainCredentialMismatch: Bool = false, + oauthKeychainCredentialAbsent: Bool = false, + oauthKeychainCredentialUnavailable: Bool = false) { self.primary = primary self.primaryWindowKind = primaryWindowKind @@ -56,6 +65,9 @@ public struct ClaudeUsageSnapshot: Sendable { self.rawText = rawText self.oauthKeychainPersistentRefHash = oauthKeychainPersistentRefHash self.oauthHistoryOwnerIdentifier = oauthHistoryOwnerIdentifier + self.oauthKeychainCredentialMismatch = oauthKeychainCredentialMismatch + self.oauthKeychainCredentialAbsent = oauthKeychainCredentialAbsent + self.oauthKeychainCredentialUnavailable = oauthKeychainCredentialUnavailable } } @@ -312,12 +324,16 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let usage = try await ClaudeUsageFetcher.fetchOAuthUsage( accessToken: credentials.accessToken, detectClaudeVersion: self.fetcher.allowsOAuthClaudeVersionDetection) + let keychainMatch = ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: credentialRecord) return try ClaudeUsageFetcher.mapOAuthUsage( usage, credentials: credentials, - oauthKeychainPersistentRefHash: ClaudeOAuthCredentialsStore - .matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: credentialRecord), - oauthHistoryOwnerIdentifier: credentialRecord.historyOwnerIdentifier) + oauthKeychainPersistentRefHash: keychainMatch.persistentRefHash, + oauthHistoryOwnerIdentifier: credentialRecord.historyOwnerIdentifier, + oauthKeychainCredentialMismatch: keychainMatch.isMismatch, + oauthKeychainCredentialAbsent: keychainMatch.isAbsent, + oauthKeychainCredentialUnavailable: keychainMatch.isUnavailable) } catch let error as CancellationError { throw error } catch let error as ClaudeUsageError { @@ -455,12 +471,16 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let usage = try await ClaudeUsageFetcher.fetchOAuthUsage( accessToken: refreshedCredentials.accessToken, detectClaudeVersion: self.fetcher.allowsOAuthClaudeVersionDetection) + let keychainMatch = ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: refreshedRecord) return try ClaudeUsageFetcher.mapOAuthUsage( usage, credentials: refreshedCredentials, - oauthKeychainPersistentRefHash: ClaudeOAuthCredentialsStore - .matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: refreshedRecord), - oauthHistoryOwnerIdentifier: refreshedRecord.historyOwnerIdentifier) + oauthKeychainPersistentRefHash: keychainMatch.persistentRefHash, + oauthHistoryOwnerIdentifier: refreshedRecord.historyOwnerIdentifier, + oauthKeychainCredentialMismatch: keychainMatch.isMismatch, + oauthKeychainCredentialAbsent: keychainMatch.isAbsent, + oauthKeychainCredentialUnavailable: keychainMatch.isUnavailable) } catch { ClaudeUsageFetcher.log.debug( "Claude OAuth post-delegation retry failed", @@ -986,7 +1006,10 @@ extension ClaudeUsageFetcher { _ usage: OAuthUsageResponse, credentials: ClaudeOAuthCredentials, oauthKeychainPersistentRefHash: String? = nil, - oauthHistoryOwnerIdentifier: String? = nil) throws -> ClaudeUsageSnapshot + oauthHistoryOwnerIdentifier: String? = nil, + oauthKeychainCredentialMismatch: Bool = false, + oauthKeychainCredentialAbsent: Bool = false, + oauthKeychainCredentialUnavailable: Bool = false) throws -> ClaudeUsageSnapshot { let oauthHistoryOwnerIdentifier = ClaudeOAuthCredentials.normalizedHistoryOwnerIdentifier( oauthHistoryOwnerIdentifier) ?? credentials.historyOwnerIdentifier @@ -1033,7 +1056,10 @@ extension ClaudeUsageFetcher { loginMethod: loginMethod, rawText: nil, oauthKeychainPersistentRefHash: oauthKeychainPersistentRefHash, - oauthHistoryOwnerIdentifier: oauthHistoryOwnerIdentifier) + oauthHistoryOwnerIdentifier: oauthHistoryOwnerIdentifier, + oauthKeychainCredentialMismatch: oauthKeychainCredentialMismatch, + oauthKeychainCredentialAbsent: oauthKeychainCredentialAbsent, + oauthKeychainCredentialUnavailable: oauthKeychainCredentialUnavailable) } throw ClaudeUsageError.parseFailed("missing session data") } @@ -1056,7 +1082,10 @@ extension ClaudeUsageFetcher { loginMethod: loginMethod, rawText: nil, oauthKeychainPersistentRefHash: oauthKeychainPersistentRefHash, - oauthHistoryOwnerIdentifier: oauthHistoryOwnerIdentifier) + oauthHistoryOwnerIdentifier: oauthHistoryOwnerIdentifier, + oauthKeychainCredentialMismatch: oauthKeychainCredentialMismatch, + oauthKeychainCredentialAbsent: oauthKeychainCredentialAbsent, + oauthKeychainCredentialUnavailable: oauthKeychainCredentialUnavailable) } private static func oauthExtraUsageCost( @@ -1363,7 +1392,10 @@ extension ClaudeUsageFetcher { loginMethod: snapshot.loginMethod, rawText: snapshot.rawText, oauthKeychainPersistentRefHash: snapshot.oauthKeychainPersistentRefHash, - oauthHistoryOwnerIdentifier: snapshot.oauthHistoryOwnerIdentifier) + oauthHistoryOwnerIdentifier: snapshot.oauthHistoryOwnerIdentifier, + oauthKeychainCredentialMismatch: snapshot.oauthKeychainCredentialMismatch, + oauthKeychainCredentialAbsent: snapshot.oauthKeychainCredentialAbsent, + oauthKeychainCredentialUnavailable: snapshot.oauthKeychainCredentialUnavailable) } } catch { Self.log.debug("Claude web extras fetch failed: \(error.localizedDescription)") diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index 5d4e207be1..c654cfcd4d 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -107,6 +107,12 @@ public struct ProviderFetchResult: Sendable { /// A one-way discriminator derived from the winning Claude OAuth credential. /// Raw access and refresh tokens never enter the fetch result or persisted history. public let claudeOAuthHistoryOwnerIdentifier: String? + /// Whether a prompt-free comparison proved the winning credential differs from Claude Code's Keychain entry. + public let claudeOAuthKeychainCredentialMismatch: Bool + /// Whether a prompt-free probe proved Claude Code has no Keychain credential. + public let claudeOAuthKeychainCredentialAbsent: Bool + /// Whether the winning Claude CLI credential could not be compared with Keychain without prompting. + public let claudeOAuthKeychainCredentialUnavailable: Bool public init( usage: UsageSnapshot, @@ -116,7 +122,10 @@ public struct ProviderFetchResult: Sendable { strategyID: String, strategyKind: ProviderFetchKind, claudeOAuthKeychainPersistentRefHash: String? = nil, - claudeOAuthHistoryOwnerIdentifier: String? = nil) + claudeOAuthHistoryOwnerIdentifier: String? = nil, + claudeOAuthKeychainCredentialMismatch: Bool = false, + claudeOAuthKeychainCredentialAbsent: Bool = false, + claudeOAuthKeychainCredentialUnavailable: Bool = false) { self.usage = usage self.credits = credits @@ -126,6 +135,9 @@ public struct ProviderFetchResult: Sendable { self.strategyKind = strategyKind self.claudeOAuthKeychainPersistentRefHash = claudeOAuthKeychainPersistentRefHash self.claudeOAuthHistoryOwnerIdentifier = claudeOAuthHistoryOwnerIdentifier + self.claudeOAuthKeychainCredentialMismatch = claudeOAuthKeychainCredentialMismatch + self.claudeOAuthKeychainCredentialAbsent = claudeOAuthKeychainCredentialAbsent + self.claudeOAuthKeychainCredentialUnavailable = claudeOAuthKeychainCredentialUnavailable } } diff --git a/Tests/CodexBarTests/ClaudeOAuthHistoryCredentialRoutingTests.swift b/Tests/CodexBarTests/ClaudeOAuthHistoryCredentialRoutingTests.swift index d53505c252..15afcf5f66 100644 --- a/Tests/CodexBarTests/ClaudeOAuthHistoryCredentialRoutingTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthHistoryCredentialRoutingTests.swift @@ -46,9 +46,33 @@ struct ClaudeOAuthHistoryCredentialRoutingTests { .matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: matchingEnvironmentRecord) == nil) #expect(ClaudeOAuthCredentialsStore .matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: matchingCodexBarRecord) == nil) + #expect(ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord) == + .matched(persistentRefHash: "opaque-ref")) + #expect(ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: differentCLIRecord) == .mismatch) + #expect(ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: matchingEnvironmentRecord) == .notApplicable) } } } + + ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: fingerprint) + { + let unavailable = ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord) + #expect(unavailable == .unavailable) + #expect(unavailable.isUnavailable) + #expect(!unavailable.isMismatch) + } + + let absentStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore() + ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(absentStore) { + #expect(ClaudeOAuthCredentialsStore + .claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord) == .absent) + } } @Test diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift new file mode 100644 index 0000000000..f321f45b33 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityBoundaryTests.swift @@ -0,0 +1,305 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationClaudeIdentityBoundaryTests { + @MainActor + @Test + func `claude history without identity falls back to last resolved account`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + store._setSnapshotForTesting(snapshot, provider: .claude) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let identitylessSnapshot = UsageSnapshot( + primary: snapshot.primary, + secondary: snapshot.secondary, + updatedAt: snapshot.updatedAt) + store._setSnapshotForTesting(identitylessSnapshot, provider: .claude) + + let history = store.planUtilizationHistory(for: .claude) + #expect(findSeries(history, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) + #expect(findSeries(history, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) + } + + @MainActor + @Test + func `established account accepts same owner after access token rotation`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "a", count: 64) + let accountIdentity = UsageStore._activeClaudeAccountIdentityForTesting("uuid-A") + let start = Date(timeIntervalSince1970: 1_700_000_000) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthActiveAccountObservation: .stable(identity: accountIdentity), + isClaudeOAuthSample: true, + now: start) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthActiveAccountObservation: .stable(identity: accountIdentity), + isClaudeOAuthSample: true, + now: start.addingTimeInterval(30 * 60)) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 50), + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthKeychainCredentialMismatch: true, + claudeOAuthActiveAccountObservation: .stable(identity: accountIdentity), + isClaudeOAuthSample: true, + now: start.addingTimeInterval(2 * 60 * 60)) + + let key = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [30, 50]) + } + + @MainActor + @Test + func `first sighting without keychain match is quarantined`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 90, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + claudeOAuthHistoryOwnerIdentifier: String(repeating: "s", count: 64), + claudeOAuthKeychainCredentialMismatch: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-current")), + isClaudeOAuthSample: true) + + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty) + #expect(store.planUtilizationHistory[.claude] == nil) + } + + @MainActor + @Test + func `file backed owner records history when keychain comparison is unavailable`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "e", count: 64) + let key = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 90), + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthKeychainCredentialUnavailable: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-current")), + isClaudeOAuthSample: true) + + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty) + #expect(UsageStore.loadClaudeOAuthAccountBindingCandidateMap( + from: store.settings.userDefaults).isEmpty) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [90]) + } + + @MainActor + @Test + func `absent keychain still quarantines an owner bound to another account`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "f", count: 64) + store.persistClaudeOAuthAccountUuidMap([ + owner: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A"), + ]) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 90), + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthKeychainCredentialAbsent: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")), + isClaudeOAuthSample: true) + + #expect(store.planUtilizationHistory[.claude] == nil) + } + + @MainActor + @Test + func `absent keychain records an unbound file owner`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "b", count: 64) + let key = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.snapshot(usedPercent: 80), + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthKeychainCredentialAbsent: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-current")), + isClaudeOAuthSample: true) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [80]) + } + + @MainActor + @Test + func `account change during identity capture cannot bind or write history`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 90, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: String(repeating: "r", count: 64), + claudeOAuthActiveAccountObservation: .changed, + isClaudeOAuthSample: true) + + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty) + #expect(store.planUtilizationHistory[.claude] == nil) + } + + @MainActor + @Test + func `missing active account identity preserves owner scoped history`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "c", count: 64) + let key = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 35, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + await UsageStore.withActiveClaudeAccountUuidForTesting(nil) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthActiveAccountObservation: .stable(identity: nil), + isClaudeOAuthSample: true) + } + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [35]) + } + + @MainActor + @Test + func `explicit oauth credential ignores Claude Code account identity`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let owner = String(repeating: "d", count: 64) + let key = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: owner)) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 45, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + await UsageStore.withActiveClaudeAccountUuidForTesting("claude-code-account") { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + claudeOAuthHistoryOwnerIdentifier: owner, + claudeOAuthActiveAccountObservation: .changed, + isClaudeOAuthSample: true) + } + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[key] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [45]) + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults).isEmpty) + } + + @Test + func `claude oauth history scope requires full auth fingerprint stability`() { + let stablePersistentRefHash = UsageStore._stableClaudeKeychainPersistentRefHashForTesting( + beforeFetchFingerprintToken: "stable-fingerprint", + afterFetchFingerprintToken: "stable-fingerprint", + beforeFetchPersistentRefHash: "stable-ref", + afterFetchPersistentRefHash: "stable-ref") + let changedFingerprintPersistentRefHash = UsageStore._stableClaudeKeychainPersistentRefHashForTesting( + beforeFetchFingerprintToken: "before-fingerprint", + afterFetchFingerprintToken: "after-fingerprint", + beforeFetchPersistentRefHash: "stable-ref", + afterFetchPersistentRefHash: "stable-ref") + + #expect(stablePersistentRefHash == "stable-ref") + #expect(changedFingerprintPersistentRefHash == nil) + } + + @Test + func `credential change around account read invalidates the observation`() { + let identityA = UsageStore._activeClaudeAccountIdentityForTesting("uuid-A") + let identityB = UsageStore._activeClaudeAccountIdentityForTesting("uuid-B") + let stable = UsageStore._claudeOAuthActiveAccountObservationForTesting( + identityBeforeFetch: identityB, + identityAfterFetch: identityB) + let changed = UsageStore._claudeOAuthActiveAccountObservationForTesting( + identityBeforeFetch: identityA, + identityAfterFetch: identityB) + let unstable = UsageStore._claudeOAuthActiveAccountObservationForTesting( + identityBeforeFetch: identityB, + identityAfterFetch: identityB, + beforeFetchWasStable: false) + + #expect(stable == .stable(identity: identityB)) + #expect(changed == .changed) + #expect(unstable == .changed) + } + + private func snapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift index 1cc2fef476..b3e4ecf56c 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift @@ -99,37 +99,6 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { #expect(buckets.accounts[aliceKey] == [bootstrap]) } - @MainActor - @Test - func `claude history without identity falls back to last resolved account`() async { - let store = UsageStorePlanUtilizationTests.makeStore() - let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - updatedAt: Date(), - identity: ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: "alice@example.com", - accountOrganization: nil, - loginMethod: "max")) - store._setSnapshotForTesting(snapshot, provider: .claude) - - await store.recordPlanUtilizationHistorySample( - provider: .claude, - snapshot: snapshot, - now: Date(timeIntervalSince1970: 1_700_000_000)) - - let identitylessSnapshot = UsageSnapshot( - primary: snapshot.primary, - secondary: snapshot.secondary, - updatedAt: snapshot.updatedAt) - store._setSnapshotForTesting(identitylessSnapshot, provider: .claude) - - let history = store.planUtilizationHistory(for: .claude) - #expect(findSeries(history, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) - #expect(findSeries(history, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) - } - @MainActor @Test func `claude oauth credential owner separates switched account history`() async throws { @@ -297,6 +266,220 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { .entries.map(\.usedPercent) == [70]) } + @MainActor + @Test + func `same dir account switch quarantines stale background credential`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let ownerA = self.oauthOwnerIdentifier("a") + let ownerB = self.oauthOwnerIdentifier("b") + let keyA = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerA)) + let keyB = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerB)) + let hourStart = Date(timeIntervalSince1970: 1_700_000_000) + + // 1) Active account A (~/.claude.json = uuid-A). Two stable exact-Keychain observations bind owner_A + // to A; the short confirmation interval does not add a second history point. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-A") { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")), + isClaudeOAuthSample: true, + now: hourStart) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(30 * 60)) + } + } + + do { + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [30]) + #expect(buckets.unscoped.isEmpty) + } + + // 2) `/login` switched the active account to B (~/.claude.json = uuid-B), but the gated BACKGROUND + // poll still serves the STALE owner_A credential. Prompt-free Keychain comparison is unavailable, + // but the existing owner_A -> uuid-A binding detects the mismatch, so this must be quarantined: + // no new sample lands. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 90), + claudeOAuthPersistentRefHash: nil, + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthKeychainCredentialUnavailable: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(2 * 60 * 60)) + } + } + + do { + let buckets = try #require(store.planUtilizationHistory[.claude]) + // key_A history is UNCHANGED (the stale sample was dropped, not appended). + #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [30]) + // Critically, the quarantined sample did NOT leak into the shared unscoped bucket. This is the + // assertion that catches the naive-nil bug (nil accountKey writes to `unscoped`, not nowhere). + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[keyB] == nil) + } + + // 3) A USER-INITIATED refresh yields account B's real credential (owner_B), with exact current-Keychain + // match evidence. Recovery: bind owner_B -> uuid-B, write to key_B, and leave key_A untouched. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 75), + claudeOAuthPersistentRefHash: "account-b-ref", + claudeOAuthHistoryOwnerIdentifier: ownerB, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(3 * 60 * 60)) + } + } + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[keyB] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [75]) + #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [30]) + #expect(buckets.unscoped.isEmpty) + } + + @MainActor + @Test + func `exact keychain match arms account map on background poll`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let ownerA = self.oauthOwnerIdentifier("a") + let ownerB = self.oauthOwnerIdentifier("b") + let keyA = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerA)) + let keyB = try #require( + UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting(historyOwnerIdentifier: ownerB)) + let hourStart = Date(timeIntervalSince1970: 1_700_000_000) + + // 1) First run after upgrading: the map is empty. A background poll serves owner_A while + // ~/.claude.json reports uuid-A, and exact current-Keychain evidence corroborates the credential. + // The first observation stages a candidate; a later identical observation confirms the binding. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-A") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-A")), + isClaudeOAuthSample: true, + now: hourStart) + } + } + + let accountAIdentity = UsageStore._activeClaudeAccountIdentityForTesting("uuid-A") + let accountBIdentity = UsageStore._activeClaudeAccountIdentityForTesting("uuid-B") + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults)[ownerA] == nil) + #expect(UsageStore.loadClaudeOAuthAccountBindingCandidateMap( + from: store.settings.userDefaults)[ownerA]?.identity == accountAIdentity) + + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-A") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 30), + claudeOAuthPersistentRefHash: "account-a-ref", + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthActiveAccountObservation: .stable(identity: accountAIdentity), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(30 * 60)) + } + } + #expect(UsageStore.loadClaudeOAuthAccountUuidMap( + from: store.settings.userDefaults)[ownerA] == accountAIdentity) + + // 2) `/login` switched to account B (~/.claude.json = uuid-B), but the gated BACKGROUND poll still + // serves the stale owner_A credential. It no longer matches the current Keychain item, and the + // existing owner_A -> uuid-A binding detects the UUID mismatch, so the sample is quarantined. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 90), + claudeOAuthPersistentRefHash: nil, + claudeOAuthHistoryOwnerIdentifier: ownerA, + claudeOAuthKeychainCredentialMismatch: true, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(2 * 60 * 60)) + } + } + + do { + let mapAfterBackground = UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults) + #expect(mapAfterBackground[ownerA] == accountAIdentity) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[keyA] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [30]) + #expect(buckets.unscoped.isEmpty) + } + + // 3) A background poll now yields account B's real credential (owner_B) with exact current-Keychain + // match evidence. That evidence, not the interaction label, binds owner_B -> uuid-B and writes key_B. + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 75), + claudeOAuthPersistentRefHash: "account-b-ref", + claudeOAuthHistoryOwnerIdentifier: ownerB, + claudeOAuthActiveAccountObservation: .stable( + identity: UsageStore._activeClaudeAccountIdentityForTesting("uuid-B")), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(3 * 60 * 60)) + } + } + + #expect(UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults)[ownerB] == nil) + await UsageStore.withActiveClaudeAccountUuidForTesting("uuid-B") { + await ProviderInteractionContext.$current.withValue(.background) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 75), + claudeOAuthPersistentRefHash: "account-b-ref", + claudeOAuthHistoryOwnerIdentifier: ownerB, + claudeOAuthActiveAccountObservation: .stable(identity: accountBIdentity), + isClaudeOAuthSample: true, + now: hourStart.addingTimeInterval(3 * 60 * 60 + 30 * 60)) + } + } + + let mapAfterRecovery = UsageStore.loadClaudeOAuthAccountUuidMap(from: store.settings.userDefaults) + #expect(mapAfterRecovery[ownerB] == accountBIdentity) + #expect(mapAfterRecovery[ownerA] == accountAIdentity) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(findSeries(buckets.accounts[keyB] ?? [], name: .session, windowMinutes: 300)? + .entries.map(\.usedPercent) == [75]) + #expect(buckets.unscoped.isEmpty) + } + @MainActor @Test func `coalesced claude oauth sample without owner cannot switch preferred account`() async throws { @@ -470,18 +653,20 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { let accountBKey = try #require(UsageStore._claudeOAuthPlanUtilizationAccountKeyForTesting( historyOwnerIdentifier: accountBOwner)) - await store.recordPlanUtilizationHistorySample( - provider: .claude, - snapshot: self.identitylessClaudeSnapshot(usedPercent: 10), - claudeOAuthHistoryOwnerIdentifier: accountAOwner, - isClaudeOAuthSample: true, - now: Date(timeIntervalSince1970: 1_700_000_000)) - await store.recordPlanUtilizationHistorySample( - provider: .claude, - snapshot: self.identitylessClaudeSnapshot(usedPercent: 75), - claudeOAuthHistoryOwnerIdentifier: accountBOwner, - isClaudeOAuthSample: true, - now: Date(timeIntervalSince1970: 1_700_007_200)) + await UsageStore.withActiveClaudeAccountUuidForTesting(nil) { + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 10), + claudeOAuthHistoryOwnerIdentifier: accountAOwner, + isClaudeOAuthSample: true, + now: Date(timeIntervalSince1970: 1_700_000_000)) + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: self.identitylessClaudeSnapshot(usedPercent: 75), + claudeOAuthHistoryOwnerIdentifier: accountBOwner, + isClaudeOAuthSample: true, + now: Date(timeIntervalSince1970: 1_700_007_200)) + } let buckets = try #require(store.planUtilizationHistory[.claude]) #expect(buckets.unscoped.isEmpty) @@ -560,23 +745,6 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { #expect(first.dropFirst("__claude_oauth__:".count).count == 64) } - @Test - func `claude oauth history scope requires full auth fingerprint stability`() { - let stablePersistentRefHash = UsageStore._stableClaudeKeychainPersistentRefHashForTesting( - beforeFetchFingerprintToken: "stable-fingerprint", - afterFetchFingerprintToken: "stable-fingerprint", - beforeFetchPersistentRefHash: "stable-ref", - afterFetchPersistentRefHash: "stable-ref") - let changedFingerprintPersistentRefHash = UsageStore._stableClaudeKeychainPersistentRefHashForTesting( - beforeFetchFingerprintToken: "before-fingerprint", - afterFetchFingerprintToken: "after-fingerprint", - beforeFetchPersistentRefHash: "stable-ref", - afterFetchPersistentRefHash: "stable-ref") - - #expect(stablePersistentRefHash == "stable-ref") - #expect(changedFingerprintPersistentRefHash == nil) - } - @Test func `same claude email separates team and personal plan history keys`() throws { let team = UsageSnapshot( From cda65e7000d14222730c51095a4d07cbe28ddcab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 05:58:11 -0700 Subject: [PATCH 057/105] docs: note Claude history isolation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 280ee12b94..4da42022bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Settings: keep the sidebar fixed and visible while resizing, prevent collapse or over-expansion, and cap detail content width for readability. Thanks @Zihao-Qi! ### Fixed +- Claude history: quarantine same-directory account-switch samples until credential ownership is stable, preventing plan-utilization history from crossing accounts. Thanks @ss251! - 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! From 88e8a537f2666528ae25da6fa3c845698f8931f6 Mon Sep 17 00:00:00 2001 From: optimiz-r <139051194+optimiz-r@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:15:21 +0200 Subject: [PATCH 058/105] feat: add read-only claude-swap accounts (#1909) * Add read-only claude-swap account adapter with provider-neutral snapshots Phase 1 of docs/claude-multi-account-and-status-items.md: execute exactly 'cswap --list --json' (no shell, bounded runtime and output), strictly parse schema v1 with its error envelope and sentinel statuses, and project rows into ProviderAccountUsageSnapshot keyed by claude-swap:. CodexBar reads no claude-swap or Claude Code credential storage. Co-Authored-By: Claude Fable 5 * Show claude-swap accounts as stacked Claude menu cards Opt-in via Preferences > Providers > Claude: a claude-swap toggle plus executable path field. When enabled, each Claude refresh also runs the adapter and renders one card per account (active first) using the Kilo multi-scope pattern; adapter failures keep last-good rows as stale data and surface in settings without touching the ambient Claude card. Hide Personal Info redacts account emails as usual. Co-Authored-By: Claude Fable 5 * fix: isolate claude-swap refresh * fix: isolate claude-swap account state * fix: preserve claude-swap cancellation * fix: import CoreFoundation for Linux * Keep provider-level errors off account override cards Extend the override-card isolation from 6430cd4e to the lastError fallback: the ambient Claude error (e.g. OAuth credentials unavailable) was rendering inside every claude-swap account card. Apply the same surface scoping as the snapshot, token-cost, and source-label fallbacks, with coverage in MenuCardOverrideIsolationTests. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Peter Steinberger --- .../Claude/ClaudeProviderImplementation.swift | 53 ++++ .../Claude/ClaudeProviderRuntime.swift | 42 +++ .../Claude/ClaudeSettingsStore.swift | 23 ++ .../Claude/UsageStore+ClaudeSwapRefresh.swift | 88 ++++++ .../CodexBar/StatusItemController+Menu.swift | 15 ++ .../StatusItemController+MenuCardModel.swift | 9 +- ...ItemController+MenuRefreshScheduling.swift | 1 + .../StatusItemController+MenuTracking.swift | 9 + Sources/CodexBar/UsageStore+Refresh.swift | 7 + Sources/CodexBar/UsageStore.swift | 10 + .../CodexBarCore/Config/CodexBarConfig.swift | 10 + .../Host/Process/SubprocessRunner.swift | 3 +- .../ProviderAccountSnapshot.swift | 50 ++++ .../ClaudeSwap/ClaudeSwapAccountList.swift | 216 +++++++++++++++ .../ClaudeSwapAccountProjection.swift | 86 ++++++ .../ClaudeSwap/ClaudeSwapAccountReader.swift | 94 +++++++ .../ClaudeProviderRuntimeTests.swift | 107 ++++++++ .../ClaudeSwapAccountProjectionTests.swift | 132 +++++++++ .../ClaudeSwapAccountReaderTests.swift | 135 ++++++++++ .../ClaudeSwapListParserTests.swift | 254 ++++++++++++++++++ .../MenuCardClaudeSwapAccountTests.swift | 65 +++++ .../MenuCardOverrideIsolationTests.swift | 90 +++++++ .../StatusMenuHostedSubmenuRefreshTests.swift | 24 ++ docs/claude.md | 20 ++ 24 files changed, 1539 insertions(+), 4 deletions(-) create mode 100644 Sources/CodexBar/Providers/Claude/ClaudeProviderRuntime.swift create mode 100644 Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift create mode 100644 Sources/CodexBarCore/ProviderAccountSnapshot.swift create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountList.swift create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountReader.swift create mode 100644 Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift create mode 100644 Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift create mode 100644 Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift create mode 100644 Tests/CodexBarTests/ClaudeSwapListParserTests.swift create mode 100644 Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift create mode 100644 Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index ea0e443f95..1b2b299fd2 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 @@ -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 for every claude-swap account by running `cswap --list --json`. " + + "Read-only: CodexBar never reads claude-swap or Claude Code credentials.", + 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( @@ -196,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..54b0a74a15 --- /dev/null +++ b/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift @@ -0,0 +1,88 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + /// True when the opt-in claude-swap adapter should run alongside the + /// ambient Claude refresh. The adapter is display-only: it never reads + /// credentials and its failures never affect the ambient Claude snapshot. + 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.claudeSwapRefreshTask?.cancel() + self.claudeSwapRefreshTask = nil + self.claudeSwapAccountSnapshots = [] + self.claudeSwapLastRefreshAt = nil + self.claudeSwapLastError = 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 + } + } + } + + private func probeClaudeSwapVersionIfNeeded(executablePath: String) async { + guard self.claudeSwapVersionProbedPath != executablePath else { return } + let version = await ClaudeSwapAccountReader.readVersion(executablePath: executablePath) + guard self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) else { return } + self.claudeSwapVersionProbedPath = 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/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 22f621439f..7ed98ff77b 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -633,6 +633,21 @@ extension StatusItemController { return false } + if context.currentProvider == .claude, self.store.claudeSwapAccountSnapshots.count > 1 { + let cards = self.store.claudeSwapAccountSnapshots.compactMap { account in + self.menuCardModel( + for: .claude, + snapshotOverride: account.snapshot, + errorOverride: ClaudeSwapAccountProjection.displayError( + accountError: account.error, + adapterError: self.store.claudeSwapLastError), + forceOverrideCard: account.snapshot == nil, + accountOverride: AccountInfo(email: account.displayLabel, plan: nil)) + } + self.addStackedMenuCards(cards, to: menu, context: context) + return false + } + guard let model = self.menuCardModel(for: context.selectedProvider) else { return false } let renderedModel = self.menuCardRefreshMonitor.model(for: model.provider, fallback: model) if context.openAIContext.hasOpenAIWebMenuItems || diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index df7a0734a9..ae3335e4c3 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -55,7 +55,7 @@ extension StatusItemController { tokenError = nil } } else if ProviderDescriptorRegistry.descriptor(for: target).tokenCost.supportsTokenCost, - snapshotOverride == nil + surface == .liveCard { credits = nil creditsError = nil @@ -72,7 +72,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 @@ -102,9 +102,12 @@ extension StatusItemController { tokenError: tokenError, account: fallbackAccount, 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, diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index a8b6549f7b..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() { diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 751d19ef00..f303cef09b 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -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/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 875450b534..644f40f52a 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -159,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( @@ -360,6 +364,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() diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 8de0b1bcb8..cdf2abfb01 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 @@ -144,6 +147,13 @@ final class UsageStore { 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 claudeSwapVersionProbedPath: String? var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index 999be2cc01..8e5d53f3a8 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -106,6 +106,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var workspaceID: String? public var enterpriseHost: String? public var tokenAccounts: ProviderTokenAccountData? + public var claudeSwapEnabled: Bool? + public var claudeSwapExecutablePath: String? public var codexActiveSource: CodexActiveSource? public var codexProfileHomePaths: [String]? public var quotaWarnings: QuotaWarningConfig? @@ -127,6 +129,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { workspaceID: String? = nil, enterpriseHost: String? = nil, tokenAccounts: ProviderTokenAccountData? = nil, + claudeSwapEnabled: Bool? = nil, + claudeSwapExecutablePath: String? = nil, codexActiveSource: CodexActiveSource? = nil, codexProfileHomePaths: [String]? = nil, quotaWarnings: QuotaWarningConfig? = nil, @@ -147,6 +151,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.workspaceID = workspaceID self.enterpriseHost = enterpriseHost self.tokenAccounts = tokenAccounts + self.claudeSwapEnabled = claudeSwapEnabled + self.claudeSwapExecutablePath = claudeSwapExecutablePath self.codexActiveSource = codexActiveSource self.codexProfileHomePaths = codexProfileHomePaths self.quotaWarnings = quotaWarnings @@ -180,6 +186,10 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { Self.clean(self.enterpriseHost) } + public var sanitizedClaudeSwapExecutablePath: String? { + Self.clean(self.claudeSwapExecutablePath) + } + public var sanitizedAWSProfile: String? { Self.clean(self.awsProfile) } diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index 193b6f6278..77a2f872cf 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -155,6 +155,7 @@ public enum SubprocessRunner { timeout: TimeInterval, standardInput: Any? = nil, currentDirectoryURL: URL? = nil, + acceptsNonZeroExit: Bool = false, label: String) async throws -> SubprocessResult { guard FileManager.default.isExecutableFile(atPath: binary) else { @@ -249,7 +250,7 @@ public enum SubprocessRunner { let stdout = await ProcessPipeCapture.decodeUTF8(stdoutData) let stderr = await ProcessPipeCapture.decodeUTF8(stderrData) - if exitCode != 0 { + if exitCode != 0, !acceptsNonZeroExit { let duration = Date().timeIntervalSince(start) self.log.warning( "Subprocess failed", diff --git a/Sources/CodexBarCore/ProviderAccountSnapshot.swift b/Sources/CodexBarCore/ProviderAccountSnapshot.swift new file mode 100644 index 0000000000..0a5e44ebce --- /dev/null +++ b/Sources/CodexBarCore/ProviderAccountSnapshot.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Stable identity for one account row surfaced by a multi-account source adapter. +/// +/// `source` names the adapter (for example `claude-swap`) and `opaqueID` is the +/// source-issued identifier (for example a numeric slot). Identity never derives +/// from emails or credential material, per +/// `docs/claude-multi-account-and-status-items.md`. +public struct ProviderAccountIdentity: Hashable, Sendable { + public let source: String + public let opaqueID: String + + public init(source: String, opaqueID: String) { + self.source = source + self.opaqueID = opaqueID + } +} + +/// Provider-neutral projection of one account's usage, consumed by menus (and, +/// later, per-account status items) without teaching UI code about any specific +/// credential source. +public struct ProviderAccountUsageSnapshot: Identifiable, Sendable { + public let id: ProviderAccountIdentity + public let provider: UsageProvider + /// Display-only label (may contain personal data such as an email); UI is + /// responsible for privacy redaction. Never logged or persisted. + public let displayLabel: String + public let isActive: Bool + public let snapshot: UsageSnapshot? + public let error: String? + public let sourceLabel: String? + + public init( + id: ProviderAccountIdentity, + provider: UsageProvider, + displayLabel: String, + isActive: Bool, + snapshot: UsageSnapshot?, + error: String?, + sourceLabel: String?) + { + self.id = id + self.provider = provider + self.displayLabel = displayLabel + self.isActive = isActive + self.snapshot = snapshot + self.error = error + self.sourceLabel = sourceLabel + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountList.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountList.swift new file mode 100644 index 0000000000..097b118a6e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountList.swift @@ -0,0 +1,216 @@ +import CoreFoundation +import Foundation + +/// Strictly parsed result of `cswap --list --json` (schema v1). +/// +/// Only the fields allow-listed in `docs/claude-multi-account-and-status-items.md` +/// are decoded: slot number, display email, active state, usage status, and the +/// 5-hour/7-day windows (percent + reset timestamp). Everything else in the +/// payload is ignored; unknown schema versions and partial top-level shapes are +/// rejected. +public struct ClaudeSwapAccountList: Equatable, Sendable { + public let activeAccountNumber: Int? + public let accounts: [ClaudeSwapAccountRow] + + public init(activeAccountNumber: Int?, accounts: [ClaudeSwapAccountRow]) { + self.activeAccountNumber = activeAccountNumber + self.accounts = accounts + } +} + +public struct ClaudeSwapAccountRow: Equatable, Sendable { + public let number: Int + /// Display-only sensitive value; never logged or persisted. + public let email: String + public let isActive: Bool + public let usageStatus: ClaudeSwapUsageStatus + public let fiveHour: ClaudeSwapUsageWindow? + public let sevenDay: ClaudeSwapUsageWindow? + + public init( + number: Int, + email: String, + isActive: Bool, + usageStatus: ClaudeSwapUsageStatus, + fiveHour: ClaudeSwapUsageWindow?, + sevenDay: ClaudeSwapUsageWindow?) + { + self.number = number + self.email = email + self.isActive = isActive + self.usageStatus = usageStatus + self.fiveHour = fiveHour + self.sevenDay = sevenDay + } +} + +public struct ClaudeSwapUsageWindow: Equatable, Sendable { + public let usedPercent: Double + public let resetsAt: Date? + + public init(usedPercent: Double, resetsAt: Date?) { + self.usedPercent = usedPercent + self.resetsAt = resetsAt + } +} + +/// The sentinel statuses `cswap` emits per account. Unknown values from newer +/// claude-swap releases are preserved rather than failing the whole payload. +public enum ClaudeSwapUsageStatus: Equatable, Sendable { + case ok + case tokenExpired + case apiKey + case keychainUnavailable + case noCredentials + case unavailable + case unknown(String) + + init(rawValue: String) { + switch rawValue { + case "ok": self = .ok + case "token_expired": self = .tokenExpired + case "api_key": self = .apiKey + case "keychain_unavailable": self = .keychainUnavailable + case "no_credentials": self = .noCredentials + case "unavailable": self = .unavailable + default: self = .unknown(rawValue) + } + } +} + +public enum ClaudeSwapListParserError: LocalizedError, Equatable, Sendable { + case notJSONObject + case missingSchemaVersion + case unsupportedSchemaVersion(Int) + case reportedError(type: String, message: String) + case malformedShape(String) + + public var errorDescription: String? { + switch self { + case .notJSONObject: + "claude-swap returned output that is not a JSON object." + case .missingSchemaVersion: + "claude-swap output has no schemaVersion field." + case let .unsupportedSchemaVersion(version): + "claude-swap output uses unsupported schema version \(version); CodexBar supports version 1." + case let .reportedError(type, message): + "claude-swap reported \(type): \(message)" + case let .malformedShape(details): + "claude-swap output is malformed: \(details)" + } + } +} + +public enum ClaudeSwapListParser { + public static let supportedSchemaVersion = 1 + + public static func parse(_ data: Data) throws -> ClaudeSwapAccountList { + let raw: Any + do { + raw = try JSONSerialization.jsonObject(with: data) + } catch { + throw ClaudeSwapListParserError.notJSONObject + } + guard let object = raw as? [String: Any] else { + throw ClaudeSwapListParserError.notJSONObject + } + guard let schemaVersion = object["schemaVersion"] as? Int else { + throw ClaudeSwapListParserError.missingSchemaVersion + } + guard schemaVersion == self.supportedSchemaVersion else { + throw ClaudeSwapListParserError.unsupportedSchemaVersion(schemaVersion) + } + if let errorObject = object["error"] as? [String: Any] { + throw ClaudeSwapListParserError.reportedError( + type: errorObject["type"] as? String ?? "Error", + message: errorObject["message"] as? String ?? "unknown error") + } + guard let rawAccounts = object["accounts"] as? [Any] else { + throw ClaudeSwapListParserError.malformedShape("missing accounts array") + } + guard let rawActiveAccountNumber = object["activeAccountNumber"] else { + throw ClaudeSwapListParserError.malformedShape("missing activeAccountNumber") + } + let activeAccountNumber: Int? = switch rawActiveAccountNumber { + case is NSNull: nil + case let number as Int where number > 0: number + default: + throw ClaudeSwapListParserError.malformedShape("activeAccountNumber is not a numeric slot or null") + } + var seenSlots: Set = [] + let accounts = try rawAccounts.map { rawRow -> ClaudeSwapAccountRow in + guard let row = rawRow as? [String: Any] else { + throw ClaudeSwapListParserError.malformedShape("account row is not an object") + } + let account = try self.parseRow(row) + guard seenSlots.insert(account.number).inserted else { + throw ClaudeSwapListParserError.malformedShape("duplicate account slot \(account.number)") + } + return account + } + let activeSlots = accounts.filter(\.isActive).map(\.number) + guard activeSlots == (activeAccountNumber.map { [$0] } ?? []) else { + throw ClaudeSwapListParserError.malformedShape("active account fields disagree") + } + return ClaudeSwapAccountList(activeAccountNumber: activeAccountNumber, accounts: accounts) + } + + private static func parseRow(_ row: [String: Any]) throws -> ClaudeSwapAccountRow { + guard let number = row["number"] as? Int else { + throw ClaudeSwapListParserError.malformedShape("account row has no numeric slot") + } + guard number > 0 else { + throw ClaudeSwapListParserError.malformedShape("account slot must be positive") + } + guard let isActive = row["active"] as? Bool else { + throw ClaudeSwapListParserError.malformedShape("slot \(number) has no active flag") + } + guard let rawStatus = row["usageStatus"] as? String else { + throw ClaudeSwapListParserError.malformedShape("slot \(number) has no usageStatus") + } + let email = (row["email"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let usage = row["usage"] as? [String: Any] + return try ClaudeSwapAccountRow( + number: number, + email: email, + isActive: isActive, + usageStatus: ClaudeSwapUsageStatus(rawValue: rawStatus), + fiveHour: self.parseWindow(usage?["fiveHour"], slot: number, name: "fiveHour"), + sevenDay: self.parseWindow(usage?["sevenDay"], slot: number, name: "sevenDay")) + } + + private static func parseWindow(_ raw: Any?, slot: Int, name: String) throws -> ClaudeSwapUsageWindow? { + guard let raw else { return nil } + guard let window = raw as? [String: Any] else { + throw ClaudeSwapListParserError.malformedShape("slot \(slot) \(name) window is not an object") + } + guard let pct = Self.finiteDouble(window["pct"]) else { + throw ClaudeSwapListParserError.malformedShape("slot \(slot) \(name) percent is not a finite number") + } + var resetsAt: Date? + if let rawResetsAt = window["resetsAt"] { + guard let text = rawResetsAt as? String, let date = Self.parseTimestamp(text) else { + throw ClaudeSwapListParserError.malformedShape("slot \(slot) \(name) resetsAt is not a timestamp") + } + resetsAt = date + } + return ClaudeSwapUsageWindow(usedPercent: min(max(pct, 0), 100), resetsAt: resetsAt) + } + + private static func finiteDouble(_ raw: Any?) -> Double? { + guard let number = raw as? NSNumber else { return nil } + // JSON booleans bridge to NSNumber too; only accept genuine numbers. + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } + let value = number.doubleValue + return value.isFinite ? value : nil + } + + private static func parseTimestamp(_ text: String) -> Date? { + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFraction.date(from: text) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: text) + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift new file mode 100644 index 0000000000..1b060dade4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Projects parsed `cswap --list --json` rows into the provider-neutral +/// account snapshot consumed by menus. Identity uses the source-issued numeric +/// slot (`claude-swap:`), never email or credential-derived values. +public enum ClaudeSwapAccountProjection { + public static let sourceName = "claude-swap" + public static let sourceLabel = "claude-swap" + static let fiveHourWindowMinutes = 5 * 60 + static let sevenDayWindowMinutes = 7 * 24 * 60 + + public static func accountSnapshots( + from list: ClaudeSwapAccountList, + now: Date = Date()) -> [ProviderAccountUsageSnapshot] + { + let ordered = list.accounts.sorted { lhs, rhs in + if lhs.isActive != rhs.isActive { return lhs.isActive } + return lhs.number < rhs.number + } + return ordered.map { row in + ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity(source: self.sourceName, opaqueID: String(row.number)), + provider: .claude, + displayLabel: self.displayLabel(for: row), + isActive: row.isActive, + snapshot: self.usageSnapshot(for: row, now: now), + error: self.errorText(for: row), + sourceLabel: self.sourceLabel) + } + } + + public static func displayError(accountError: String?, adapterError: String?) -> String? { + accountError ?? adapterError.map { "Showing the last successful update: \($0)" } + } + + static func displayLabel(for row: ClaudeSwapAccountRow) -> String { + row.email.isEmpty ? "Account \(row.number)" : row.email + } + + private static func usageSnapshot(for row: ClaudeSwapAccountRow, now: Date) -> UsageSnapshot? { + guard row.usageStatus == .ok else { return nil } + let primary = row.fiveHour.map { window in + RateWindow( + usedPercent: window.usedPercent, + windowMinutes: self.fiveHourWindowMinutes, + resetsAt: window.resetsAt, + resetDescription: nil) + } + let secondary = row.sevenDay.map { window in + RateWindow( + usedPercent: window.usedPercent, + windowMinutes: self.sevenDayWindowMinutes, + resetsAt: window.resetsAt, + resetDescription: nil) + } + guard primary != nil || secondary != nil else { return nil } + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: self.displayLabel(for: row), + accountOrganization: nil, + loginMethod: self.sourceLabel)) + } + + private static func errorText(for row: ClaudeSwapAccountRow) -> String? { + switch row.usageStatus { + case .ok: + row.fiveHour == nil && row.sevenDay == nil ? "No usage windows reported." : nil + case .tokenExpired: + "Token expired. Switch to this account in claude-swap to refresh it." + case .apiKey: + "API-key account; subscription usage is unavailable." + case .keychainUnavailable: + "claude-swap could not read the active account's Keychain entry." + case .noCredentials: + "No stored credentials for this account slot." + case .unavailable: + "Usage fetch failed." + case let .unknown(raw): + "Unrecognized claude-swap status: \(raw)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountReader.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountReader.swift new file mode 100644 index 0000000000..128b90bb07 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountReader.swift @@ -0,0 +1,94 @@ +import Foundation + +public enum ClaudeSwapAccountReaderError: LocalizedError, Sendable { + case executablePathNotConfigured + case outputTooLarge(byteCount: Int) + + public var errorDescription: String? { + switch self { + case .executablePathNotConfigured: + "No claude-swap executable path is configured." + case let .outputTooLarge(byteCount): + "claude-swap produced \(byteCount) bytes of output; refusing to parse more than " + + "\(ClaudeSwapAccountReader.maxOutputBytes)." + } + } +} + +/// Read-only adapter over the external `claude-swap` executable. +/// +/// Executes exactly `cswap --list --json` (never a shell, never config-defined +/// passthrough arguments) with a bounded runtime and bounded output, per the +/// Phase 1 contract in `docs/claude-multi-account-and-status-items.md`. CodexBar +/// never reads claude-swap or Claude Code credential storage; the subprocess is +/// solely responsible for its own credential access. +public enum ClaudeSwapAccountReader { + public static let maxOutputBytes = 262_144 + public static let defaultTimeout: TimeInterval = 30 + + public static func readAccountList( + executablePath: String, + timeout: TimeInterval = ClaudeSwapAccountReader.defaultTimeout) async throws -> ClaudeSwapAccountList + { + // Handled claude-swap failures print a schema-v1 error envelope to stdout + // and exit non-zero, so non-zero exits still parse; the parser surfaces + // the envelope as `ClaudeSwapListParserError.reportedError`. + let result = try await self.run( + executablePath: executablePath, + arguments: ["--list", "--json"], + timeout: timeout, + acceptsNonZeroExit: true, + label: "claude-swap list") + return try ClaudeSwapListParser.parse(Data(result.utf8)) + } + + /// Best-effort version probe (`cswap --version` prints `cswap `). + public static func readVersion( + executablePath: String, + timeout: TimeInterval = 10) async -> String? + { + guard let output = try? await self.run( + executablePath: executablePath, + arguments: ["--version"], + timeout: timeout, + label: "claude-swap version") + else { + return nil + } + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard let version = trimmed.split(whereSeparator: \.isWhitespace).last, !version.isEmpty else { + return nil + } + return String(version) + } + + public static func resolvedExecutablePath(_ configuredPath: String) throws -> String { + let trimmed = configuredPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw ClaudeSwapAccountReaderError.executablePathNotConfigured + } + return (trimmed as NSString).expandingTildeInPath + } + + private static func run( + executablePath: String, + arguments: [String], + timeout: TimeInterval, + acceptsNonZeroExit: Bool = false, + label: String) async throws -> String + { + try Task.checkCancellation() + let binary = try self.resolvedExecutablePath(executablePath) + let result = try await SubprocessRunner.run( + binary: binary, + arguments: arguments, + environment: ProcessInfo.processInfo.environment, + timeout: timeout, + acceptsNonZeroExit: acceptsNonZeroExit, + label: label) + guard result.stdout.utf8.count <= self.maxOutputBytes else { + throw ClaudeSwapAccountReaderError.outputTooLarge(byteCount: result.stdout.utf8.count) + } + return result.stdout + } +} diff --git a/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift new file mode 100644 index 0000000000..acb45b0dc7 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift @@ -0,0 +1,107 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct ClaudeProviderRuntimeTests { + @Test + func `disabling adapter immediately clears retained accounts`() { + let (settings, store) = self.makeStore() + store.claudeSwapAccountSnapshots = [self.accountSnapshot()] + store.claudeSwapLastRefreshAt = Date() + store.claudeSwapLastError = "stale" + let runtime = ClaudeProviderRuntime() + + runtime.settingsDidChange(context: ProviderRuntimeContext(provider: .claude, settings: settings, store: store)) + + #expect(store.claudeSwapAccountSnapshots.isEmpty) + #expect(store.claudeSwapLastRefreshAt == nil) + #expect(store.claudeSwapLastError == nil) + } + + @Test + func `disabled Claude provider does not restart adapter`() { + let (settings, store) = self.makeStore() + settings.claudeSwapExecutablePath = "/path/to/cswap" + settings.claudeSwapEnabled = true + let runtime = ClaudeProviderRuntime() + let context = ProviderRuntimeContext(provider: .claude, settings: settings, store: store) + + runtime.stop(context: context) + runtime.settingsDidChange(context: context) + + #expect(!store.isEnabled(.claude)) + #expect(store.claudeSwapRefreshTask == nil) + } + + @Test + func `late adapter result is rejected after executable path changes`() async throws { + let (settings, store) = self.makeStore() + let executable = try self.makeFakeExecutable() + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = executable + settings.claudeSwapEnabled = true + + let refresh = Task { @MainActor in + await store.refreshClaudeSwapAccounts() + } + try await Task.sleep(for: .milliseconds(100)) + settings.claudeSwapExecutablePath = "/new/path/to/cswap" + await refresh.value + + #expect(store.claudeSwapAccountSnapshots.isEmpty) + #expect(store.claudeSwapLastRefreshAt == nil) + } + + private func makeStore() -> (SettingsStore, UsageStore) { + let suite = "ClaudeProviderRuntimeTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + return (settings, store) + } + + private func accountSnapshot() -> ProviderAccountUsageSnapshot { + ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "1"), + provider: .claude, + displayLabel: "account@example.com", + isActive: false, + snapshot: nil, + error: "Token expired", + sourceLabel: ClaudeSwapAccountProjection.sourceLabel) + } + + private func makeFakeExecutable() throws -> String { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-runtime-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("cswap") + let script = """ + #!/bin/sh + if [ "$1" = "--version" ]; then + echo 'cswap 0.16.0' + exit 0 + fi + sleep 0.3 + cat <<'EOF' + {"schemaVersion":1,"activeAccountNumber":1,"accounts":[ + {"number":1,"email":"a@b.c","active":true,"usageStatus":"ok","usage":{"fiveHour":{"pct":12.5}}} + ]} + EOF + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } +} diff --git a/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift new file mode 100644 index 0000000000..38ff33675f --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift @@ -0,0 +1,132 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeSwapAccountProjectionTests { + @Test + func `adapter failures mark retained account snapshots as stale`() { + #expect(ClaudeSwapAccountProjection.displayError( + accountError: nil, + adapterError: "timed out") == "Showing the last successful update: timed out") + #expect(ClaudeSwapAccountProjection.displayError( + accountError: "Token expired.", + adapterError: "timed out") == "Token expired.") + } + + private let now = Date(timeIntervalSince1970: 1_782_000_000) + + @Test + func `projects rows into provider neutral snapshots with active account first`() throws { + let reset = Date(timeIntervalSince1970: 1_782_170_999) + let list = ClaudeSwapAccountList( + activeAccountNumber: 2, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "work@example.com", + isActive: false, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 25, resetsAt: reset), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 16.5, resetsAt: nil)), + ClaudeSwapAccountRow( + number: 2, + email: "personal@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 80, resetsAt: nil), + sevenDay: nil), + ]) + + let snapshots = ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now) + #expect(snapshots.count == 2) + + let active = try #require(snapshots.first) + #expect(active.id == ProviderAccountIdentity(source: "claude-swap", opaqueID: "2")) + #expect(active.provider == .claude) + #expect(active.displayLabel == "personal@example.com") + #expect(active.isActive == true) + #expect(active.error == nil) + #expect(active.sourceLabel == "claude-swap") + #expect(active.snapshot?.primary?.usedPercent == 80) + #expect(active.snapshot?.primary?.windowMinutes == 300) + #expect(active.snapshot?.secondary == nil) + #expect(active.snapshot?.updatedAt == self.now) + #expect(active.snapshot?.identity?.accountEmail == "personal@example.com") + #expect(active.snapshot?.identity?.loginMethod == "claude-swap") + + let inactive = try #require(snapshots.last) + #expect(inactive.id.opaqueID == "1") + #expect(inactive.isActive == false) + #expect(inactive.snapshot?.primary?.resetsAt == reset) + #expect(inactive.snapshot?.secondary?.usedPercent == 16.5) + #expect(inactive.snapshot?.secondary?.windowMinutes == 10080) + } + + @Test + func `maps sentinel statuses to per account errors without usage`() throws { + let rows: [(ClaudeSwapUsageStatus, String)] = [ + (.tokenExpired, "Token expired"), + (.apiKey, "API-key account"), + (.keychainUnavailable, "Keychain"), + (.noCredentials, "No stored credentials"), + (.unavailable, "Usage fetch failed"), + (.unknown("mystery"), "mystery"), + ] + + for (index, entry) in rows.enumerated() { + let list = ClaudeSwapAccountList( + activeAccountNumber: nil, + accounts: [ + ClaudeSwapAccountRow( + number: index + 1, + email: "a@b.c", + isActive: false, + usageStatus: entry.0, + fiveHour: nil, + sevenDay: nil), + ]) + let snapshot = try #require( + ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(snapshot.snapshot == nil) + let error = try #require(snapshot.error) + #expect(error.contains(entry.1)) + } + } + + @Test + func `ok row without windows reports missing usage instead of an empty card`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + ClaudeSwapAccountRow( + number: 1, + email: "a@b.c", + isActive: true, + usageStatus: .ok, + fiveHour: nil, + sevenDay: nil), + ]) + + let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(snapshot.snapshot == nil) + #expect(snapshot.error == "No usage windows reported.") + } + + @Test + func `falls back to ordinal label when email is empty`() throws { + let list = ClaudeSwapAccountList( + activeAccountNumber: nil, + accounts: [ + ClaudeSwapAccountRow( + number: 3, + email: "", + isActive: false, + usageStatus: .noCredentials, + fiveHour: nil, + sevenDay: nil), + ]) + + let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first) + #expect(snapshot.displayLabel == "Account 3") + } +} diff --git a/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift b/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift new file mode 100644 index 0000000000..33d7bdd254 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift @@ -0,0 +1,135 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Reader tests use fake executables only, per the Phase 1 contract: no real +/// claude-swap install, no credentials, no Keychain access. +struct ClaudeSwapAccountReaderTests { + private func makeFakeExecutable(_ script: String) throws -> String { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-swap-reader-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("cswap") + try "#!/bin/sh\n\(script)\n".write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + + @Test + func `reads and parses a schema v1 list from the executable`() async throws { + let path = try self.makeFakeExecutable(""" + [ "$1" = "--list" ] || exit 2 + [ "$2" = "--json" ] || exit 2 + cat <<'EOF' + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": {"fiveHour": {"pct": 12.5}}} + ]} + EOF + """) + + let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: path) + #expect(list.activeAccountNumber == 1) + #expect(list.accounts.first?.fiveHour?.usedPercent == 12.5) + } + + @Test + func `surfaces the error envelope from a non zero exit`() async throws { + let path = try self.makeFakeExecutable(""" + echo '{"schemaVersion": 1, "error": {"type": "SwitchError", "message": "store locked"}}' + exit 1 + """) + + await #expect(throws: ClaudeSwapListParserError.reportedError(type: "SwitchError", message: "store locked")) { + try await ClaudeSwapAccountReader.readAccountList(executablePath: path) + } + } + + @Test + func `terminates executables that exceed the timeout`() async throws { + let path = try self.makeFakeExecutable("sleep 30") + + await #expect(throws: (any Error).self) { + try await ClaudeSwapAccountReader.readAccountList(executablePath: path, timeout: 0.5) + } + } + + @Test + func `rejects oversized output before parsing`() async throws { + let path = try self.makeFakeExecutable(""" + i=0 + while [ $i -lt 5000 ]; do + printf '%s' '{"filler": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}' + i=$((i+1)) + done + """) + + await #expect(throws: (any Error).self) { + try await ClaudeSwapAccountReader.readAccountList(executablePath: path) + } + } + + @Test + func `fails cleanly when the executable is missing`() async throws { + await #expect(throws: (any Error).self) { + try await ClaudeSwapAccountReader.readAccountList( + executablePath: "/nonexistent/path/to/cswap") + } + await #expect(throws: ClaudeSwapAccountReaderError.self) { + try await ClaudeSwapAccountReader.readAccountList(executablePath: " ") + } + } + + @Test + func `reads the executable version`() async throws { + let path = try self.makeFakeExecutable(""" + [ "$1" = "--version" ] || exit 2 + echo 'cswap 0.16.0' + """) + + let version = await ClaudeSwapAccountReader.readVersion(executablePath: path) + #expect(version == "0.16.0") + } + + @Test + func `version probe returns nil when the executable fails`() async throws { + let path = try self.makeFakeExecutable("exit 3") + + let version = await ClaudeSwapAccountReader.readVersion(executablePath: path) + #expect(version == nil) + } + + @Test + func `cancellation during version probe prevents account list launch`() async throws { + let marker = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-swap-list-launched-\(UUID().uuidString)") + let path = try self.makeFakeExecutable(""" + if [ "$1" = "--version" ]; then + sleep 30 + exit 0 + fi + touch '\(marker.path)' + echo '{"schemaVersion":1,"activeAccountNumber":null,"accounts":[]}' + """) + let task = Task { + _ = await ClaudeSwapAccountReader.readVersion(executablePath: path) + return try await ClaudeSwapAccountReader.readAccountList(executablePath: path) + } + + try await Task.sleep(for: .milliseconds(100)) + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test + func `expands tilde in configured paths`() throws { + let resolved = try ClaudeSwapAccountReader.resolvedExecutablePath("~/bin/cswap") + #expect(resolved.hasPrefix("/")) + #expect(!resolved.contains("~")) + #expect(resolved.hasSuffix("/bin/cswap")) + } +} diff --git a/Tests/CodexBarTests/ClaudeSwapListParserTests.swift b/Tests/CodexBarTests/ClaudeSwapListParserTests.swift new file mode 100644 index 0000000000..7b5853aabb --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSwapListParserTests.swift @@ -0,0 +1,254 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeSwapListParserTests { + private func parse(_ json: String) throws -> ClaudeSwapAccountList { + try ClaudeSwapListParser.parse(Data(json.utf8)) + } + + @Test + func `parses schema v1 list payload`() throws { + let json = """ + { + "schemaVersion": 1, + "activeAccountNumber": 2, + "accounts": [ + { + "number": 1, + "email": "work@example.com", + "organizationName": "", + "organizationUuid": "", + "isOrganization": false, + "active": false, + "usageStatus": "ok", + "usage": { + "fiveHour": {"pct": 25.0, "resetsAt": "2026-06-22T23:29:59Z", "countdown": "1h"}, + "sevenDay": {"pct": 16.5, "resetsAt": "2026-06-26T17:59:59Z"} + }, + "usageFetchedAt": "2026-06-22T20:00:00Z", + "usageAgeSeconds": 42.0 + }, + { + "number": 2, + "email": "personal@example.com", + "active": true, + "usageStatus": "ok", + "usage": {"fiveHour": {"pct": 80}} + } + ] + } + """ + + let list = try self.parse(json) + #expect(list.activeAccountNumber == 2) + #expect(list.accounts.count == 2) + + let first = try #require(list.accounts.first) + #expect(first.number == 1) + #expect(first.email == "work@example.com") + #expect(first.isActive == false) + #expect(first.usageStatus == .ok) + #expect(first.fiveHour?.usedPercent == 25.0) + #expect(first.fiveHour?.resetsAt == Date(timeIntervalSince1970: 1_782_170_999)) + #expect(first.sevenDay?.usedPercent == 16.5) + + let second = try #require(list.accounts.last) + #expect(second.isActive == true) + #expect(second.fiveHour?.usedPercent == 80) + #expect(second.fiveHour?.resetsAt == nil) + #expect(second.sevenDay == nil) + } + + @Test + func `parses empty account list without accounts configured`() throws { + let json = """ + {"schemaVersion": 1, "activeAccountNumber": null, "accounts": []} + """ + + let list = try self.parse(json) + #expect(list.activeAccountNumber == nil) + #expect(list.accounts.isEmpty) + } + + @Test + func `maps usage status sentinels including unknown values`() throws { + let json = """ + { + "schemaVersion": 1, + "activeAccountNumber": 1, + "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "token_expired", "usage": null}, + {"number": 2, "email": "d@e.f", "active": false, "usageStatus": "api_key", "usage": null}, + {"number": 3, "email": "g@h.i", "active": false, "usageStatus": "keychain_unavailable", "usage": null}, + {"number": 4, "email": "j@k.l", "active": false, "usageStatus": "no_credentials", "usage": null}, + {"number": 5, "email": "m@n.o", "active": false, "usageStatus": "unavailable", "usage": null}, + {"number": 6, "email": "p@q.r", "active": false, "usageStatus": "brand_new_status", "usage": null} + ] + } + """ + + let statuses = try self.parse(json).accounts.map(\.usageStatus) + #expect(statuses == [ + .tokenExpired, + .apiKey, + .keychainUnavailable, + .noCredentials, + .unavailable, + .unknown("brand_new_status"), + ]) + } + + @Test + func `surfaces schema v1 error envelope`() throws { + let json = """ + {"schemaVersion": 1, "error": {"type": "SwitchError", "message": "boom"}} + """ + + #expect(throws: ClaudeSwapListParserError.reportedError(type: "SwitchError", message: "boom")) { + try self.parse(json) + } + } + + @Test + func `rejects unknown schema versions`() throws { + let json = """ + {"schemaVersion": 2, "activeAccountNumber": 1, "accounts": []} + """ + + #expect(throws: ClaudeSwapListParserError.unsupportedSchemaVersion(2)) { + try self.parse(json) + } + } + + @Test + func `rejects payloads without schema version or accounts`() throws { + #expect(throws: ClaudeSwapListParserError.missingSchemaVersion) { + try self.parse(#"{"accounts": []}"#) + } + #expect(throws: ClaudeSwapListParserError.malformedShape("missing accounts array")) { + try self.parse(#"{"schemaVersion": 1}"#) + } + #expect(throws: ClaudeSwapListParserError.malformedShape("missing activeAccountNumber")) { + try self.parse(#"{"schemaVersion": 1, "accounts": []}"#) + } + #expect(throws: ClaudeSwapListParserError.notJSONObject) { + try self.parse("not json at all") + } + #expect(throws: ClaudeSwapListParserError.notJSONObject) { + try self.parse(#"["schemaVersion", 1]"#) + } + } + + @Test + func `rejects invalid or duplicate account slots`() throws { + #expect(throws: ClaudeSwapListParserError.malformedShape("account slot must be positive")) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": null, "accounts": [ + {"number": 0, "active": false, "usageStatus": "ok"} + ]} + """) + } + #expect(throws: ClaudeSwapListParserError.malformedShape("duplicate account slot 1")) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "active": true, "usageStatus": "ok"}, + {"number": 1, "active": false, "usageStatus": "ok"} + ]} + """) + } + #expect(throws: ClaudeSwapListParserError.malformedShape( + "activeAccountNumber is not a numeric slot or null")) + { + try self.parse(#"{"schemaVersion": 1, "activeAccountNumber": "1", "accounts": []}"#) + } + #expect(throws: ClaudeSwapListParserError.malformedShape("active account fields disagree")) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 2, "accounts": [ + {"number": 1, "active": true, "usageStatus": "ok"}, + {"number": 2, "active": false, "usageStatus": "ok"} + ]} + """) + } + } + + @Test + func `rejects rows with missing required fields`() throws { + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"email": "a@b.c", "active": true, "usageStatus": "ok"} + ]} + """) + } + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "usageStatus": "ok"} + ]} + """) + } + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true} + ]} + """) + } + } + + @Test + func `rejects invalid percentages and timestamps`() throws { + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": {"fiveHour": {"pct": "not-a-number"}}} + ]} + """) + } + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": {"fiveHour": {"pct": true}}} + ]} + """) + } + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": {"fiveHour": {"pct": 10, "resetsAt": "yesterday-ish"}}} + ]} + """) + } + } + + @Test + func `clamps out of range percentages`() throws { + let json = """ + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": {"fiveHour": {"pct": 130.5}, "sevenDay": {"pct": -4}}} + ]} + """ + + let row = try #require(self.parse(json).accounts.first) + #expect(row.fiveHour?.usedPercent == 100) + #expect(row.sevenDay?.usedPercent == 0) + } + + @Test + func `parses fractional second timestamps`() throws { + let json = """ + {"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [ + {"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok", + "usage": {"fiveHour": {"pct": 10, "resetsAt": "2026-06-22T23:29:59.500Z"}}} + ]} + """ + + let row = try #require(self.parse(json).accounts.first) + #expect(row.fiveHour?.resetsAt == Date(timeIntervalSince1970: 1_782_170_999.5)) + } +} diff --git a/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift new file mode 100644 index 0000000000..e86eb237d1 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift @@ -0,0 +1,65 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +/// Menu-model coverage for claude-swap account cards: the provider-neutral +/// projection renders as a regular Claude usage card with session/weekly +/// windows, account identity, and Hide Personal Info redaction. +struct MenuCardClaudeSwapAccountTests { + private func makeModel(hidePersonalInfo: Bool) throws -> UsageMenuCardView.Model { + let now = Date(timeIntervalSince1970: 1_782_000_000) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let list = ClaudeSwapAccountList( + activeAccountNumber: 2, + accounts: [ + ClaudeSwapAccountRow( + number: 2, + email: "personal@example.com", + isActive: true, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: 25, resetsAt: now.addingTimeInterval(3600)), + sevenDay: ClaudeSwapUsageWindow(usedPercent: 60, resetsAt: now.addingTimeInterval(86400))), + ]) + let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: now).first) + + return UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: account.snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: account.displayLabel, plan: nil), + isRefreshing: false, + lastError: account.error, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: hidePersonalInfo, + now: now)) + } + + @Test + func `claude swap account snapshot renders session and weekly metrics with identity`() throws { + let model = try self.makeModel(hidePersonalInfo: false) + + #expect(model.email == "personal@example.com") + let primary = try #require(model.metrics.first(where: { $0.id == "primary" })) + #expect(primary.percent == 25) + let secondary = try #require(model.metrics.first(where: { $0.id == "secondary" })) + #expect(secondary.percent == 60) + } + + @Test + func `claude swap account card respects hide personal info`() throws { + let model = try self.makeModel(hidePersonalInfo: true) + + #expect(!model.email.contains("personal@example.com")) + #expect(!model.email.contains("example.com")) + } +} diff --git a/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift new file mode 100644 index 0000000000..27a51d899b --- /dev/null +++ b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift @@ -0,0 +1,90 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuCardOverrideIsolationTests { + @Test + func `nil snapshot account card does not inherit ambient Claude costs`() throws { + let suite = "MenuCardOverrideIsolationTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.costUsageEnabled = true + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 456, + last30DaysCostUSD: 1.23, + daily: [], + updatedAt: Date()), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + let model = try #require(controller.menuCardModel( + for: .claude, + errorOverride: "Token expired", + forceOverrideCard: true, + accountOverride: AccountInfo(email: "account@example.com", plan: nil))) + + #expect(model.tokenUsage == nil) + } + + @Test + func `account card without its own error does not inherit the ambient Claude error`() throws { + let suite = "MenuCardOverrideIsolationTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setErrorForTesting("Claude OAuth credentials unavailable", provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + let accountSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "account@example.com", + accountOrganization: nil, + loginMethod: "claude-swap")) + + let model = try #require(controller.menuCardModel( + for: .claude, + snapshotOverride: accountSnapshot, + accountOverride: AccountInfo(email: "account@example.com", plan: nil))) + + #expect(model.subtitleStyle != .error) + #expect(!model.subtitleText.contains("Claude OAuth credentials unavailable")) + + let liveModel = try #require(controller.menuCardModel(for: .claude)) + #expect(liveModel.subtitleStyle == .error) + #expect(liveModel.subtitleText == "Claude OAuth credentials unavailable") + } +} diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 32485b0d9b..cab2d718ba 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -6,6 +6,30 @@ import Testing @MainActor @Suite(.serialized) struct StatusMenuHostedSubmenuRefreshTests { + @Test + func `claude swap completion changes open menu readiness`() { + let settings = Self.makeSettings() + settings.setProviderEnabled( + provider: .claude, + metadata: ProviderDescriptorRegistry.descriptor(for: .claude).metadata, + enabled: true) + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let before = controller.menuAdjunctReadinessSignature() + store.claudeSwapRevision &+= 1 + + #expect(controller.menuAdjunctReadinessSignature() != before) + } + @Test func `status components change open menu readiness`() { let settings = Self.makeSettings() diff --git a/docs/claude.md b/docs/claude.md index 58d8f477d9..e104eae274 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -112,6 +112,26 @@ Admin API key setup: - Extra usage spend/limit (if enabled). - Account email + inferred plan. +## claude-swap accounts (opt-in, read-only) + +Phase 1 of the accepted multi-account design in +[claude-multi-account-and-status-items.md](claude-multi-account-and-status-items.md). + +- Setup: Preferences → Providers → Claude → "Read accounts from claude-swap", then set the path to the + [`cswap`](https://github.com/realiti4/claude-swap) executable (for example `~/.local/bin/cswap`). +- Behavior: on each Claude refresh, CodexBar runs `cswap --list --json` independently of the ambient Claude fetch (no + shell, fixed arguments, bounded runtime and output), requires `schemaVersion == 1`, and parses only slot number, + active state, usage status, email (display only), and the 5-hour/7-day windows. +- Display: when claude-swap reports more than one account, the Claude menu shows one stacked card per + account (active account first) alongside nothing else changing; with zero or one account the menu is + unchanged. Account identity is `claude-swap:`. +- Isolation: CodexBar never reads claude-swap or Claude Code credential storage for this feature; the + subprocess handles its own credential access. Adapter failures keep the last successful accounts as + stale data, surface the error in provider settings, and never affect the ambient Claude usage card. +- Sentinel statuses (`token_expired`, `api_key`, `keychain_unavailable`, `no_credentials`, + `unavailable`) render as per-account notes instead of usage bars. +- Account switching is intentionally out of scope; use `cswap` directly to switch accounts. + ## CLI PTY (fallback) - Runs `claude` in a PTY session (`ClaudeCLISession`). - Default behavior: exit after each probe; Debug → "Keep CLI sessions alive" keeps it running between probes. From 9ad35deb6a890cb2e44cdd4c395eff70c78999c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 07:15:58 -0700 Subject: [PATCH 059/105] docs: note claude-swap accounts --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da42022bc..fa54888274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.39.1 — Unreleased ### Added +- Claude: show opt-in read-only claude-swap accounts as stacked usage cards without delaying ambient refreshes. Thanks @optimiz-r! - Codex dashboard: show calendar-correct raw Today and 30-day credit totals without converting credits to billed dollars. Thanks @avenoxai! - 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! From e3c2adeb53a1930f7bd19f76d871699097cf280b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Larry=20Hao=EF=BC=88=E9=83=9D=E5=8D=93=E8=BF=9C=EF=BC=89?= <107194248+hhh2210@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:59:12 +0800 Subject: [PATCH 060/105] fix: scope provider refresh state (#1911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: scope refresh-row disabling to the provider being refreshed Refreshing one provider greyed out — and single-flighted — the Refresh row for every provider, so the menu implied the others were refreshing when they were not. Replace the single global `manualRefreshTask` with a per-scope map (`.global` for the all-providers ⌘R/overview refresh, `.provider(p)` for a single provider). The disable gate now reflects only the target provider's own refresh (a global refresh still disables every row), and `startManualRefresh` guards per scope so different providers can refresh concurrently instead of one blocking the rest. `MenuCardRefreshMonitor` tracks in-flight providers as a set plus a global flag. Rewrites the refresh-row tests that encoded the old blanket behavior and adds coverage for scoped greying and concurrent per-provider refreshes. Co-Authored-By: Claude Fable 5 * fix: address review — preserve frozen cards and exclude global/provider overlap - MenuCardRefreshMonitor: keep an already-refreshing provider's frozen card when a second provider's refresh re-supplies models (old-wins merge), so the first card no longer jumps mid-refresh — the artifact the freeze exists to prevent. - startManualRefresh: make a .global refresh mutually exclusive with any per-provider one (and vice versa); two different providers still refresh concurrently. - Merged-overview Refresh row now reflects any in-flight manual refresh, including a per-provider refresh's post-fetch status/token tail, instead of flickering. - Make the test-only manualRefreshTask / manualRefreshProvider accessors return a value only when unambiguous (global, or exactly one task), else nil; move them to +Actions. - Add tests: concurrent frozen-card retention, and overview-busy-through-tail + global refresh exclusion. Co-Authored-By: Claude Fable 5 * fix: apply frozen-card old-wins merge, global/provider exclusivity guard, scoped overview greying * fix: isolate provider refresh snapshots * test: use scoped refresh tasks --------- Co-authored-by: Claude Fable 5 Co-authored-by: Peter Steinberger --- Sources/CodexBar/MenuCardRefreshMonitor.swift | 41 +++- .../StatusItemController+Actions.swift | 26 ++- .../StatusItemController+MenuTracking.swift | 2 +- ...ItemController+PersistentMenuActions.swift | 21 +- .../StatusItemController+Shutdown.swift | 9 +- Sources/CodexBar/StatusItemController.swift | 6 +- .../StatusMenuClosedPreparationTests.swift | 2 +- ...StatusMenuMergedOverviewRefreshTests.swift | 3 +- .../StatusMenuPersistentRefreshTests.swift | 220 ++++++++++++++---- .../StatusMenuSwitcherRefreshTests.swift | 61 ++++- 10 files changed, 306 insertions(+), 85 deletions(-) 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/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 736520aacf..4067c2a4ad 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) { ( @@ -114,10 +123,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 +142,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 +167,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() } diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index f303cef09b..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) } } 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..e4b5f94a09 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -28,10 +28,11 @@ extension StatusItemController { 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.swift b/Sources/CodexBar/StatusItemController.swift index 4d6c033d53..dae1718326 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -138,8 +138,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] = [:] diff --git a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift index a3b528adef..62628b01ef 100644 --- a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift +++ b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift @@ -185,7 +185,7 @@ extension StatusMenuTests { controller._test_manualRefreshOperation = nil } controller.refreshNow() - let task = try #require(controller.manualRefreshTask) + let task = try #require(controller.manualRefreshTasks[.global]) controller.invalidateMenus() for _ in 0..<40 { diff --git a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift index 2bb35b2509..2812dbb9d1 100644 --- a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift @@ -42,8 +42,7 @@ struct StatusMenuMergedOverviewRefreshTests { } #expect(requestCount == 0) - #expect(controller.manualRefreshTask == nil) - #expect(controller.manualRefreshProvider == nil) + #expect(controller.manualRefreshTasks.isEmpty) } private func makeSettings() -> SettingsStore { diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index 859f4242d2..b4fcb34d67 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -172,17 +172,17 @@ struct StatusMenuPersistentRefreshTests { let gate = ManualRefreshGate() controller._test_manualRefreshOperation = { await gate.wait() } #expect(try controller.handleMenuTrackingShortcutEvent(self.keyEvent("r", keyCode: 15), menu: menu)) - for _ in 0..<20 where controller.manualRefreshTask == nil { + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { await Task.yield() } - let task = try #require(controller.manualRefreshTask) + let task = try #require(controller.manualRefreshTasks[.provider(.codex)]) #expect(!refreshItem.isEnabled) gate.resume() await task.value - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks[.provider(.codex)] == nil) #expect(refreshItem.isEnabled) } @@ -260,22 +260,37 @@ struct StatusMenuPersistentRefreshTests { #expect(!refreshItem.isEnabled) controller.store.isRefreshing = false - controller.manualRefreshProvider = .claude - controller.manualRefreshTask = Task {} + + // A manual refresh scoped to another provider must not grey out this provider's row. + controller.manualRefreshTasks[.provider(.claude)] = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + + // This provider's own manual refresh does disable its row. + controller.manualRefreshTasks[.provider(.codex)] = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + controller.manualRefreshTasks[.provider(.codex)] = nil + controller.manualRefreshTasks[.provider(.claude)] = nil + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + + // An all-providers refresh greys every row. + controller.manualRefreshTasks[.global] = Task {} controller.updatePersistentRefreshItemsEnabled() #expect(!refreshItem.isEnabled) - controller.manualRefreshTask = nil - controller.manualRefreshProvider = nil + controller.manualRefreshTasks[.global] = nil controller.updatePersistentRefreshItemsEnabled() #expect(refreshItem.isEnabled) refreshItem.representedObject = "notRefresh" - controller.manualRefreshTask = Task {} + controller.manualRefreshTasks[.global] = Task {} controller.updatePersistentRefreshItemsEnabled() #expect(refreshItem.isEnabled) #expect(!controller.persistentRefreshItems.allObjects.contains { $0 === refreshItem }) - controller.manualRefreshTask = nil + controller.manualRefreshTasks[.global] = nil } @Test @@ -291,9 +306,9 @@ struct StatusMenuPersistentRefreshTests { #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) controller.store.isRefreshing = false - monitor.isManualRefreshInFlight = true + monitor.beginManualRefresh(frozenModels: [:], provider: nil) #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) - monitor.isManualRefreshInFlight = false + monitor.endManualRefresh() controller.store.isRefreshing = false let now = Date() @@ -314,7 +329,7 @@ struct StatusMenuPersistentRefreshTests { #expect(failure.style == .error) #expect(failure.text == "Refresh failed") - monitor.isManualRefreshInFlight = true + monitor.beginManualRefresh(frozenModels: [:], provider: nil) #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) } @@ -328,7 +343,7 @@ struct StatusMenuPersistentRefreshTests { let expectedClaude = monitor.subtitle(for: .claude, fallback: fallback) monitor.beginManualRefresh(frozenModels: [.codex: codexModel], provider: .codex) - defer { monitor.endManualRefresh() } + defer { monitor.endManualRefresh(for: .codex) } #expect(monitor.isManualRefreshInFlight(for: .codex)) #expect(!monitor.isManualRefreshInFlight(for: .claude)) @@ -356,7 +371,7 @@ struct StatusMenuPersistentRefreshTests { resetDescription: nil), updatedAt: now) let fallback = try #require(controller.menuCardModel(for: .claude)) - controller.menuCardRefreshMonitor.isManualRefreshInFlight = true + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [:], provider: .claude) controller.store.snapshots[.claude] = UsageSnapshot( primary: RateWindow( @@ -374,7 +389,7 @@ struct StatusMenuPersistentRefreshTests { let inFlight = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) #expect(inFlight.metrics.map(\.percent) == fallback.metrics.map(\.percent)) - controller.menuCardRefreshMonitor.isManualRefreshInFlight = false + controller.menuCardRefreshMonitor.endManualRefresh(for: .claude) let refreshed = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) let expected = try #require(controller.menuCardModel(for: .claude)) @@ -730,7 +745,7 @@ struct StatusMenuPersistentRefreshTests { controller.store.errors[.codex] = "Refresh failed with a much longer replacement message that must not resize the tracked menu" let replacementErrorHeight = fittingHeight(for: errorModel) - controller.menuCardRefreshMonitor.isManualRefreshInFlight = true + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [:], provider: nil) let retryHeight = fittingHeight(for: errorModel) #expect(replacementErrorHeight == errorHeight) @@ -752,7 +767,7 @@ struct StatusMenuPersistentRefreshTests { controller.refreshNow() #expect(requestCount == 0) - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks.isEmpty) #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) } @@ -772,7 +787,7 @@ struct StatusMenuPersistentRefreshTests { } controller.refreshNow() - let task = try #require(controller.manualRefreshTask) + let task = try #require(controller.manualRefreshTasks[.global]) controller.refreshNow() controller.refreshNow() await Task.yield() @@ -783,7 +798,7 @@ struct StatusMenuPersistentRefreshTests { gate.resume() await task.value - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks[.global] == nil) #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) } @@ -805,37 +820,29 @@ struct StatusMenuPersistentRefreshTests { } let mouseGate = ManualRefreshGate() - var requestCount = 0 - controller._test_manualRefreshOperation = { - requestCount += 1 - await mouseGate.wait() - } + controller._test_manualRefreshOperation = { await mouseGate.wait() } let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) #expect(controller.isPersistentRefreshItem(refreshItem)) controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) - for _ in 0..<20 where controller.manualRefreshTask == nil { + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { await Task.yield() } - let mouseTask = try #require(controller.manualRefreshTask) - #expect(controller.manualRefreshProvider == .claude) - #expect(controller.isRefreshActionInFlight(for: codexMenu)) - #expect(controller.isRefreshActionInFlight(for: NSMenu())) - let codexRefreshItem = try #require(codexMenu.items.first { $0.title == "Refresh" }) - #expect(controller.isPersistentRefreshItem(codexRefreshItem)) - controller.performPersistentRefreshAction(in: ObjectIdentifier(codexMenu)) - await Task.yield() - #expect(requestCount == 1) + let mouseTask = try #require(controller.manualRefreshTasks[.provider(.claude)]) + + // Refreshing Claude greys the Claude row but must leave the Codex row available. + #expect(controller.isRefreshActionInFlight(for: menu)) + #expect(!controller.isRefreshActionInFlight(for: codexMenu)) + mouseGate.resume() await mouseTask.value let keyboardGate = ManualRefreshGate() controller._test_manualRefreshOperation = { await keyboardGate.wait() } #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) - for _ in 0..<20 where controller.manualRefreshTask == nil { + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { await Task.yield() } - let keyboardTask = try #require(controller.manualRefreshTask) - #expect(controller.manualRefreshProvider == .claude) + let keyboardTask = try #require(controller.manualRefreshTasks[.provider(.claude)]) keyboardGate.resume() await keyboardTask.value } @@ -865,8 +872,7 @@ struct StatusMenuPersistentRefreshTests { } #expect(requestCount == 0) - #expect(controller.manualRefreshTask == nil) - #expect(controller.manualRefreshProvider == nil) + #expect(controller.manualRefreshTasks.isEmpty) } @Test @@ -888,11 +894,10 @@ struct StatusMenuPersistentRefreshTests { let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) #expect(controller.isPersistentRefreshItem(refreshItem)) controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) - for _ in 0..<20 where controller.manualRefreshTask == nil { + for _ in 0..<20 where controller.manualRefreshTasks[.global] == nil { await Task.yield() } - let overviewTask = try #require(controller.manualRefreshTask) - #expect(controller.manualRefreshProvider == nil) + let overviewTask = try #require(controller.manualRefreshTasks[.global]) overviewGate.resume() await overviewTask.value @@ -901,11 +906,10 @@ struct StatusMenuPersistentRefreshTests { let providerGate = ManualRefreshGate() controller._test_manualRefreshOperation = { await providerGate.wait() } #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) - for _ in 0..<20 where controller.manualRefreshTask == nil { + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil { await Task.yield() } - let providerTask = try #require(controller.manualRefreshTask) - #expect(controller.manualRefreshProvider == .claude) + let providerTask = try #require(controller.manualRefreshTasks[.provider(.claude)]) providerGate.resume() await providerTask.value } @@ -956,13 +960,13 @@ struct StatusMenuPersistentRefreshTests { } controller.refreshNow() - let task = try #require(controller.manualRefreshTask) + let task = try #require(controller.manualRefreshTasks[.global]) #expect(!refreshItem.isEnabled) gate.resume() await task.value - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks[.global] == nil) #expect(refreshItem.isEnabled) let fallback = MenuCardLiveSubtitle(text: "Fallback", style: .info) #expect(controller.menuCardRefreshMonitor.subtitle(for: .codex, fallback: fallback).style == .error) @@ -1092,3 +1096,125 @@ extension StatusMenuPersistentRefreshTests { #expect(refreshView.frame.height == metrics.rowHeight) } } + +extension StatusMenuPersistentRefreshTests { + @Test + func `concurrent manual refreshes keep each provider's frozen card`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let monitor = controller.menuCardRefreshMonitor + let now = Date() + + func quotaSnapshot(usedPercent: Double, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: updatedAt.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: updatedAt) + } + + // The snapshot helper supplies every enabled provider even for a provider-scoped refresh. + controller.store.snapshots[.claude] = quotaSnapshot(usedPercent: 21, updatedAt: now) + controller.store.snapshots[.codex] = quotaSnapshot(usedPercent: 15, updatedAt: now) + let claudeFrozen = try #require(controller.menuCardModel(for: .claude)) + let codexBeforeItsRefresh = try #require(controller.menuCardModel(for: .codex)) + monitor.beginManualRefresh( + frozenModels: [.claude: claudeFrozen, .codex: codexBeforeItsRefresh], + provider: .claude) + + // Each provider must freeze the card visible when its own refresh starts. + controller.store.snapshots[.claude] = quotaSnapshot(usedPercent: 65, updatedAt: now.addingTimeInterval(1)) + controller.store.snapshots[.codex] = quotaSnapshot(usedPercent: 42, updatedAt: now.addingTimeInterval(1)) + let claudeMidRefresh = try #require(controller.menuCardModel(for: .claude)) + let codexFrozen = try #require(controller.menuCardModel(for: .codex)) + monitor.beginManualRefresh( + frozenModels: [.claude: claudeMidRefresh, .codex: codexFrozen], + provider: .codex) + + let shownClaude = monitor.model(for: .claude, fallback: claudeMidRefresh) + let shownCodex = monitor.model(for: .codex, fallback: codexFrozen) + #expect(shownClaude.metrics.first?.percentLabel == "79% left") + #expect(shownCodex.metrics.first?.percentLabel == "58% left") + + // Ending Codex leaves Claude frozen; ending Claude clears it. + monitor.endManualRefresh(for: .codex) + #expect(monitor.isManualRefreshInFlight(for: .claude)) + #expect(!monitor.isManualRefreshInFlight(for: .codex)) + monitor.endManualRefresh(for: .claude) + #expect(!monitor.isManualRefreshInFlight(for: .claude)) + } + + @Test + func `refreshing one provider does not block refreshing another`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnly([.claude, .codex], settings: settings) + + let controller = self.makeController(settings: settings) + let codexMenu = try #require(controller.makeMenu(for: .codex) as? StatusItemMenu) + controller.menuWillOpen(codexMenu) + defer { controller.menuDidClose(codexMenu) } + + // Simulate a Claude manual refresh already in flight. + controller.manualRefreshTasks[.provider(.claude)] = Task {} + defer { + controller.manualRefreshTasks[.provider(.claude)]?.cancel() + controller.manualRefreshTasks[.provider(.claude)] = nil + } + + let gate = ManualRefreshGate() + controller._test_manualRefreshOperation = { await gate.wait() } + + // A Codex refresh must still start rather than being blocked by the in-flight Claude one. + controller.performPersistentRefreshAction(in: ObjectIdentifier(codexMenu)) + for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil { + await Task.yield() + } + let codexTask = try #require(controller.manualRefreshTasks[.provider(.codex)]) + #expect(controller.isRefreshActionInFlight(for: codexMenu)) + + gate.resume() + await codexTask.value + } + + @Test + func `overview stays busy through a provider refresh tail and blocks a global refresh`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnly([.claude, .codex], settings: settings) + settings.mergedMenuLastSelectedWasOverview = true + + let controller = self.makeController(settings: settings) + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + // A per-provider Claude refresh whose task outlives the store's `refreshingProviders` window + // (the status/token/credits tail runs after the provider is removed from that set). + controller.manualRefreshTasks[.provider(.claude)] = Task {} + defer { + controller.manualRefreshTasks[.provider(.claude)]?.cancel() + controller.manualRefreshTasks[.provider(.claude)] = nil + } + + // Overview stands for every provider, so its row stays greyed even with `refreshingProviders` empty. + #expect(controller.store.refreshingProviders.isEmpty) + #expect(controller.isRefreshActionInFlight(for: menu)) + + // And a global overview refresh must not start on top of the in-flight provider refresh. + var requestCount = 0 + controller._test_manualRefreshOperation = { requestCount += 1 } + controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + for _ in 0..<20 { + await Task.yield() + } + #expect(requestCount == 0) + #expect(controller.manualRefreshTasks[.global] == nil) + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index 21b969fce6..59d9f4d99b 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -349,7 +349,7 @@ struct StatusMenuSwitcherRefreshTests { preferencesSelection: PreferencesSelection(), statusBar: .system) defer { - controller.manualRefreshTask?.cancel() + controller.manualRefreshTasks.values.forEach { $0.cancel() } controller.releaseStatusItemsForTesting() } @@ -401,7 +401,7 @@ struct StatusMenuSwitcherRefreshTests { #expect(inFlight.metrics.first?.percentLabel == "79% left") gate.resume() - await controller.manualRefreshTask?.value + await controller.manualRefreshTasks[.global]?.value #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) let completed = controller.menuCardRefreshMonitor.model(for: .codex, fallback: emptyFallback) #expect(completed.metrics.isEmpty) @@ -435,7 +435,7 @@ struct StatusMenuSwitcherRefreshTests { preferencesSelection: PreferencesSelection(), statusBar: .system) defer { - controller.manualRefreshTask?.cancel() + controller.manualRefreshTasks.values.forEach { $0.cancel() } controller.releaseStatusItemsForTesting() } @@ -450,7 +450,7 @@ struct StatusMenuSwitcherRefreshTests { let initialRefreshItem = try #require(menu.items.first { $0.title == "Refresh" }) controller.refreshNow() - let refreshTask = try #require(controller.manualRefreshTask) + let refreshTask = try #require(controller.manualRefreshTasks[.global]) #expect(!initialRefreshItem.isEnabled) var rebuildCount = 0 @@ -463,7 +463,7 @@ struct StatusMenuSwitcherRefreshTests { gate.resume() await refreshTask.value - #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshTasks[.global] == nil) let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) @@ -497,7 +497,7 @@ struct StatusMenuSwitcherRefreshTests { preferencesSelection: PreferencesSelection(), statusBar: .system) defer { - controller.manualRefreshTask?.cancel() + controller.manualRefreshTasks.values.forEach { $0.cancel() } controller.releaseStatusItemsForTesting() } @@ -507,13 +507,13 @@ struct StatusMenuSwitcherRefreshTests { controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.codex)]) let refreshItem = try #require(cache.items.first { $0.title == "Refresh" }) - controller.manualRefreshTask = Task {} + controller.manualRefreshTasks[.global] = Task {} controller.updatePersistentRefreshItemsEnabled() #expect(!refreshItem.isEnabled) menu.removeAllItems() #expect(refreshItem.menu == nil) - controller.manualRefreshTask = nil + controller.manualRefreshTasks[.global] = nil controller.updatePersistentRefreshItemsEnabled() #expect(!refreshItem.isEnabled) @@ -527,6 +527,51 @@ struct StatusMenuSwitcherRefreshTests { #expect(refreshItem.isEnabled) } + @Test + func `a provider manual refresh only greys its own tab`() { + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { + controller.manualRefreshTasks.values.forEach { $0.cancel() } + controller.manualRefreshTasks.removeAll() + controller.releaseStatusItemsForTesting() + } + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + // A manual refresh of Claude must leave the Codex tab's Refresh row enabled. + controller.manualRefreshTasks[.provider(.claude)] = Task {} + #expect(!controller.isRefreshActionInFlight(for: menu)) + + // Switching to the Claude tab reflects Claude's own in-flight refresh. + settings.selectedMenuProvider = .claude + #expect(controller.isRefreshActionInFlight(for: menu)) + + // An all-providers refresh busies every tab regardless of the selected provider. + settings.selectedMenuProvider = .codex + controller.manualRefreshTasks[.provider(.claude)] = nil + controller.manualRefreshTasks[.global] = Task {} + #expect(controller.isRefreshActionInFlight(for: menu)) + } + @Test func `native image menu rows are replaced during reconciliation`() { let settings = Self.makeSettings() From 45b000c135b06a69c982b2ed09df35563b2e2350 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 07:59:55 -0700 Subject: [PATCH 061/105] docs: note scoped provider refresh --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa54888274..f4bd3257bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Settings: keep the sidebar fixed and visible while resizing, prevent collapse or over-expansion, and cap detail content width for readability. Thanks @Zihao-Qi! ### Fixed +- 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! - 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! From 6c4cf3a77f57236ff0f8ae1e334a2fe4baa50cf3 Mon Sep 17 00:00:00 2001 From: optimiz-r <139051194+optimiz-r@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:06:25 +0200 Subject: [PATCH 062/105] fix: prefer claude-swap account cards (#1913) * Give claude-swap accounts explicit precedence over Claude token-account cards Address Codex review: with multiple Claude token accounts in stacked layout, the token-account branch rendered first and silently hid the adapter's rows. Active claude-swap accounts now suppress Claude token-account presentation at the display seam (stacked cards and the segmented switcher) and render first in addMenuCards, with coverage in ClaudeSwapMenuPrecedenceTests. Also add the packaged synthetic proof screenshot (fake cswap executable, no real accounts or credentials) to docs/claude.md. Co-Authored-By: Claude Fable 5 * test: stabilize claude-swap precedence coverage --------- Co-authored-by: Claude Fable 5 Co-authored-by: Peter Steinberger --- ...tusItemController+AccountMenuDisplay.swift | 14 +++++++ .../CodexBar/StatusItemController+Menu.swift | 35 ++++++++++-------- .../ClaudeSwapMenuPrecedenceTests.swift | 17 +++++++++ docs/claude.md | 6 +++ .../claude-swap-accounts-synthetic-proof.png | Bin 0 -> 161047 bytes 5 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeSwapMenuPrecedenceTests.swift create mode 100644 docs/screenshots/claude-swap-accounts-synthetic-proof.png 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+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 7ed98ff77b..bd79412c46 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -607,6 +607,26 @@ 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) + { + let cards = self.store.claudeSwapAccountSnapshots.compactMap { account in + self.menuCardModel( + for: .claude, + snapshotOverride: account.snapshot, + errorOverride: ClaudeSwapAccountProjection.displayError( + accountError: account.error, + adapterError: self.store.claudeSwapLastError), + forceOverrideCard: account.snapshot == nil, + accountOverride: AccountInfo(email: account.displayLabel, plan: nil)) + } + self.addStackedMenuCards(cards, to: menu, context: context) + return false + } + if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { let accountSnapshots = tokenAccountDisplay.snapshots let cards = accountSnapshots.isEmpty @@ -633,21 +653,6 @@ extension StatusItemController { return false } - if context.currentProvider == .claude, self.store.claudeSwapAccountSnapshots.count > 1 { - let cards = self.store.claudeSwapAccountSnapshots.compactMap { account in - self.menuCardModel( - for: .claude, - snapshotOverride: account.snapshot, - errorOverride: ClaudeSwapAccountProjection.displayError( - accountError: account.error, - adapterError: self.store.claudeSwapLastError), - forceOverrideCard: account.snapshot == nil, - accountOverride: AccountInfo(email: account.displayLabel, plan: nil)) - } - self.addStackedMenuCards(cards, to: menu, context: context) - return false - } - guard let model = self.menuCardModel(for: context.selectedProvider) else { return false } let renderedModel = self.menuCardRefreshMonitor.model(for: model.provider, fallback: model) if context.openAIContext.hasOpenAIWebMenuItems || diff --git a/Tests/CodexBarTests/ClaudeSwapMenuPrecedenceTests.swift b/Tests/CodexBarTests/ClaudeSwapMenuPrecedenceTests.swift new file mode 100644 index 0000000000..052fe883e2 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSwapMenuPrecedenceTests.swift @@ -0,0 +1,17 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct ClaudeSwapMenuPrecedenceTests { + @Test + func `multiple Claude swap accounts take precedence`() { + #expect(ClaudeSwapMenuPrecedence.prefersClaudeSwap(provider: .claude, accountCount: 2)) + } + + @Test + func `precedence requires Claude and multiple swap accounts`() { + #expect(!ClaudeSwapMenuPrecedence.prefersClaudeSwap(provider: .claude, accountCount: 0)) + #expect(!ClaudeSwapMenuPrecedence.prefersClaudeSwap(provider: .claude, accountCount: 1)) + #expect(!ClaudeSwapMenuPrecedence.prefersClaudeSwap(provider: .openai, accountCount: 2)) + } +} diff --git a/docs/claude.md b/docs/claude.md index e104eae274..36b2bceae7 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -131,6 +131,12 @@ Phase 1 of the accepted multi-account design in - Sentinel statuses (`token_expired`, `api_key`, `keychain_unavailable`, `no_credentials`, `unavailable`) render as per-account notes instead of usage bars. - Account switching is intentionally out of scope; use `cswap` directly to switch accounts. +- When multiple claude-swap accounts are available, they take explicit precedence over Claude + token-account presentation (stacked cards and the segmented switcher). + +Packaged synthetic proof (fake `cswap` executable, no real accounts or credentials): + +![Stacked claude-swap account cards](screenshots/claude-swap-accounts-synthetic-proof.png) ## CLI PTY (fallback) - Runs `claude` in a PTY session (`ClaudeCLISession`). diff --git a/docs/screenshots/claude-swap-accounts-synthetic-proof.png b/docs/screenshots/claude-swap-accounts-synthetic-proof.png new file mode 100644 index 0000000000000000000000000000000000000000..b24d8ecadb79150638633c7e8c91aa31ccb4cda2 GIT binary patch literal 161047 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�i-6r!)`o#C2ag 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%0Ap?-!< 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 |;C#j>>XU%K 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 literal 0 HcmV?d00001 From 8e9a844dbd36d891dc28f3a089dae53087063f82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 09:06:58 -0700 Subject: [PATCH 063/105] docs: note claude-swap precedence --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4bd3257bc..a97238047e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Settings: keep the sidebar fixed and visible while resizing, prevent collapse or over-expansion, and cap detail content width for readability. Thanks @Zihao-Qi! ### Fixed +- 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! - 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! From 68939add9bff7ac0820c72652cfbed67a598d9dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 12:28:53 -0700 Subject: [PATCH 064/105] fix: detect usable providers on first launch (#1921) --- CHANGELOG.md | 1 + .../SettingsStore+ProviderDetection.swift | 73 +++++++++++++------ .../ProviderDetectionPolicyTests.swift | 44 +++++++++++ 3 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 Tests/CodexBarTests/ProviderDetectionPolicyTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a97238047e..92259bc30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Settings: keep the sidebar fixed and visible while resizing, prevent collapse or over-expansion, and cap detail content width for readability. Thanks @Zihao-Qi! ### Fixed +- Providers: detect Claude Desktop on fresh installs and ignore Gemini CLI installations without usable OAuth credentials. - 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! 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/Tests/CodexBarTests/ProviderDetectionPolicyTests.swift b/Tests/CodexBarTests/ProviderDetectionPolicyTests.swift new file mode 100644 index 0000000000..a3ad4216ac --- /dev/null +++ b/Tests/CodexBarTests/ProviderDetectionPolicyTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ProviderDetectionPolicyTests { + @Test + func `fresh install detects Codex and Claude Desktop without unconfigured Gemini`() { + let enabled = ProviderDetectionPolicy.enabledProviders(signals: .init( + codexCLIInstalled: true, + claudeCLIInstalled: false, + claudeDesktopInstalled: true, + geminiCLIInstalled: true, + geminiConfigured: false, + antigravityAvailable: false)) + + #expect(enabled == [.codex, .claude]) + } + + @Test + func `configured Gemini CLI is detected`() { + let enabled = ProviderDetectionPolicy.enabledProviders(signals: .init( + codexCLIInstalled: false, + claudeCLIInstalled: false, + claudeDesktopInstalled: false, + geminiCLIInstalled: true, + geminiConfigured: true, + antigravityAvailable: false)) + + #expect(enabled == [.gemini]) + } + + @Test + func `Codex remains the fallback when no provider source is available`() { + let enabled = ProviderDetectionPolicy.enabledProviders(signals: .init( + codexCLIInstalled: false, + claudeCLIInstalled: false, + claudeDesktopInstalled: false, + geminiCLIInstalled: false, + geminiConfigured: false, + antigravityAvailable: false)) + + #expect(enabled == [.codex]) + } +} From 8cbe84873c52a96082f9f6ea050f96f31177fc3a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 22:16:09 +0100 Subject: [PATCH 065/105] perf: reduce idle cost scan CPU (#1923) --- CHANGELOG.md | 1 + Sources/CodexBar/UsageStore+TokenCost.swift | 11 ++- Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCore/CostUsageFetcher.swift | 51 ++++++++-- .../Generated/CodexParserHash.generated.swift | 2 +- .../CostUsage/CostUsageScanner+Projects.swift | 13 ++- .../CostUsagePerformanceGateTests.swift | 35 +++++++ .../UsageStoreCachedTokenHydrationTests.swift | 93 +++++++++++++++++++ 8 files changed, 195 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92259bc30a..9c9dc05086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Settings: keep the sidebar fixed and visible while resizing, prevent collapse or over-expansion, and cap detail content width for readability. Thanks @Zihao-Qi! ### 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: 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! 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.swift b/Sources/CodexBar/UsageStore.swift index cdf2abfb01..1b0502ed50 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -292,7 +292,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 diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 2f968fa8a4..1a6f704b3f 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -18,6 +18,11 @@ public enum CostUsageError: LocalizedError, Sendable { } public struct CostUsageFetcher: Sendable { + package struct CachedCodexTokenSnapshotResult: Sendable { + package let snapshot: CostUsageTokenSnapshot + package let lastRefreshAt: Date? + } + private let scannerOptions: CostUsageScanner.Options? public init(cacheRoot: URL? = nil) { @@ -40,6 +45,18 @@ public struct CostUsageFetcher: Sendable { scannerOptions: self.scannerOptionsOverride()) } + package func loadCachedCodexTokenSnapshotResult( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30) async -> CachedCodexTokenSnapshotResult? + { + await Self.loadCachedCodexTokenSnapshotResult( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + scannerOptions: self.scannerOptionsOverride()) + } + public func loadTokenSnapshot( provider: UsageProvider, environment: [String: String] = ProcessInfo.processInfo.environment, @@ -234,6 +251,20 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String? = nil, historyDays: Int = 30, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CostUsageTokenSnapshot? + { + await self.loadCachedCodexTokenSnapshotResult( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + scannerOptions: overrideScannerOptions)?.snapshot + } + + static func loadCachedCodexTokenSnapshotResult( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30, + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async + -> CachedCodexTokenSnapshotResult? { if let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), !codexHomePath.isEmpty @@ -243,7 +274,7 @@ public struct CostUsageFetcher: Sendable { // Decoding the persisted scan cache parses multi-megabyte JSON; keep it off the // cooperative pool alongside the scans themselves. - let cachedSnapshot: CostUsageTokenSnapshot?? = try? await CostUsageScanExecutor.run { _ in + let cachedSnapshot: CachedCodexTokenSnapshotResult?? = try? await CostUsageScanExecutor.run { _ in let clampedHistoryDays = max(1, min(365, historyDays)) let until = now let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now @@ -252,6 +283,7 @@ public struct CostUsageFetcher: Sendable { let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) var reports: [CostUsageDailyReport] = [] var projects: [CostUsageProjectBreakdown] = [] + var nativeLastRefreshAt: Date? if !cache.days.isEmpty, cache.roots == CostUsageScanner.codexRootsFingerprint(options: options), @@ -263,6 +295,10 @@ public struct CostUsageFetcher: Sendable { modelsDevCacheRoot: options.cacheRoot) if !daily.data.isEmpty { reports.append(daily) + if cache.lastScanUnixMs > 0 { + nativeLastRefreshAt = Date( + timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) + } if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache( cache: cache, @@ -280,17 +316,20 @@ public struct CostUsageFetcher: Sendable { cacheRoot: options.cacheRoot) { reports.append(piDaily) + nativeLastRefreshAt = nil if let piProject = Self.unknownProjectBreakdown(from: piDaily) { projects.append(piProject) } } guard !reports.isEmpty else { return nil } - return Self.tokenSnapshot( - from: CostUsageDailyReport.merged(reports), - now: now, - historyDays: clampedHistoryDays, - projects: Self.mergedProjectBreakdowns(projects)) + return CachedCodexTokenSnapshotResult( + snapshot: Self.tokenSnapshot( + from: CostUsageDailyReport.merged(reports), + now: now, + historyDays: clampedHistoryDays, + projects: Self.mergedProjectBreakdowns(projects)), + lastRefreshAt: nativeLastRefreshAt) } return cachedSnapshot.flatMap(\.self) } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 57705aaa14..b4a5eea1d9 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "f9681e8eb932e939" + static let value = "bda3b0f590bffb5f" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift index fc77b8ebe1..b6b1efee0f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift @@ -6,8 +6,16 @@ extension CostUsageScanner { range: CostUsageDayRange, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil, - priorityTurns: [String: CodexPriorityTurnMetadata] = [:]) -> [CostUsageProjectBreakdown] + priorityTurns: [String: CodexPriorityTurnMetadata] = [:], + modelsDevCatalogLoader: (URL?) -> ModelsDevCatalog? = { + CostUsagePricing.modelsDevCatalog(cacheRoot: $0) + }) -> [CostUsageProjectBreakdown] { + // Project rollups build one report per cached session file. Resolve pricing once so every + // row does not fall back through ModelsDevCache.load and repeat filesystem metadata reads. + let resolvedModelsDevCatalog = modelsDevCatalog + ?? modelsDevCatalogLoader(modelsDevCacheRoot) + ?? ModelsDevCatalog(providers: [:]) let projectPathResolver = CodexCanonicalProjectPathResolver() var accumulatorsByProjectPath: [String: CodexProjectBreakdownAccumulator] = [:] for (filePath, usage) in cache.files { @@ -20,8 +28,7 @@ extension CostUsageScanner { let report = Self.buildCodexReportFromCache( cache: fileCache, range: range, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot, + modelsDevCatalog: resolvedModelsDevCatalog, priorityTurns: priorityTurns) guard !report.data.isEmpty else { continue } let projectKey = usage.canonicalProjectPath diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift index 387e5bf28d..691edce2eb 100644 --- a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift +++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift @@ -93,6 +93,41 @@ struct CostUsagePerformanceGateTests { #expect(advanced.lastRowID == scanned.lastRowID + 1) } + @Test + func `project rollups resolve the pricing catalog once per build`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + _ = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 3, turnsPerFile: 4) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var catalogLoadCount = 0 + let projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot, + modelsDevCatalogLoader: { _ in + catalogLoadCount += 1 + return ModelsDevCatalog(providers: [:]) + }) + + #expect(!projects.isEmpty) + #expect(catalogLoadCount == 1) + } + private static func writeSyntheticCodexCorpus( env: CostUsageTestEnvironment, day: Date, diff --git a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift index 5b0fb6590e..4a460d2a37 100644 --- a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift +++ b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift @@ -98,6 +98,99 @@ struct UsageStoreCachedTokenHydrationTests { #expect(store.tokenSnapshot(for: .codex) == nil) } + @Test + func `fresh cached hydration suppresses the redundant startup token refresh`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = Date() + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: now, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: now, + historyDays: 1, + scannerOptions: options) + + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: CostUsageFetcher(scannerOptions: options), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + var tokenRefreshCount = 0 + store._test_tokenUsageRefreshOverride = { _, _ in tokenRefreshCount += 1 } + + store.hydrateCachedTokenSnapshots(now: now) + for _ in 0..<100 where store.tokenSnapshot(for: .codex) == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + await store.refreshTokenUsageNow(for: .codex, force: false) + + #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 42) + #expect(store.tokenLastAttemptAt(for: .codex).map { abs($0.timeIntervalSince(now)) < 0.001 } == true) + #expect(tokenRefreshCount == 0) + } + + @Test + func `stale cached hydration still allows the startup token refresh`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = Date() + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: now, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: now, + historyDays: 1, + scannerOptions: options) + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.lastScanUnixMs = Int64(now.addingTimeInterval(-2 * 60 * 60).timeIntervalSince1970 * 1000) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: CostUsageFetcher(scannerOptions: options), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + var tokenRefreshCount = 0 + store._test_tokenUsageRefreshOverride = { _, _ in tokenRefreshCount += 1 } + + store.hydrateCachedTokenSnapshots(now: now) + for _ in 0..<100 where store.tokenSnapshot(for: .codex) == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + await store.refreshTokenUsageNow(for: .codex, force: false) + + #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 42) + #expect(store.tokenLastAttemptAt(for: .codex) != nil) + #expect(tokenRefreshCount == 1) + } + private static func makeCodexOnlySettings(historyDays: Int) -> SettingsStore { let suite = "UsageStoreCachedTokenHydrationTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! From ba378a219dd581cc121a858eae07f297ec0822cf Mon Sep 17 00:00:00 2001 From: Zihao Qi <35388022+Zihao-Qi@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:51:25 -0500 Subject: [PATCH 066/105] fix: include Claude Desktop local-agent usage (#1922) Thanks @Zihao-Qi! Co-authored-by: Trim --- CHANGELOG.md | 1 + .../CodexBar/SettingsStore+TokenCost.swift | 10 +- .../Claude/ClaudeDesktopProjectsLocator.swift | 89 ++++++++++ .../Claude/ClaudeProviderDescriptor.swift | 3 +- .../CostUsage/CostUsageScanner+Claude.swift | 34 +++- .../CostUsageScannerClaudeDesktopTests.swift | 161 ++++++++++++++++++ .../CodexBarTests/CostUsageScannerTests.swift | 65 +++++++ .../SettingsStoreCoverageTests.swift | 56 ++++++ docs/claude.md | 10 +- docs/providers.md | 4 +- 10 files changed, 418 insertions(+), 15 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeDesktopProjectsLocator.swift create mode 100644 Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c9dc05086..d0fb0141d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### 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! 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/CodexBarCore/Providers/Claude/ClaudeDesktopProjectsLocator.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeDesktopProjectsLocator.swift new file mode 100644 index 0000000000..9b886e2f1e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeDesktopProjectsLocator.swift @@ -0,0 +1,89 @@ +import Foundation + +public enum ClaudeDesktopProjectsLocator { + private static let sessionDirectoryNames = [ + "local-agent-mode-sessions", + "claude-code-sessions", + ] + + private static let skippedDirectoryNames = Set([ + ".build", + ".git", + "build", + "DerivedData", + "node_modules", + "outputs", + "target", + ]) + + public static func roots( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default) -> [URL] + { + let claudeApplicationSupport = homeDirectory + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Claude", isDirectory: true) + let sessionRoots = self.sessionDirectoryNames.map { + claudeApplicationSupport.appendingPathComponent($0, isDirectory: true) + } + + var roots: [URL] = [] + var queue = sessionRoots.map { (url: $0, depth: 0) } + var visited = Set(sessionRoots.map(\.standardizedFileURL.path)) + var nextIndex = 0 + // Current Desktop entries under claude-code-sessions are metadata whose cliSessionId maps to the + // shared ~/.claude/projects logs. Seed that root anyway so embedded project stores are found when present. + // Covers observed Desktop layouts through account/workspace, session, agent, and local_agent + // without descending into arbitrary checked-out workspaces. + let maxDepth = 4 + + while nextIndex < queue.count { + let current = queue[nextIndex] + nextIndex += 1 + if let projects = self.projectsRoot(under: current.url, fileManager: fileManager) { + roots.append(projects) + } + + guard current.depth < maxDepth else { continue } + for child in self.childDirectories(at: current.url, fileManager: fileManager) { + let standardized = child.standardizedFileURL + guard visited.insert(standardized.path).inserted else { continue } + queue.append((standardized, current.depth + 1)) + } + } + return roots + } + + private static func projectsRoot(under base: URL, fileManager: FileManager) -> URL? { + let projects = base + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: projects.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { return nil } + return projects.standardizedFileURL + } + + private static func childDirectories(at url: URL, fileManager: FileManager) -> [URL] { + guard let children = try? fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { + return [] + } + + return children.compactMap { child in + guard !self.skippedDirectoryNames.contains(child.lastPathComponent) else { return nil } + guard let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]), + values.isSymbolicLink != true, + values.isDirectory == true + else { + return nil + } + return child + } + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 4ce6f1f67a..4a1c35c2a9 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -129,7 +129,8 @@ public enum ClaudeProviderDescriptor { } private static func noDataMessage() -> String { - "No Claude usage logs found in ~/.config/claude/projects or ~/.claude/projects." + "No Claude usage logs found in ~/.config/claude/projects, ~/.claude/projects, " + + "or Claude Desktop sessions." } public static func resolveUsageStrategy( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 83730d3b16..2f61dabada 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -24,12 +24,17 @@ extension CostUsageScanner { var unresolved = false } - private static func defaultClaudeProjectsRoots(options: Options) -> [URL] { + static func defaultClaudeProjectsRoots( + options: Options, + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default) -> [URL] + { if let override = options.claudeProjectsRoots { return override } var roots: [URL] = [] - if let env = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"]? + if let env = environment["CLAUDE_CONFIG_DIR"]? .trimmingCharacters(in: .whitespacesAndNewlines), !env.isEmpty { @@ -44,12 +49,27 @@ extension CostUsageScanner { } } } else { - let home = FileManager.default.homeDirectoryForCurrentUser - roots.append(home.appendingPathComponent(".config/claude/projects", isDirectory: true)) - roots.append(home.appendingPathComponent(".claude/projects", isDirectory: true)) + roots.append(homeDirectory.appendingPathComponent(".config/claude/projects", isDirectory: true)) + roots.append(homeDirectory.appendingPathComponent(".claude/projects", isDirectory: true)) + roots.append(contentsOf: ClaudeDesktopProjectsLocator.roots( + homeDirectory: homeDirectory, + fileManager: fileManager)) } - return roots + return self.deduplicatedClaudeProjectRoots(roots) + } + + private static func deduplicatedClaudeProjectRoots(_ roots: [URL]) -> [URL] { + var seen: Set = [] + var out: [URL] = [] + for root in roots { + let standardized = root.standardizedFileURL + let path = standardized.path + guard !seen.contains(path) else { continue } + seen.insert(path) + out.append(standardized) + } + return out } static func parseClaudeFile( @@ -647,7 +667,6 @@ extension CostUsageScanner { || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs - let roots = self.defaultClaudeProjectsRoots(options: options) let providerFilter = options.claudeLogProviderFilter var touched: Set = [] @@ -667,6 +686,7 @@ extension CostUsageScanner { modelsDevCacheRoot: options.cacheRoot, checkCancellation: checkCancellation) + let roots = self.defaultClaudeProjectsRoots(options: options) for root in roots { try Self.scanClaudeRoot( root: root, diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift new file mode 100644 index 0000000000..3db56c7272 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerClaudeDesktopTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageScannerClaudeDesktopTests { + @Test + func `claude daily report includes nested desktop local agent projects`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 5) + let iso0 = env.isoString(for: day) + let assistant: [String: Any] = [ + "type": "assistant", + "timestamp": iso0, + "message": [ + "model": "claude-test-model", + "usage": [ + "input_tokens": 120, + "cache_creation_input_tokens": 30, + "cache_read_input_tokens": 20, + "output_tokens": 40, + ], + ], + ] + let nestedAssistant: [String: Any] = [ + "type": "assistant", + "timestamp": iso0, + "message": [ + "model": "claude-test-model", + "usage": [ + "input_tokens": 10, + "output_tokens": 5, + ], + ], + ] + let currentAssistant: [String: Any] = [ + "type": "assistant", + "timestamp": iso0, + "message": [ + "model": "claude-test-model", + "usage": [ + "input_tokens": 7, + "output_tokens": 3, + ], + ], + ] + let decoyAssistant: [String: Any] = [ + "type": "assistant", + "timestamp": iso0, + "message": [ + "model": "claude-test-model", + "usage": [ + "input_tokens": 999, + "output_tokens": 999, + ], + ], + ] + let projectsRoot = try env.writeClaudeDesktopLocalAgentProjectFile( + relativePath: "project-a/session-a.jsonl", + contents: env.jsonl([assistant])) + .deletingLastPathComponent() + .deletingLastPathComponent() + let nestedProjectsRoot = try env.writeNestedClaudeDesktopLocalAgentProjectFile( + relativePath: "project-b/session-b.jsonl", + contents: env.jsonl([nestedAssistant])) + .deletingLastPathComponent() + .deletingLastPathComponent() + let currentProjectsRoot = try env.writeClaudeDesktopCodeSessionProjectFile( + relativePath: "project-c/session-c.jsonl", + contents: env.jsonl([currentAssistant])) + .deletingLastPathComponent() + .deletingLastPathComponent() + let decoyProjectsRoot = try env.writeClaudeDesktopLocalAgentFile( + relativePath: "outputs/node_modules/package/.claude/projects/project-decoy/session-decoy.jsonl", + contents: env.jsonl([decoyAssistant])) + .deletingLastPathComponent() + .deletingLastPathComponent() + + let discovered = CostUsageScanner.defaultClaudeProjectsRoots( + options: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + environment: [:], + homeDirectory: env.root) + #expect(discovered.contains(projectsRoot.standardizedFileURL)) + #expect(discovered.contains(nestedProjectsRoot.standardizedFileURL)) + #expect(discovered.contains(currentProjectsRoot.standardizedFileURL)) + #expect(!discovered.contains(decoyProjectsRoot.standardizedFileURL)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: nil, + claudeProjectsRoots: discovered, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 137) + #expect(report.data[0].cacheCreationTokens == 30) + #expect(report.data[0].cacheReadTokens == 20) + #expect(report.data[0].outputTokens == 48) + #expect(report.data[0].totalTokens == 235) + } + + @Test + func `current desktop shared claude projects root remains discovered`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 5) + let sessionID = "desktop-cli-session" + let assistant: [String: Any] = [ + "type": "assistant", + "timestamp": env.isoString(for: day), + "message": [ + "model": "claude-test-model", + "usage": [ + "input_tokens": 11, + "cache_read_input_tokens": 13, + "output_tokens": 4, + ], + ], + ] + // Current Desktop's cliSessionId points to the matching JSONL in this shared root. + let sharedProjectsRoot = try env.writeClaudeDesktopSharedProjectFile( + relativePath: "desktop-project/\(sessionID).jsonl", + contents: env.jsonl([assistant])) + .deletingLastPathComponent() + .deletingLastPathComponent() + + let discovered = CostUsageScanner.defaultClaudeProjectsRoots( + options: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + environment: [:], + homeDirectory: env.root) + #expect(discovered.contains(sharedProjectsRoot.standardizedFileURL)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: nil, + claudeProjectsRoots: discovered, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 11) + #expect(report.data[0].cacheReadTokens == 13) + #expect(report.data[0].outputTokens == 4) + #expect(report.data[0].totalTokens == 28) + } +} diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index c94ce42a2a..3798910945 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -940,6 +940,71 @@ struct CostUsageTestEnvironment { return url } + func writeClaudeDesktopLocalAgentFile(relativePath: String, contents: String) throws -> URL { + let localAgentRoot = self.root + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Claude", isDirectory: true) + .appendingPathComponent("local-agent-mode-sessions", isDirectory: true) + .appendingPathComponent("workspace-id", isDirectory: true) + .appendingPathComponent("session-id", isDirectory: true) + .appendingPathComponent("local_agent", isDirectory: true) + let url = localAgentRoot.appendingPathComponent(relativePath, isDirectory: false) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + + func writeClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL { + try self.writeClaudeDesktopLocalAgentFile( + relativePath: ".claude/projects/\(relativePath)", + contents: contents) + } + + func writeClaudeDesktopCodeSessionProjectFile(relativePath: String, contents: String) throws -> URL { + let projectsRoot = self.root + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Claude", isDirectory: true) + .appendingPathComponent("claude-code-sessions", isDirectory: true) + .appendingPathComponent("account-id", isDirectory: true) + .appendingPathComponent("org-id", isDirectory: true) + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + + func writeClaudeDesktopSharedProjectFile(relativePath: String, contents: String) throws -> URL { + let projectsRoot = self.root + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + + func writeNestedClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL { + let projectsRoot = self.root + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Claude", isDirectory: true) + .appendingPathComponent("local-agent-mode-sessions", isDirectory: true) + .appendingPathComponent("workspace-id", isDirectory: true) + .appendingPathComponent("session-id", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("local_agent", isDirectory: true) + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + func writeCodexArchivedSessionFile(filename: String, contents: String) throws -> URL { let url = self.codexArchivedSessionsRoot.appendingPathComponent(filename, isDirectory: false) try contents.write(to: url, atomically: true, encoding: .utf8) diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 2edaeea4f0..9b8f0d34df 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -390,6 +390,62 @@ struct SettingsStoreCoverageTests { #expect(SettingsStore.hasAnyTokenCostUsageSources( env: ["CLAUDE_CONFIG_DIR": claudeRoot.path], fileManager: fileManager)) + + let metadataOnlyHome = fileManager.temporaryDirectory.appendingPathComponent( + "claude-desktop-metadata-\(UUID().uuidString)", + isDirectory: true) + let metadataFile = metadataOnlyHome + .appendingPathComponent("Library/Application Support/Claude/claude-code-sessions", isDirectory: true) + .appendingPathComponent("account-id/org-id/local_session.json", isDirectory: false) + try fileManager.createDirectory(at: metadataFile.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(#"{"cliSessionId":"desktop-cli-session"}"#.utf8).write(to: metadataFile) + + #expect(!SettingsStore.hasAnyTokenCostUsageSources( + env: [:], + fileManager: fileManager, + homeDirectory: metadataOnlyHome)) + + let desktopHome = fileManager.temporaryDirectory.appendingPathComponent( + "claude-desktop-\(UUID().uuidString)", + isDirectory: true) + let desktopProjects = desktopHome + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Claude", isDirectory: true) + .appendingPathComponent("local-agent-mode-sessions", isDirectory: true) + .appendingPathComponent("workspace-id", isDirectory: true) + .appendingPathComponent("session-id", isDirectory: true) + .appendingPathComponent("local_agent", isDirectory: true) + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + try fileManager.createDirectory(at: desktopProjects, withIntermediateDirectories: true) + let desktopFile = desktopProjects + .appendingPathComponent("project-a", isDirectory: true) + .appendingPathComponent("session-a.jsonl", isDirectory: false) + try fileManager.createDirectory(at: desktopFile.deletingLastPathComponent(), withIntermediateDirectories: true) + fileManager.createFile(atPath: desktopFile.path, contents: Data("{}".utf8)) + + #expect(SettingsStore.hasAnyTokenCostUsageSources( + env: [:], + fileManager: fileManager, + homeDirectory: desktopHome)) + + let desktopCodeHome = fileManager.temporaryDirectory.appendingPathComponent( + "claude-desktop-code-\(UUID().uuidString)", + isDirectory: true) + let desktopCodeFile = desktopCodeHome + .appendingPathComponent("Library/Application Support/Claude/claude-code-sessions", isDirectory: true) + .appendingPathComponent("account-id/org-id/.claude/projects/project-a", isDirectory: true) + .appendingPathComponent("session-a.jsonl", isDirectory: false) + try fileManager.createDirectory( + at: desktopCodeFile.deletingLastPathComponent(), + withIntermediateDirectories: true) + fileManager.createFile(atPath: desktopCodeFile.path, contents: Data("{}".utf8)) + + #expect(SettingsStore.hasAnyTokenCostUsageSources( + env: [:], + fileManager: fileManager, + homeDirectory: desktopCodeHome)) } @Test diff --git a/docs/claude.md b/docs/claude.md index 36b2bceae7..aa1434e720 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -165,10 +165,16 @@ Packaged synthetic proof (fake `cswap` executable, no real accounts or credentia - `$CLAUDE_CONFIG_DIR` (comma-separated), each root uses `/projects`. - Fallback roots: - `~/.config/claude/projects` - - `~/.claude/projects` + - `~/.claude/projects` (Claude Code and current Claude Desktop Code/Cowork CLI sessions) + - Additional embedded Claude Desktop project stores, when present: + - `~/Library/Application Support/Claude/local-agent-mode-sessions/**/.claude/projects` + - `~/Library/Application Support/Claude/claude-code-sessions/**/.claude/projects` + - Current Claude Desktop metadata under `claude-code-sessions` points to shared CLI session JSONL by + `cliSessionId`; metadata-only directories are not treated as usage sources. - Supported pi sessions: - `~/.pi/agent/sessions/**/*.jsonl` -- Files: `**/*.jsonl` under the native project roots plus supported pi session files. +- Files: `**/*.jsonl` under the native project roots, discovered Claude Desktop project roots, + plus supported pi session files. - Parsing: - Native Claude logs parse lines with `type: "assistant"` and `message.usage`. - Uses per-model token counts (input, cache read/create, output). diff --git a/docs/providers.md b/docs/providers.md index 1837080052..2a40a8b42d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -105,7 +105,9 @@ headers, source selection, provider ordering, and token accounts are stored in ` - Admin API shows organization spend/messages summaries with the same inline dashboard pattern as OpenAI API. - App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). - CLI Auto: Web API (`web`) → CLI PTY (`claude`). -- Local cost usage: scans `CLAUDE_CONFIG_DIR` when set, otherwise `~/.config/claude/projects` and `~/.claude/projects` JSONL files for the configured history window. +- Local cost usage: scans `CLAUDE_CONFIG_DIR` when set, otherwise `~/.config/claude/projects`, + `~/.claude/projects` (including current Claude Desktop Code/Cowork CLI sessions), and nested Claude Desktop + local-agent JSONL files for the configured history window. - Status: Statuspage.io (Anthropic). - Details: `docs/claude.md`. From 5dc3e9a0a28922b57f4b05ffce7d0fc943555fa6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 15:03:35 -0700 Subject: [PATCH 067/105] fix: distinguish debug menu bar icons --- CHANGELOG.md | 1 + .../StatusItemController+Animation.swift | 19 +++++++++++++++---- Sources/CodexBar/StatusItemController.swift | 15 +++++++++++++-- .../StatusItemBalanceDisplayTests.swift | 16 ++++++++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fb0141d0..834ba4c951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### 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. ### 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. diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 77fc163f1e..c73fb2c060 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -694,7 +694,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 } @@ -704,9 +708,16 @@ 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? { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index dae1718326..fd194c6827 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 @@ -292,11 +301,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 } diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift index 1c181ebc20..3074f52701 100644 --- a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -656,6 +656,22 @@ struct StatusItemBalanceDisplayTests { #expect(StatusItemController.buttonTitle("", hasImage: true).isEmpty) } + @Test + func `debug button title stays visible with or without a usage value`() { + #expect(StatusItemController.buttonTitle(nil, hasImage: true, isDebugApp: true) == " D") + #expect(StatusItemController.buttonTitle("42%", hasImage: true, isDebugApp: true) == " 42% D") + #expect(StatusItemController.buttonTitle("42%", hasImage: false, isDebugApp: true) == "42% D") + } + + @Test + func `debug bundle identity updates status item accessibility`() { + #expect(StatusItemController.isDebugApp(bundleIdentifier: "com.steipete.codexbar.debug")) + #expect(!StatusItemController.isDebugApp(bundleIdentifier: "com.steipete.codexbar")) + #expect(!StatusItemController.isDebugApp(bundleIdentifier: nil)) + #expect(StatusItemController.statusItemAccessibilityTitle(isDebugApp: true) == "CodexBar Debug") + #expect(StatusItemController.statusItemAccessibilityTitle(isDebugApp: false) == "CodexBar") + } + private func makeSettings(suiteName: String, provider: UsageProvider) -> SettingsStore { let settings = testSettingsStore(suiteName: suiteName) settings.statusChecksEnabled = false From ab4717831d6db6eac5f1f8b6f69993e8562a2a1a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 23:23:15 +0100 Subject: [PATCH 068/105] feat: add claude-swap account switching (#1924) --- CHANGELOG.md | 1 + .../CodexBar/MenuCardView+ModelInput.swift | 6 + Sources/CodexBar/MenuCardView.swift | 39 +++-- .../Claude/ClaudeProviderImplementation.swift | 4 +- .../Claude/UsageStore+ClaudeSwapRefresh.swift | 75 ++++++++- .../StatusItemController+ClaudeSwapMenu.swift | 51 ++++++ .../CodexBar/StatusItemController+Menu.swift | 22 +-- .../StatusItemController+MenuCardModel.swift | 5 +- Sources/CodexBar/UsageStore.swift | 2 +- .../Host/Process/SubprocessRunner.swift | 49 ++++-- .../ProviderAccountSnapshot.swift | 5 + .../ClaudeSwapAccountProjection.swift | 20 ++- .../ClaudeSwap/ClaudeSwapAccountReader.swift | 59 ++++++- .../ClaudeSwap/ClaudeSwapAccountSwitch.swift | 117 ++++++++++++++ .../ClaudeProviderRuntimeTests.swift | 152 ++++++++++++++++++ .../ClaudeSwapAccountProjectionTests.swift | 12 ++ .../ClaudeSwapAccountReaderTests.swift | 68 +++++++- .../ClaudeSwapSwitchParserTests.swift | 92 +++++++++++ .../MenuCardClaudeSwapAccountTests.swift | 13 +- .../MenuCardOverrideIsolationTests.swift | 1 + docs/claude-multi-account-and-status-items.md | 47 ++++-- docs/claude.md | 11 +- 22 files changed, 780 insertions(+), 71 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountSwitch.swift create mode 100644 Tests/CodexBarTests/ClaudeSwapSwitchParserTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 834ba4c951..e42c9474c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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! - 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! diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index e5b2dc1846..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? @@ -46,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, @@ -77,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 diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index cb5d3292c0..1c80085b52 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -142,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 @@ -155,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() @@ -280,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 @@ -336,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) } } } @@ -539,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() @@ -818,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), @@ -971,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 @@ -986,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) { @@ -1105,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 diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 1b2b299fd2..d79cc66ad2 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -103,8 +103,8 @@ struct ClaudeProviderImplementation: ProviderImplementation { ProviderSettingsToggleDescriptor( id: "claude-swap-accounts", title: "Read accounts from claude-swap", - subtitle: "Shows usage for every claude-swap account by running `cswap --list --json`. " + - "Read-only: CodexBar never reads claude-swap or Claude Code credentials.", + 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: [], diff --git a/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift b/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift index 54b0a74a15..a2a827dc56 100644 --- a/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift +++ b/Sources/CodexBar/Providers/Claude/UsageStore+ClaudeSwapRefresh.swift @@ -1,10 +1,20 @@ 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. The adapter is display-only: it never reads - /// credentials and its failures never affect the ambient Claude snapshot. + /// 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 @@ -12,12 +22,18 @@ extension UsageStore { func clearClaudeSwapAccountState() { let hadState = !self.claudeSwapAccountSnapshots.isEmpty || - self.claudeSwapLastRefreshAt != nil || self.claudeSwapLastError != nil + 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 } @@ -68,11 +84,60 @@ extension UsageStore { } } + /// 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.claudeSwapVersionProbedPath != executablePath else { return } + guard self.claudeSwapTransientState.versionProbedPath != executablePath else { return } let version = await ClaudeSwapAccountReader.readVersion(executablePath: executablePath) guard self.isCurrentClaudeSwapConfiguration(executablePath: executablePath) else { return } - self.claudeSwapVersionProbedPath = executablePath + self.claudeSwapTransientState.versionProbedPath = executablePath self.claudeSwapDetectedVersion = version } 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+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index bd79412c46..9399bd4bb2 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -613,17 +613,7 @@ extension StatusItemController { provider: context.currentProvider, accountCount: self.store.claudeSwapAccountSnapshots.count) { - let cards = self.store.claudeSwapAccountSnapshots.compactMap { account in - self.menuCardModel( - for: .claude, - snapshotOverride: account.snapshot, - errorOverride: ClaudeSwapAccountProjection.displayError( - accountError: account.error, - adapterError: self.store.claudeSwapLastError), - forceOverrideCard: account.snapshot == nil, - accountOverride: AccountInfo(email: account.displayLabel, plan: nil)) - } - self.addStackedMenuCards(cards, to: menu, context: context) + self.addClaudeSwapMenuCards(to: menu, context: context) return false } @@ -689,10 +679,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) @@ -707,7 +698,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)", diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index ae3335e4c3..1196619300 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) @@ -101,6 +102,8 @@ 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, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 1b0502ed50..d7429d696e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -153,7 +153,7 @@ final class UsageStore { var claudeSwapDetectedVersion: String? var claudeSwapRevision: UInt64 = 0 @ObservationIgnored var claudeSwapRefreshTask: Task? - @ObservationIgnored var claudeSwapVersionProbedPath: String? + @ObservationIgnored var claudeSwapTransientState = ClaudeSwapTransientState() var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index 77a2f872cf..058befb693 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -148,6 +148,29 @@ public enum SubprocessRunner { // MARK: - Public API + /// Runs a process to natural exit without letting caller cancellation or a + /// forced timeout interrupt an external mutation after launch. + public static func runToCompletion( + binary: String, + arguments: [String], + environment: [String: String], + currentDirectoryURL: URL? = nil, + acceptsNonZeroExit: Bool = false, + label: String) async throws -> SubprocessResult + { + let task = Task.detached(priority: .userInitiated) { + try await self.run( + binary: binary, + arguments: arguments, + environment: environment, + timeout: .infinity, + currentDirectoryURL: currentDirectoryURL, + acceptsNonZeroExit: acceptsNonZeroExit, + label: label) + } + return try await task.value + } + public static func run( binary: String, arguments: [String], @@ -208,15 +231,21 @@ public enum SubprocessRunner { } let killedByTimeout = KillFlag() - let timeoutTimer = DispatchSource.makeTimerSource(queue: self.timeoutQueue) - timeoutTimer.schedule(deadline: .now() + self.timeoutInterval(timeout)) - timeoutTimer.setEventHandler { - guard process.isRunning else { return } - killedByTimeout.set() - self.terminateProcess(process, processGroup: processGroup) + let timeoutTimerBox: TimeoutTimer? = if timeout.isFinite { + { + let timeoutTimer = DispatchSource.makeTimerSource(queue: self.timeoutQueue) + timeoutTimer.schedule(deadline: .now() + self.timeoutInterval(timeout)) + timeoutTimer.setEventHandler { + guard process.isRunning else { return } + killedByTimeout.set() + self.terminateProcess(process, processGroup: processGroup) + } + timeoutTimer.resume() + return TimeoutTimer(timer: timeoutTimer) + }() + } else { + nil } - timeoutTimer.resume() - let timeoutTimerBox = TimeoutTimer(timer: timeoutTimer) do { let exitCode = try await withTaskCancellationHandler { @@ -225,10 +254,10 @@ public enum SubprocessRunner { try Task.checkCancellation() return code } onCancel: { - timeoutTimerBox.cancel() + timeoutTimerBox?.cancel() self.terminateProcess(process, processGroup: processGroup) } - timeoutTimerBox.cancel() + timeoutTimerBox?.cancel() let duration = Date().timeIntervalSince(start) // Race guard: the timeout timer may kill the process just before the diff --git a/Sources/CodexBarCore/ProviderAccountSnapshot.swift b/Sources/CodexBarCore/ProviderAccountSnapshot.swift index 0a5e44ebce..6a569c1b6c 100644 --- a/Sources/CodexBarCore/ProviderAccountSnapshot.swift +++ b/Sources/CodexBarCore/ProviderAccountSnapshot.swift @@ -26,6 +26,9 @@ public struct ProviderAccountUsageSnapshot: Identifiable, Sendable { /// responsible for privacy redaction. Never logged or persisted. public let displayLabel: String public let isActive: Bool + /// Whether the source can make this inactive account the provider's active account. + /// Activation remains source-owned; CodexBar never handles credential material. + public let canActivate: Bool public let snapshot: UsageSnapshot? public let error: String? public let sourceLabel: String? @@ -35,6 +38,7 @@ public struct ProviderAccountUsageSnapshot: Identifiable, Sendable { provider: UsageProvider, displayLabel: String, isActive: Bool, + canActivate: Bool = false, snapshot: UsageSnapshot?, error: String?, sourceLabel: String?) @@ -43,6 +47,7 @@ public struct ProviderAccountUsageSnapshot: Identifiable, Sendable { self.provider = provider self.displayLabel = displayLabel self.isActive = isActive + self.canActivate = canActivate self.snapshot = snapshot self.error = error self.sourceLabel = sourceLabel diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift index 1b060dade4..6fa1366ea0 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountProjection.swift @@ -23,14 +23,21 @@ public enum ClaudeSwapAccountProjection { provider: .claude, displayLabel: self.displayLabel(for: row), isActive: row.isActive, + canActivate: !row.isActive && self.canActivate(row), snapshot: self.usageSnapshot(for: row, now: now), error: self.errorText(for: row), sourceLabel: self.sourceLabel) } } - public static func displayError(accountError: String?, adapterError: String?) -> String? { - accountError ?? adapterError.map { "Showing the last successful update: \($0)" } + public static func displayError( + accountError: String?, + adapterError: String?, + switchError: String? = nil) -> String? + { + switchError.map { "Account switch failed: \($0)" } + ?? accountError + ?? adapterError.map { "Showing the last successful update: \($0)" } } static func displayLabel(for row: ClaudeSwapAccountRow) -> String { @@ -83,4 +90,13 @@ public enum ClaudeSwapAccountProjection { "Unrecognized claude-swap status: \(raw)" } } + + private static func canActivate(_ row: ClaudeSwapAccountRow) -> Bool { + switch row.usageStatus { + case .ok, .apiKey, .unavailable: + true + case .tokenExpired, .keychainUnavailable, .noCredentials, .unknown: + false + } + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountReader.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountReader.swift index 128b90bb07..5896400c06 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountReader.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountReader.swift @@ -15,13 +15,13 @@ public enum ClaudeSwapAccountReaderError: LocalizedError, Sendable { } } -/// Read-only adapter over the external `claude-swap` executable. +/// Bounded adapter over the external `claude-swap` executable. /// -/// Executes exactly `cswap --list --json` (never a shell, never config-defined -/// passthrough arguments) with a bounded runtime and bounded output, per the -/// Phase 1 contract in `docs/claude-multi-account-and-status-items.md`. CodexBar -/// never reads claude-swap or Claude Code credential storage; the subprocess is -/// solely responsible for its own credential access. +/// Executes only the fixed list and explicit switch argument arrays (never a +/// shell or config-defined passthrough arguments), per the contracts in +/// `docs/claude-multi-account-and-status-items.md`. CodexBar never reads +/// claude-swap or Claude Code credential storage; the subprocess is solely +/// responsible for its own credential access. public enum ClaudeSwapAccountReader { public static let maxOutputBytes = 262_144 public static let defaultTimeout: TimeInterval = 30 @@ -42,6 +42,32 @@ public enum ClaudeSwapAccountReader { return try ClaudeSwapListParser.parse(Data(result.utf8)) } + /// Activates one source-issued numeric slot through claude-swap. + /// + /// The external tool owns the credential transaction. CodexBar supplies no + /// credentials and accepts no config-defined arguments. + public static func switchAccount( + executablePath: String, + accountNumber: Int) async throws + -> ClaudeSwapAccountSwitchResult + { + guard accountNumber > 0 else { + throw ClaudeSwapSwitchParserError.malformedShape("requested account slot must be positive") + } + let result = try await self.runCredentialTransaction( + executablePath: executablePath, + arguments: ["--switch-to", String(accountNumber), "--json"], + acceptsNonZeroExit: true, + label: "claude-swap switch") + let parsed = try ClaudeSwapSwitchParser.parse(Data(result.utf8)) + guard parsed.toAccountNumber == accountNumber else { + throw ClaudeSwapSwitchParserError.mismatchedTarget( + expected: accountNumber, + actual: parsed.toAccountNumber) + } + return parsed + } + /// Best-effort version probe (`cswap --version` prints `cswap `). public static func readVersion( executablePath: String, @@ -91,4 +117,25 @@ public enum ClaudeSwapAccountReader { } return result.stdout } + + /// Once launched, a credential mutation must reach the external tool's + /// natural exit; caller cancellation and read-probe timeouts must not kill it. + private static func runCredentialTransaction( + executablePath: String, + arguments: [String], + acceptsNonZeroExit: Bool, + label: String) async throws -> String + { + let binary = try self.resolvedExecutablePath(executablePath) + let result = try await SubprocessRunner.runToCompletion( + binary: binary, + arguments: arguments, + environment: ProcessInfo.processInfo.environment, + acceptsNonZeroExit: acceptsNonZeroExit, + label: label) + guard result.stdout.utf8.count <= self.maxOutputBytes else { + throw ClaudeSwapAccountReaderError.outputTooLarge(byteCount: result.stdout.utf8.count) + } + return result.stdout + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountSwitch.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountSwitch.swift new file mode 100644 index 0000000000..de539ed3d7 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSwap/ClaudeSwapAccountSwitch.swift @@ -0,0 +1,117 @@ +import CoreFoundation +import Foundation + +/// Allow-listed result of `cswap --switch-to --json` (schema v1). +public struct ClaudeSwapAccountSwitchResult: Equatable, Sendable { + public let switched: Bool + public let fromAccountNumber: Int? + public let toAccountNumber: Int + public let reason: String + + public init(switched: Bool, fromAccountNumber: Int?, toAccountNumber: Int, reason: String) { + self.switched = switched + self.fromAccountNumber = fromAccountNumber + self.toAccountNumber = toAccountNumber + self.reason = reason + } +} + +public enum ClaudeSwapSwitchParserError: LocalizedError, Equatable, Sendable { + case notJSONObject + case missingSchemaVersion + case unsupportedSchemaVersion(Int) + case reportedError(type: String, message: String) + case malformedShape(String) + case mismatchedTarget(expected: Int, actual: Int) + + public var errorDescription: String? { + switch self { + case .notJSONObject: + "claude-swap returned switch output that is not a JSON object." + case .missingSchemaVersion: + "claude-swap switch output has no schemaVersion field." + case let .unsupportedSchemaVersion(version): + "claude-swap switch output uses unsupported schema version \(version); CodexBar supports version 1." + case let .reportedError(type, message): + "claude-swap reported \(type): \(message)" + case let .malformedShape(details): + "claude-swap switch output is malformed: \(details)" + case let .mismatchedTarget(expected, actual): + "claude-swap reported account slot \(actual) after CodexBar requested slot \(expected)." + } + } +} + +public enum ClaudeSwapSwitchParser { + public static func parse(_ data: Data) throws -> ClaudeSwapAccountSwitchResult { + let raw: Any + do { + raw = try JSONSerialization.jsonObject(with: data) + } catch { + throw ClaudeSwapSwitchParserError.notJSONObject + } + guard let object = raw as? [String: Any] else { + throw ClaudeSwapSwitchParserError.notJSONObject + } + guard let schemaVersion = self.integer(object["schemaVersion"]) else { + throw ClaudeSwapSwitchParserError.missingSchemaVersion + } + guard schemaVersion == ClaudeSwapListParser.supportedSchemaVersion else { + throw ClaudeSwapSwitchParserError.unsupportedSchemaVersion(schemaVersion) + } + if let errorObject = object["error"] as? [String: Any] { + throw ClaudeSwapSwitchParserError.reportedError( + type: errorObject["type"] as? String ?? "Error", + message: errorObject["message"] as? String ?? "unknown error") + } + guard let switched = object["switched"] as? Bool else { + throw ClaudeSwapSwitchParserError.malformedShape("missing switched flag") + } + guard let reason = (object["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !reason.isEmpty + else { + throw ClaudeSwapSwitchParserError.malformedShape("missing reason") + } + let fromAccountNumber = try self.accountNumber( + in: object["from"], + field: "from", + allowsNull: true) + guard let toAccountNumber = try self.accountNumber( + in: object["to"], + field: "to", + allowsNull: false) + else { + throw ClaudeSwapSwitchParserError.malformedShape("to account has no numeric slot") + } + return ClaudeSwapAccountSwitchResult( + switched: switched, + fromAccountNumber: fromAccountNumber, + toAccountNumber: toAccountNumber, + reason: reason) + } + + private static func accountNumber(in raw: Any?, field: String, allowsNull: Bool) throws -> Int? { + if raw is NSNull, allowsNull { return nil } + guard let account = raw as? [String: Any] else { + throw ClaudeSwapSwitchParserError.malformedShape("missing \(field) account") + } + guard let rawNumber = account["number"] else { + throw ClaudeSwapSwitchParserError.malformedShape("\(field) account has no number") + } + if rawNumber is NSNull, allowsNull { return nil } + guard let number = self.integer(rawNumber), number > 0 + else { + throw ClaudeSwapSwitchParserError.malformedShape("\(field) account number is not a positive slot") + } + return number + } + + private static func integer(_ raw: Any?) -> Int? { + guard let number = raw as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { + return nil + } + return raw as? Int + } +} diff --git a/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift index acb45b0dc7..f9c35ca98f 100644 --- a/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift +++ b/Tests/CodexBarTests/ClaudeProviderRuntimeTests.swift @@ -55,6 +55,128 @@ struct ClaudeProviderRuntimeTests { #expect(store.claudeSwapLastRefreshAt == nil) } + @Test + func `explicit account activation is serialized through claude swap and refreshes Claude`() async throws { + let (settings, store) = self.makeStore() + let marker = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-swap-switch-args-\(UUID().uuidString)") + let executable = try self.makeSwitchExecutable(marker: marker) + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = executable + settings.claudeSwapEnabled = true + let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2") + store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot( + id: accountID, + provider: .claude, + displayLabel: "switch@example.com", + isActive: false, + canActivate: true, + snapshot: nil, + error: nil, + sourceLabel: ClaudeSwapAccountProjection.sourceLabel)] + var refreshedProviders: [UsageProvider] = [] + store._test_providerRefreshOverride = { refreshedProviders.append($0) } + defer { store._test_providerRefreshOverride = nil } + + store.switchClaudeSwapAccount(accountID) + let task = try #require(store.claudeSwapTransientState.task) + await task.value + + let arguments = try String(contentsOf: marker, encoding: .utf8) + #expect(arguments == "--switch-to\n2\n--json\n") + #expect(refreshedProviders == [.claude]) + #expect(store.claudeSwapTransientState.task == nil) + #expect(store.claudeSwapTransientState.switchingAccountID == nil) + #expect(store.claudeSwapTransientState.lastError == nil) + #expect(store.claudeSwapTransientState.lastErrorAccountID == nil) + } + + @Test + func `non actionable account cannot start credential transaction`() throws { + let (settings, store) = self.makeStore() + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = "/path/to/cswap" + settings.claudeSwapEnabled = true + let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2") + store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot( + id: accountID, + provider: .claude, + displayLabel: "expired@example.com", + isActive: false, + canActivate: false, + snapshot: nil, + error: "Token expired", + sourceLabel: ClaudeSwapAccountProjection.sourceLabel)] + + store.switchClaudeSwapAccount(accountID) + + #expect(store.claudeSwapTransientState.task == nil) + } + + @Test + func `failed activation stays scoped to its requested account`() async throws { + let (settings, store) = self.makeStore() + let executable = try self.makeFailedSwitchExecutable() + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = executable + settings.claudeSwapEnabled = true + let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2") + store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot( + id: accountID, + provider: .claude, + displayLabel: "switch@example.com", + isActive: false, + canActivate: true, + snapshot: nil, + error: nil, + sourceLabel: ClaudeSwapAccountProjection.sourceLabel)] + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + + store.switchClaudeSwapAccount(accountID) + let task = try #require(store.claudeSwapTransientState.task) + await task.value + + #expect(store.claudeSwapTransientState.lastError?.contains("credentials missing") == true) + #expect(store.claudeSwapTransientState.lastErrorAccountID == accountID) + } + + @Test + func `configuration change during provider refresh discards switch result`() async throws { + let (settings, store) = self.makeStore() + let executable = try self.makeFailedSwitchExecutable() + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + settings.claudeSwapExecutablePath = executable + settings.claudeSwapEnabled = true + let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2") + store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot( + id: accountID, + provider: .claude, + displayLabel: "switch@example.com", + isActive: false, + canActivate: true, + snapshot: nil, + error: nil, + sourceLabel: ClaudeSwapAccountProjection.sourceLabel)] + store._test_providerRefreshOverride = { _ in + settings.claudeSwapExecutablePath = "/new/path/to/cswap" + } + defer { store._test_providerRefreshOverride = nil } + + store.switchClaudeSwapAccount(accountID) + let task = try #require(store.claudeSwapTransientState.task) + await task.value + + #expect(store.claudeSwapTransientState.task == nil) + #expect(store.claudeSwapTransientState.switchingAccountID == nil) + #expect(store.claudeSwapTransientState.lastError == nil) + #expect(store.claudeSwapTransientState.lastErrorAccountID == nil) + } + private func makeStore() -> (SettingsStore, UsageStore) { let suite = "ClaudeProviderRuntimeTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -104,4 +226,34 @@ struct ClaudeProviderRuntimeTests { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) return url.path } + + private func makeSwitchExecutable(marker: URL) throws -> String { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-switch-runtime-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("cswap") + let script = """ + #!/bin/sh + printf '%s\n' "$@" > '\(marker.path)' + echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":2},"reason":"switched"}' + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + + private func makeFailedSwitchExecutable() throws -> String { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-failed-switch-runtime-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("cswap") + let script = """ + #!/bin/sh + echo '{"schemaVersion":1,"error":{"type":"SwitchError","message":"credentials missing"}}' + exit 1 + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } } diff --git a/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift index 38ff33675f..8da5a1b566 100644 --- a/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift +++ b/Tests/CodexBarTests/ClaudeSwapAccountProjectionTests.swift @@ -11,6 +11,14 @@ struct ClaudeSwapAccountProjectionTests { #expect(ClaudeSwapAccountProjection.displayError( accountError: "Token expired.", adapterError: "timed out") == "Token expired.") + #expect(ClaudeSwapAccountProjection.displayError( + accountError: nil, + adapterError: "timed out", + switchError: "store locked") == "Account switch failed: store locked") + #expect(ClaudeSwapAccountProjection.displayError( + accountError: "API-key account", + adapterError: nil, + switchError: "store locked") == "Account switch failed: store locked") } private let now = Date(timeIntervalSince1970: 1_782_000_000) @@ -45,6 +53,7 @@ struct ClaudeSwapAccountProjectionTests { #expect(active.provider == .claude) #expect(active.displayLabel == "personal@example.com") #expect(active.isActive == true) + #expect(active.canActivate == false) #expect(active.error == nil) #expect(active.sourceLabel == "claude-swap") #expect(active.snapshot?.primary?.usedPercent == 80) @@ -57,6 +66,7 @@ struct ClaudeSwapAccountProjectionTests { let inactive = try #require(snapshots.last) #expect(inactive.id.opaqueID == "1") #expect(inactive.isActive == false) + #expect(inactive.canActivate == true) #expect(inactive.snapshot?.primary?.resetsAt == reset) #expect(inactive.snapshot?.secondary?.usedPercent == 16.5) #expect(inactive.snapshot?.secondary?.windowMinutes == 10080) @@ -90,6 +100,8 @@ struct ClaudeSwapAccountProjectionTests { #expect(snapshot.snapshot == nil) let error = try #require(snapshot.error) #expect(error.contains(entry.1)) + let expectedCanActivate = entry.0 == .apiKey || entry.0 == .unavailable + #expect(snapshot.canActivate == expectedCanActivate) } } diff --git a/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift b/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift index 33d7bdd254..4870bcfae3 100644 --- a/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift +++ b/Tests/CodexBarTests/ClaudeSwapAccountReaderTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import CodexBarCore -/// Reader tests use fake executables only, per the Phase 1 contract: no real +/// Reader tests use fake executables only: no real /// claude-swap install, no credentials, no Keychain access. struct ClaudeSwapAccountReaderTests { private func makeFakeExecutable(_ script: String) throws -> String { @@ -91,6 +91,72 @@ struct ClaudeSwapAccountReaderTests { #expect(version == "0.16.0") } + @Test + func `switches only by validated numeric slot with fixed arguments`() async throws { + let path = try self.makeFakeExecutable(""" + [ "$1" = "--switch-to" ] || exit 2 + [ "$2" = "7" ] || exit 2 + [ "$3" = "--json" ] || exit 2 + [ -z "$4" ] || exit 2 + echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":7},"reason":"switched"}' + """) + + let result = try await ClaudeSwapAccountReader.switchAccount( + executablePath: path, + accountNumber: 7) + + #expect(result.switched) + #expect(result.fromAccountNumber == 1) + #expect(result.toAccountNumber == 7) + } + + @Test + func `rejects switch result for another slot`() async throws { + let path = try self.makeFakeExecutable(""" + echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":8},"reason":"switched"}' + """) + + await #expect(throws: ClaudeSwapSwitchParserError.mismatchedTarget(expected: 7, actual: 8)) { + try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 7) + } + } + + @Test + func `surfaces switch error envelope from non zero exit`() async throws { + let path = try self.makeFakeExecutable(""" + echo '{"schemaVersion":1,"error":{"type":"SwitchError","message":"credentials missing"}}' + exit 1 + """) + + await #expect(throws: ClaudeSwapSwitchParserError.reportedError( + type: "SwitchError", + message: "credentials missing")) + { + try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 2) + } + } + + @Test + func `started credential switch reaches natural exit after caller cancellation`() async throws { + let marker = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-swap-switch-finished-\(UUID().uuidString)") + let path = try self.makeFakeExecutable(""" + sleep 0.3 + touch '\(marker.path)' + echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":2},"reason":"switched"}' + """) + let task = Task { + try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 2) + } + + try await Task.sleep(for: .milliseconds(100)) + task.cancel() + let result = try await task.value + + #expect(result.switched) + #expect(FileManager.default.fileExists(atPath: marker.path)) + } + @Test func `version probe returns nil when the executable fails`() async throws { let path = try self.makeFakeExecutable("exit 3") diff --git a/Tests/CodexBarTests/ClaudeSwapSwitchParserTests.swift b/Tests/CodexBarTests/ClaudeSwapSwitchParserTests.swift new file mode 100644 index 0000000000..b06982d037 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSwapSwitchParserTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeSwapSwitchParserTests { + private func parse(_ json: String) throws -> ClaudeSwapAccountSwitchResult { + try ClaudeSwapSwitchParser.parse(Data(json.utf8)) + } + + @Test + func `parses direct switch result without retaining display identity`() throws { + let result = try self.parse(""" + { + "schemaVersion": 1, + "switched": true, + "from": {"number": 1, "email": "old@example.com"}, + "to": {"number": 2, "email": "new@example.com"}, + "strategy": "direct", + "reason": "switched", + "message": "Switched", + "warnings": [] + } + """) + + #expect(result == ClaudeSwapAccountSwitchResult( + switched: true, + fromAccountNumber: 1, + toAccountNumber: 2, + reason: "switched")) + } + + @Test + func `accepts unmanaged source and already active no op`() throws { + let freshActivation = try self.parse(""" + {"schemaVersion":1,"switched":true,"from":null, + "to":{"number":2},"reason":"switched"} + """) + #expect(freshActivation.fromAccountNumber == nil) + #expect(freshActivation.toAccountNumber == 2) + + let unmanaged = try self.parse(""" + {"schemaVersion":1,"switched":true,"from":{"number":null}, + "to":{"number":3},"reason":"switched"} + """) + #expect(unmanaged.fromAccountNumber == nil) + #expect(unmanaged.toAccountNumber == 3) + + let active = try self.parse(""" + {"schemaVersion":1,"switched":false,"from":{"number":3}, + "to":{"number":3},"reason":"already-active"} + """) + #expect(active.switched == false) + #expect(active.reason == "already-active") + } + + @Test + func `surfaces switch error envelope`() { + #expect(throws: ClaudeSwapSwitchParserError.reportedError( + type: "SwitchError", + message: "store locked")) + { + try self.parse(""" + {"schemaVersion":1,"error":{"type":"SwitchError","message":"store locked"}} + """) + } + } + + @Test + func `rejects malformed or unsupported switch results`() { + #expect(throws: ClaudeSwapSwitchParserError.notJSONObject) { + try self.parse("not json") + } + #expect(throws: ClaudeSwapSwitchParserError.missingSchemaVersion) { + try self.parse(#"{"switched":true}"#) + } + #expect(throws: ClaudeSwapSwitchParserError.missingSchemaVersion) { + try self.parse(#"{"schemaVersion":true}"#) + } + #expect(throws: ClaudeSwapSwitchParserError.unsupportedSchemaVersion(2)) { + try self.parse(#"{"schemaVersion":2}"#) + } + #expect(throws: ClaudeSwapSwitchParserError.malformedShape("missing switched flag")) { + try self.parse(#"{"schemaVersion":1}"#) + } + #expect(throws: (any Error).self) { + try self.parse(""" + {"schemaVersion":1,"switched":true,"from":{"number":1}, + "to":{"number":true},"reason":"switched"} + """) + } + } +} diff --git a/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift index e86eb237d1..9848354b77 100644 --- a/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift +++ b/Tests/CodexBarTests/MenuCardClaudeSwapAccountTests.swift @@ -7,7 +7,10 @@ import Testing /// projection renders as a regular Claude usage card with session/weekly /// windows, account identity, and Hide Personal Info redaction. struct MenuCardClaudeSwapAccountTests { - private func makeModel(hidePersonalInfo: Bool) throws -> UsageMenuCardView.Model { + private func makeModel( + hidePersonalInfo: Bool, + planOverride: String? = nil) throws -> UsageMenuCardView.Model + { let now = Date(timeIntervalSince1970: 1_782_000_000) let metadata = try #require(ProviderDefaults.metadata[.claude]) let list = ClaudeSwapAccountList( @@ -34,6 +37,7 @@ struct MenuCardClaudeSwapAccountTests { tokenSnapshot: nil, tokenError: nil, account: AccountInfo(email: account.displayLabel, plan: nil), + planOverride: planOverride, isRefreshing: false, lastError: account.error, usageBarsShowUsed: true, @@ -44,6 +48,13 @@ struct MenuCardClaudeSwapAccountTests { now: now)) } + @Test + func `claude swap action overrides adapter login method`() throws { + let model = try self.makeModel(hidePersonalInfo: false, planOverride: "Switch Account...") + + #expect(model.planText == "Switch Account...") + } + @Test func `claude swap account snapshot renders session and weekly metrics with identity`() throws { let model = try self.makeModel(hidePersonalInfo: false) diff --git a/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift index 27a51d899b..b6a061bfd0 100644 --- a/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift +++ b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift @@ -43,6 +43,7 @@ struct MenuCardOverrideIsolationTests { accountOverride: AccountInfo(email: "account@example.com", plan: nil))) #expect(model.tokenUsage == nil) + #expect(model.email == "account@example.com") } @Test diff --git a/docs/claude-multi-account-and-status-items.md b/docs/claude-multi-account-and-status-items.md index c0831fd4c3..e188ce7cb0 100644 --- a/docs/claude-multi-account-and-status-items.md +++ b/docs/claude-multi-account-and-status-items.md @@ -8,8 +8,7 @@ read_when: # Claude multi-account and status item decision -Status: **Phase 1 design accepted; do not merge an implementation until its independent adapter and model proof is -complete.** +Status: **Phase 1 account display implemented; Phase 2 explicit account activation accepted.** Related: [#1756](https://github.com/steipete/CodexBar/issues/1756), [#1268](https://github.com/steipete/CodexBar/issues/1268), and the bounded Claude sign-in repair in @@ -17,12 +16,12 @@ Related: [#1756](https://github.com/steipete/CodexBar/issues/1756), ## Accepted direction -1. Add an opt-in, read-only `claude-swap` adapter as the first Claude subscription multi-account source. +1. Use an opt-in `claude-swap` adapter as the first Claude subscription multi-account source. 2. Normalize its results behind a provider-neutral account snapshot before adding any status item UI. 3. Make per-account status items opt-in, replace the provider item for that provider, cap selection at four, and keep them mutually exclusive with Merge Icons. -4. Defer account switching. A later phase may invoke `cswap --switch-to --json` only after a separate product - decision and explicit user action. +4. Allow an explicit click on an inactive account card to invoke exactly `cswap --switch-to --json`. Keep + automatic switching and session launching out of scope. This solves the durable OAuth refresh problem without making CodexBar a second credential vault. It also avoids a Claude-only status item implementation that would need to be redesigned for Codex and other providers. @@ -57,16 +56,15 @@ Keychain and prompt behavior. The safer seam is a credential-free usage adapter | Option | Credential ownership | Durability | Risk | Recommendation | | --- | --- | --- | --- | --- | | First-party OAuth account vault | CodexBar | High | New login, refresh, storage, revocation, migration, and security surface | Defer | -| Read-only `claude-swap` adapter | `claude-swap` | High | External executable and schema dependency | **Phase 1** | +| Bounded `claude-swap` adapter | `claude-swap` | High | External executable and schema dependency | **Phase 1–2** | | Discover Claude Code Keychain entries | Claude Code / ambiguous | Unknown | Undocumented enumeration; prompt and identity hazards | Reject | | Existing token accounts | CodexBar config | Low for OAuth | Access token expires without refresh metadata | Keep for current cookie/API-key uses | -As of [`claude-swap` v0.16.0](https://github.com/realiti4/claude-swap/releases/tag/v0.16.0), +As of [`claude-swap` v0.18.0](https://github.com/realiti4/claude-swap/releases/tag/v0.18.0), `cswap --list --json` still returns a versioned object with `schemaVersion: 1`, an active account number, account slots, redaction-sensitive email labels, 5-hour and 7-day usage percentages, and reset timestamps. Handled failures return an -error object and non-zero exit. v0.16.0 also adds mutation-capable automatic switching; that does not expand this -integration. CodexBar does not need `--token-status`, credential files, Keychain access, or raw OAuth values for the -display-only phase. +error object and non-zero exit. Direct switching returns the same versioned envelope. CodexBar does not need +`--token-status`, credential files, Keychain access, or raw OAuth values for display or explicit activation. ## Phase 1 adapter contract @@ -88,6 +86,20 @@ display-only phase. The executable is an optional external dependency, not a bundled component. Preferences should show detected version, last refresh, adapter errors, and a link to the upstream project; CodexBar should not install or update it. +## Phase 2 explicit activation contract + +- Only an explicit click on an inactive, actionable account card can start a switch. +- Derive the numeric slot from the already validated account snapshot and execute exactly + `cswap --switch-to --json`; never accept free-form arguments or invoke a shell. +- Serialize switches, validate `schemaVersion == 1` and the returned target slot, and bound captured output. +- Once launched, let the external credential transaction reach its natural exit without forced timeout or + cancellation. If the adapter setting changes, hide its UI state and discard its result when the original + configuration is no longer current. +- Refresh ambient Claude usage and the adapter account list after completion. Show switch errors independently from + list-refresh errors and preserve the last successful usage snapshots. +- Keep expired, missing, unknown, and Keychain-inaccessible credential slots non-actionable. Never auto-switch, launch + sessions, add/import/export/purge accounts, or mutate credentials directly. + ## Provider-neutral account model Introduce one projection used by menus and status items rather than teaching status item code about Claude OAuth: @@ -98,6 +110,7 @@ struct ProviderAccountUsageSnapshot: Identifiable { let provider: UsageProvider let displayLabel: String let isActive: Bool + let canActivate: Bool let snapshot: UsageSnapshot? let error: String? let sourceLabel: String? @@ -147,14 +160,15 @@ No real credential, browser session, or provider call was used. ## Accepted decisions -1. The optional external `claude-swap` dependency is accepted only for exact read-only `cswap --list --json` execution. -2. Switching, automatic switching, account mutation, and session launching stay out of Phase 1. +1. The optional external `claude-swap` dependency is accepted for exact `cswap --list --json` execution and explicit + `cswap --switch-to --json` activation. +2. Automatic switching, account add/import/export/purge, and session launching stay out of scope. 3. Provider-neutral account snapshots land before any per-account status item work. 4. Per-account status items are capped at four and mutually exclusive with Merge Icons. 5. Status item labels use aliases or privacy-safe ordinals, never email identity. -Any change to these decisions requires a new product/auth review before implementation because it changes storage, -status item migration, process authority, or the credential boundary. +Any further change to these decisions requires a new product/auth review before implementation because it changes +storage, status item migration, process authority, or the credential boundary. ## Implementation and validation sequence @@ -163,6 +177,7 @@ status item migration, process authority, or the credential boundary. 2. Add the opt-in adapter and provider-neutral projection. Verify no credential reads and no impact on ambient Claude. 3. Add settings-state and menu-model tests. Keep AppKit status item creation out of headless tests. 4. Add status item identity/migration tests, then implement account items behind the opt-in setting. -5. Run focused tests, `make check`, `make test`, packaged synthetic proof, and macOS UI proof with redacted fixtures. +5. Add exact-argv, strict switch-result, serialization, and refresh tests using a fake executable only. +6. Run focused tests, `make check`, `make test`, packaged synthetic proof, and macOS UI proof with redacted fixtures. -No credential import, account mutation, or compatibility shim is part of this proposal. +No credential import, automatic switching, session launching, or compatibility shim is part of this proposal. diff --git a/docs/claude.md b/docs/claude.md index aa1434e720..33a4710d2d 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -112,9 +112,9 @@ Admin API key setup: - Extra usage spend/limit (if enabled). - Account email + inferred plan. -## claude-swap accounts (opt-in, read-only) +## claude-swap accounts (opt-in) -Phase 1 of the accepted multi-account design in +The accepted multi-account design in [claude-multi-account-and-status-items.md](claude-multi-account-and-status-items.md). - Setup: Preferences → Providers → Claude → "Read accounts from claude-swap", then set the path to the @@ -130,7 +130,12 @@ Phase 1 of the accepted multi-account design in stale data, surface the error in provider settings, and never affect the ambient Claude usage card. - Sentinel statuses (`token_expired`, `api_key`, `keychain_unavailable`, `no_credentials`, `unavailable`) render as per-account notes instead of usage bars. -- Account switching is intentionally out of scope; use `cswap` directly to switch accounts. +- Switching: an inactive account with usable source credentials shows “Switch Account…”. Clicking it runs exactly + `cswap --switch-to --json`, validates the versioned result and requested slot, then refreshes both ambient + Claude usage and every claude-swap account card. Switches are serialized; no automatic switching occurs. +- Expired, missing, unknown, or Keychain-inaccessible credentials stay non-actionable. A failed switch remains visible + on that account without discarding its last successful usage. A running Claude Code process can take up to the + claude-swap Keychain cache interval to observe the new account. - When multiple claude-swap accounts are available, they take explicit precedence over Claude token-account presentation (stacked cards and the segmented switcher). From 10377051d09e8a4c217163dc3b2dacfbd6d11a71 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 23:34:28 +0100 Subject: [PATCH 069/105] chore: prepare 0.40.0 release --- CHANGELOG.md | 5 ++++- version.env | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e42c9474c6..05ccce98eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ # Changelog -## 0.39.1 — Unreleased +## 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! @@ -13,6 +14,7 @@ ### 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. @@ -21,6 +23,7 @@ - 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! diff --git a/version.env b/version.env index 0a44da7e14..f9fe5f970f 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.39.1 -BUILD_NUMBER=98 +MARKETING_VERSION=0.40.0 +BUILD_NUMBER=99 From 215efbd39ad58acb4b0cd637c407d63e6183bc8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 23:42:41 +0100 Subject: [PATCH 070/105] fix: use passwordless release keychain --- .mac-release.env | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.mac-release.env b/.mac-release.env index 16dfc02adf..cd85737a53 100644 --- a/.mac-release.env +++ b/.mac-release.env @@ -22,10 +22,11 @@ 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' From a3454640cf5349fdb50f151bf989622c982557ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 00:10:16 +0100 Subject: [PATCH 071/105] docs: update appcast for 0.40.0 --- appcast.xml | 116 ++++++++++++++++++---------------------------------- 1 file changed, 40 insertions(+), 76 deletions(-) diff --git a/appcast.xml b/appcast.xml index febccdf2ca..55e9249cb7 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,46 @@ CodexBar + + 0.40.0 + Mon, 06 Jul 2026 00:10:16 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 99 + 0.40.0 + 14.0 + CodexBar 0.40.0 +

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!
  • +
+

View full changelog

+]]> + +
0.39.0 Sat, 04 Jul 2026 13:01:10 -0700 @@ -82,82 +122,6 @@ ]]> - - 0.38.0 - Fri, 03 Jul 2026 04:18:13 -0700 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 94 - 0.38.0 - 14.0 - CodexBar 0.38.0 -

Added

-
    -
  • Doubao: add signed Volcengine AK/SK support for Coding Plan session, weekly, and monthly usage. Thanks @LeoLin990405!
  • -
  • CrossModel: add API-key wallet balance and UTC daily, weekly, and monthly spend tracking. Thanks @hujuncheng!
  • -
  • Localization: complete Traditional Chinese provider and menu coverage, and route remaining provider UI copy through localized formatters. Thanks @jack24254029!
  • -
  • Menu: add an opt-in setting to refresh provider usage whenever the menu opens without changing the periodic refresh clock. Thanks @dstier-git!
  • -
  • Qoder: add big-model credit usage from qoder.com and qoder.com.cn browser sessions or manual cookies. Thanks @Yuxin-Qiao!
  • -
  • Quota warnings: add an optional centered on-screen text alert that stays click-through and does not steal focus. Thanks @SAASEmpiree!
  • -
  • Sakana AI: add manual-cookie usage for five-hour and weekly quota windows. Thanks @LeoLin990405!
  • -
  • Status pages: show live component submenus for Claude, Codex, and Augment. Thanks @elijahfriedman!
  • -
  • Cost history: choose inline, submenu, or combined local-cost presentation. Thanks @Zihao-Qi!
  • -
  • Confetti: optionally celebrate session-limit resets with full-screen confetti, configurable beside the weekly-limit celebration in Advanced settings. Thanks @bystritskiy!
  • -
  • z.ai: support saved token-account team usage with account-scoped organization and project metadata. Thanks @zqbake!
  • -
  • CLI: show session pace in text output, expose derived pace data in JSON, and honor the configured weekly work-day baseline. Thanks @kmatsunami!
  • -
  • Claude: add a combined "Session + Weekly" menu bar metric that shows the 5-hour session and weekly lanes together (paced on the weekly lane), matching Codex, and classify lanes by cadence so a weekly-only account is not mislabeled as a session. Thanks @Shengqiang-Zhang!
  • -
-

Changed

-
    -
  • Settings: complete redesign as a System Settings-style window — a sidebar lists app panes plus every provider (search, drag reorder, status dots, enable via context menu), panes use native grouped forms, the window keeps one size instead of resizing per tab, and the last selected pane is remembered across launches.
  • -
  • Menu: group Plan Usage, Cost, and Storage rows so related account usage is easier to scan. Thanks @Zihao-Qi!
  • -
-

Fixed

-
    -
  • Usage refresh: refresh provider data shortly after known quota reset boundaries instead of leaving expired reset times visible until the next normal poll. Thanks @pavbar!
  • -
  • Settings: align General-pane controls, show compact installed terminal app icons, and enlarge the window to fit more options.
  • -
  • Sakana AI: parse server-rendered quota reset timestamps as UTC instead of device-local time (#1826). Thanks @ss251!
  • -
  • Cursor: hide misleading pace and run-out details once a billing-cycle quota is fully depleted. Thanks @Yuxin-Qiao!
  • -
  • Claude Education: treat subscription-only CLI responses as unavailable quotas, keep local cost data in menus and widgets, and suppress expected refresh cancellations (#1808).
  • -
  • Claude web usage: bound stale requests so Auto can reach CLI fallback instead of hanging indefinitely.
  • -
  • Claude history: keep OAuth utilization separate across account switches while preserving continuity through token refreshes.
  • -
  • Linux CLI: keep Claude OAuth usage subprocess-free, skip version probes, and let Auto bypass unsupported web sources. Thanks @derekszen!
  • -
  • Usage display: make Usage widgets follow the used-versus-remaining preference already shared by menus and Overview rows (#1738). Thanks @OlegLustenko and @FrancoLan!
  • -
  • OpenCode Go: keep rolling usage available when the dashboard omits the optional weekly window. Thanks @mohkg1017!
  • -
  • Menu bar: make Show most-used provider rank only providers selected for Overview. Thanks @dstier-git!
  • -
  • Codex: show expiring reset-credit availability even when optional credits and extra usage are hidden, while preserving CLI --no-credits. Thanks @simon-ami!
  • -
  • Claude CLI: prevent logged-out background Auto fallbacks from opening browser OAuth during app refresh. Thanks @afarwind!
  • -
  • Keychain prompts: explain that macOS handles password entry, surface the existing opt-out path, and link to troubleshooting before access begins (fixes #1681). Thanks @someshfengde and @Yuxin-Qiao!
  • -
  • Claude: use the dedicated Claude Code authentication command for sign-in, report its real exit status, and stop treating a browser URL as completed login (fixes #1715).
  • -
  • OpenAI API: explain that project service-account keys cannot read organization usage instead of surfacing a generic credit-balance HTTP 401 error (fixes #1792). Thanks @dhruv-anand-aintech!
  • -
  • Codex cost history: stop double-billing cached input and reprice stale Codex and Pi cache entries. Thanks @dstier-git!
  • -
  • Overview: render row selection on the GPU to keep trackpad scrolling smooth. Thanks @hhh2210!
  • -
  • Codex cost history: count cache reads separately, deduplicate active and archived sessions at row level, and preserve cached days across narrow refreshes. Thanks @kiranmagic7!
  • -
  • Pi cost history: price Codex cache reads once using their true context size. Thanks @kiranmagic7!
  • -
  • Menu bar: in the combined "Session + Weekly" metric (Codex and Claude), pair the 5-hour session usage with the weekly pace in pace and both display modes instead of showing the busier (most-constrained) lane's usage, which mislabeled the readout as weekly usage + weekly pace. Thanks @Shengqiang-Zhang!
  • -
  • Menu bar: in the combined "Session + Weekly" metric, ignore Claude web's synthetic 0% five-hour placeholder (emitted for accounts with no live session window but a real weekly lane) so the readout shows the weekly lane instead of a non-existent 5h 0%/5h 100% session.
  • -
  • Memory pressure: finish isolating utility-queue source reads from main-actor state to prevent the remaining callback crash. Thanks @Zihao-Qi!
  • -
  • Kiro: run account, usage, and context commands through a PTY so current CLI versions return usage without timing out. Thanks @sf-jin-ku!
  • -
  • OpenAI web: ignore stale profiles from removed browsers, discover registered installs outside standard app folders, and surface browser-profile access and cookie-load timeout diagnostics.
  • -
  • PTY probes: preserve Darwin device identifiers without crashing when Intel macOS reports signed values.
  • -
  • CLI server: collect /usage providers concurrently under finite per-provider deadlines so one hung provider degrades to its own error row without discarding healthy results. Thanks @enieuwy!
  • -
  • Privacy: hide account and team identity values without showing a Hidden placeholder or empty account rows. Thanks @Zihao-Qi!
  • -
  • Mistral: restore Vibe monthly-plan usage by forwarding only required console session cookies. Thanks @lfmundim!
  • -
  • Codex: show enterprise monthly credit limits across OAuth, CLI, menu, and widget surfaces. Thanks @ChenZiHong-Gavin!
  • -
  • Codex: avoid launching monthly-credit CLI enrichment during usage-only OAuth refreshes.
  • -
  • Usage display: keep positive values below one percent visible instead of rounding them to zero. Thanks @Max0633!
  • -
  • Menu bar: show pace as 0% instead of a signed +0% or -0% when the pace delta rounds to zero. Thanks @devYRPauli!
  • -
  • Menu: align the persistent Refresh row with native actions, keep Settings, About, and Quit keyboard-navigable, and use a narrower Usage Dashboard icon. Thanks @Zihao-Qi!
  • -
  • Menu: match the persistent Refresh symbol size, weight, and icon column to native action rows across standard and narrow provider menus. Thanks @micnem!
  • -
  • Claude: stop installed-version checks from invoking a login shell and triggering unwanted Keychain prompts. Thanks @enieuwy!
  • -
  • Localization: reject blank translated values and restore the affected Vietnamese provider prompts. Thanks @kiranmagic7!
  • -
  • Usage totals: keep Today tied to the current local calendar day across cost, Admin API, and Poe surfaces instead of showing the latest historical bucket. Thanks @Zihao-Qi!
  • -
  • Antigravity: align compact icons and automatic highest-usage selection with grouped Gemini and Claude/GPT 5-hour and weekly lanes while ignoring non-renderable cadences. Thanks @Yuxin-Qiao!
  • -
  • Antigravity CLI: reuse an authenticated user-launched agy server for faster, more reliable one-shot usage checks. Thanks @junmo-kim!
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From 89b8a506207c424527044d7aeb919b908ae44807 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 00:28:50 +0100 Subject: [PATCH 072/105] perf: reuse cached Codex cost aggregates (#1926) --- CHANGELOG.md | 2 +- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 1 + .../CostUsageScanner+CacheHelpers.swift | 186 ++++++++++-------- .../CostUsagePerformanceGateTests.swift | 165 +++++++++++++++- 5 files changed, 266 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ccce98eb..1677e45956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - 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. +- 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. - 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! diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index b4a5eea1d9..a25a9196e0 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "bda3b0f590bffb5f" + static let value = "d43faceb2100c02d" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 09517ba061..2515354d9d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -140,6 +140,7 @@ struct CostUsageFileUsage: Codable { var forkedFromId: String? var projectPath: String? var canonicalProjectPath: String? + var codexCostCacheComplete: Bool? var codexCostNanos: [String: [String: Int64]]? var codexPrioritySurchargeNanos: [String: [String: Int64]]? var codexStandardCostNanos: [String: [String: Int64]]? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 6543356d94..d5f78283df 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -8,19 +8,21 @@ import Darwin #endif extension CostUsageScanner { - static func codexRowsByDayModel( - cache: CostUsageCache, - range: CostUsageDayRange) -> [String: [String: [CodexUsageRow]]] - { - var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:] - for usage in cache.files.values { - for row in usage.codexRows ?? [] { - guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) - else { continue } - rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) - } + private final class CodexModelsDevCatalogResolver { + private var catalog: ModelsDevCatalog? + private let cacheRoot: URL? + + init(catalog: ModelsDevCatalog?, cacheRoot: URL?) { + self.catalog = catalog + self.cacheRoot = cacheRoot + } + + func load(_ loader: (URL?) -> ModelsDevCatalog?) -> ModelsDevCatalog { + if let catalog { return catalog } + let loaded = loader(self.cacheRoot) ?? ModelsDevCatalog(providers: [:]) + self.catalog = loaded + return loaded } - return rowsByDayModel } static func codexRowsByDayModel( @@ -78,6 +80,12 @@ extension CostUsageScanner { self.codexIntByDayModel(cache: cache, range: range) { $0.codexPriorityTokens } } + static func codexReportDayKeys(cache: CostUsageCache, range: CostUsageDayRange) -> [String] { + cache.days.keys.sorted().filter { + CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) + } + } + static func codexNanosByDayModel( cache: CostUsageCache, range: CostUsageDayRange, @@ -276,6 +284,7 @@ extension CostUsageScanner { forkedFromId: String? = nil, projectPath: String? = nil, canonicalProjectPath: String? = nil, + codexCostCacheComplete: Bool? = true, codexCostNanos: [String: [String: Int64]]? = nil, codexPrioritySurchargeNanos: [String: [String: Int64]]? = nil, codexStandardCostNanos: [String: [String: Int64]]? = nil, @@ -301,6 +310,7 @@ extension CostUsageScanner { forkedFromId: forkedFromId, projectPath: projectPath, canonicalProjectPath: canonicalProjectPath, + codexCostCacheComplete: codexCostCacheComplete, codexCostNanos: codexCostNanos, codexPrioritySurchargeNanos: codexPrioritySurchargeNanos, codexStandardCostNanos: codexStandardCostNanos, @@ -314,14 +324,17 @@ extension CostUsageScanner { static func needsCodexCostCache(_ usage: CostUsageFileUsage) -> Bool { !(usage.codexRows?.isEmpty ?? true) - && (usage.codexCostNanos == nil || self.needsCodexModeSplitCache(usage)) + && (usage.codexCostCacheComplete != true || self.needsCodexModeSplitCache(usage)) } static func needsCodexCostCache(_ usage: CostUsageFileUsage, range: CostUsageDayRange) -> Bool { + guard usage.codexCostCacheComplete != true || self.needsCodexModeSplitCache(usage) else { + return false + } guard let rows = usage.codexRows, !rows.isEmpty else { return false } return rows.contains { CostUsageDayRange.isInRange(dayKey: $0.day, since: range.sinceKey, until: range.untilKey) - } && (usage.codexCostNanos == nil || Self.needsCodexModeSplitCache(usage)) + } } static func needsCodexModeSplitCache(_ usage: CostUsageFileUsage) -> Bool { @@ -330,21 +343,36 @@ extension CostUsageScanner { let hasStandardTokens = !(usage.codexStandardTokens?.isEmpty ?? true) let hasPriorityTokens = !(usage.codexPriorityTokens?.isEmpty ?? true) - guard hasStandardCost || hasPriorityCost else { return true } + // Token maps are also the completion marker for models with no known pricing. guard hasStandardTokens || hasPriorityTokens else { return true } - return hasStandardCost != hasStandardTokens || hasPriorityCost != hasPriorityTokens + return (hasStandardCost && !hasStandardTokens) || (hasPriorityCost && !hasPriorityTokens) } static func codexFileUsageWithCostCache( _ usage: CostUsageFileUsage, context: CodexFileScanContext) -> CostUsageFileUsage + { + self.codexFileUsageWithCostCache( + usage, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + } + + static func codexFileUsageWithCostCache( + _ usage: CostUsageFileUsage, + range: CostUsageDayRange, + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> CostUsageFileUsage { guard let rows = usage.codexRows, !rows.isEmpty else { return usage } var migratedRows: [CodexUsageRow] = [] for row in rows where CostUsageDayRange.isInRange( dayKey: row.day, - since: context.range.scanSinceKey, - until: context.range.scanUntilKey) + since: range.scanSinceKey, + until: range.scanUntilKey) { migratedRows.append(row) } @@ -352,26 +380,26 @@ extension CostUsageScanner { let splitMaps = Self.codexModeSplitMaps( rows: migratedRows, - range: context.range, - priorityTurns: context.resources.priorityTurns, - modelsDevCatalog: context.resources.modelsDevCatalog, - modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + range: range, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) var updated = usage updated.codexCostNanos = Self.mergeMissingCostMaps( usage.codexCostNanos, Self.codexCostNanos( rows: migratedRows, - range: context.range, - modelsDevCatalog: context.resources.modelsDevCatalog, - modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + range: range, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot)) updated.codexPrioritySurchargeNanos = Self.mergeMissingCostMaps( usage.codexPrioritySurchargeNanos, Self.codexPrioritySurchargeNanos( rows: migratedRows, - range: context.range, - priorityTurns: context.resources.priorityTurns, - modelsDevCatalog: context.resources.modelsDevCatalog, - modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + range: range, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot)) updated.codexStandardCostNanos = Self.mergeMissingCostMaps( usage.codexStandardCostNanos, splitMaps.standardCostNanos) @@ -384,6 +412,7 @@ extension CostUsageScanner { updated.codexPriorityTokens = Self.mergeMissingIntMaps( usage.codexPriorityTokens, splitMaps.priorityTokens) + updated.codexCostCacheComplete = true updated.codexTurnIDs = Self.mergeCodexTurnIDs(usage.codexTurnIDs, rows: migratedRows) updated.codexRows = rows return updated @@ -1263,33 +1292,39 @@ extension CostUsageScanner { range: CostUsageDayRange, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil, - priorityTurns: [String: CodexPriorityTurnMetadata] = [:]) -> CostUsageDailyReport + priorityTurns: [String: CodexPriorityTurnMetadata] = [:], + modelsDevCatalogLoader: (URL?) -> ModelsDevCatalog? = { + CostUsagePricing.modelsDevCatalog(cacheRoot: $0) + }) -> CostUsageDailyReport { - var entries: [CostUsageDailyReport.Entry] = [] - var totalInput = 0 - var totalCacheRead = 0 - var totalOutput = 0 - var totalTokens = 0 - var totalCost: Double = 0 - var costSeen = false - - let dayKeys = cache.days.keys.sorted().filter { - CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) - } - let costNanosByDayModel = self.codexCostNanosByDayModel(cache: cache, range: range) - let prioritySurchargeNanosByDayModel = self.codexPrioritySurchargeNanosByDayModel(cache: cache, range: range) - let standardCostNanosByDayModel = self.codexStandardCostNanosByDayModel(cache: cache, range: range) - let priorityCostNanosByDayModel = self.codexPriorityCostNanosByDayModel(cache: cache, range: range) - let standardTokensByDayModel = self.codexStandardTokensByDayModel(cache: cache, range: range) - let priorityTokensByDayModel = self.codexPriorityTokensByDayModel(cache: cache, range: range) - - let hasCodexRows = cache.files.values.contains { - !($0.codexRows?.isEmpty ?? true) + let catalogResolver = CodexModelsDevCatalogResolver( + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + var reportCache = cache + for (path, usage) in cache.files where self.needsCodexCostCache(usage, range: range) { + reportCache.files[path] = self.codexFileUsageWithCostCache( + usage, + range: range, + priorityTurns: priorityTurns, + modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), + modelsDevCacheRoot: modelsDevCacheRoot) } - let rowsByDayModel = hasCodexRows ? self.codexRowsByDayModel(cache: cache, range: range) : [:] + var entries: [CostUsageDailyReport.Entry] = [] + var (totalInput, totalCacheRead, totalOutput, totalTokens) = (0, 0, 0, 0) + var (totalCost, costSeen) = (0.0, false) + + let dayKeys = self.codexReportDayKeys(cache: reportCache, range: range) + let costNanosByDayModel = self.codexCostNanosByDayModel(cache: reportCache, range: range) + let prioritySurchargeNanosByDayModel = self.codexPrioritySurchargeNanosByDayModel( + cache: reportCache, + range: range) + let standardCostNanosByDayModel = self.codexStandardCostNanosByDayModel(cache: reportCache, range: range) + let priorityCostNanosByDayModel = self.codexPriorityCostNanosByDayModel(cache: reportCache, range: range) + let standardTokensByDayModel = self.codexStandardTokensByDayModel(cache: reportCache, range: range) + let priorityTokensByDayModel = self.codexPriorityTokensByDayModel(cache: reportCache, range: range) for day in dayKeys { - guard let models = cache.days[day] else { continue } + guard let models = reportCache.days[day] else { continue } let modelNames = models.keys.sorted() var dayInput = 0 @@ -1310,20 +1345,17 @@ extension CostUsageScanner { dayCacheRead += cached dayOutput += output - let rows = rowsByDayModel[day]?[model] - let rowCostBreakdown = rows.map { - self.codexRowCostBreakdown( - rows: $0, - priorityTurns: priorityTurns, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - } let cachedBaseCost = costNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } - let rowTotalCost = cachedBaseCost == nil ? rowCostBreakdown?.totalCostUSD : nil - let standardCost = standardCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } - ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardCostUSD : nil) - let priorityCost = priorityCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } - ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityCostUSD : nil) + let cachedStandardCost = standardCostNanosByDayModel[day]?[model].map { + Double($0) / Self.costScale + } + let cachedPriorityCost = priorityCostNanosByDayModel[day]?[model].map { + Double($0) / Self.costScale + } + let cachedStandardTokens = standardTokensByDayModel[day]?[model] + let cachedPriorityTokens = priorityTokensByDayModel[day]?[model] + let standardCost = cachedStandardCost + let priorityCost = cachedPriorityCost let splitTotalCost: Double? = if standardCost != nil || priorityCost != nil { (standardCost ?? 0) + (priorityCost ?? 0) } else { @@ -1331,36 +1363,20 @@ extension CostUsageScanner { } var cost = splitTotalCost ?? cachedBaseCost - ?? rowTotalCost ?? CostUsagePricing.codexCostUSD( model: model, inputTokens: input, cachedInputTokens: cached, outputTokens: output, - modelsDevCatalog: modelsDevCatalog, + modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), modelsDevCacheRoot: modelsDevCacheRoot) if splitTotalCost == nil, let surchargeNanos = prioritySurchargeNanosByDayModel[day]?[model], cachedBaseCost != nil { cost = (cost ?? 0) + (Double(surchargeNanos) / Self.costScale) - } else if splitTotalCost == nil, - rowTotalCost == nil, - !priorityTurns.isEmpty, - let rows, - let surcharge = self.codexPrioritySurchargeUSD( - rows: rows, - priorityTurns: priorityTurns, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - { - cost = (cost ?? 0) + surcharge } - let standardModeTokens = standardTokensByDayModel[day]?[model] - ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardTokens : nil) - let priorityModeTokens = priorityTokensByDayModel[day]?[model] - ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityTokens : nil) - let hasModeSplit = priorityCost != nil || priorityModeTokens != nil + let hasModeSplit = priorityCost != nil || cachedPriorityTokens != nil breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, @@ -1368,8 +1384,8 @@ extension CostUsageScanner { totalTokens: totalTokens, standardCostUSD: hasModeSplit ? standardCost : nil, priorityCostUSD: hasModeSplit ? priorityCost : nil, - standardTokens: hasModeSplit ? standardModeTokens : nil, - priorityTokens: hasModeSplit ? priorityModeTokens : nil)) + standardTokens: hasModeSplit ? cachedStandardTokens : nil, + priorityTokens: hasModeSplit ? cachedPriorityTokens : nil)) if let cost { dayCost += cost dayCostSeen = true diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift index 691edce2eb..b5295f1cd6 100644 --- a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift +++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift @@ -93,6 +93,163 @@ struct CostUsagePerformanceGateTests { #expect(advanced.lastRowID == scanned.lastRowID + 1) } + @Test + func `cached daily report resolves and uses the pricing catalog once`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let model = "perf-custom-model" + _ = try Self.writeSyntheticCodexCorpus( + env: env, + day: day, + files: 3, + turnsPerFile: 4, + model: model) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let catalogJSON = """ + { + "openai": { + "id": "openai", + "models": { + "\(model)": { + "id": "\(model)", + "cost": { "input": 10, "output": 50, "cache_read": 1 } + } + } + } + } + """ + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(catalogJSON.utf8)) + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cachedUsage = try #require(cache.files.values.first { !($0.codexRows?.isEmpty ?? true) }) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + #expect(!CostUsageScanner.needsCodexCostCache(cachedUsage, range: range)) + var catalogLoadCount = 0 + let report = CostUsageScanner.buildCodexReportFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: env.cacheRoot, + modelsDevCatalogLoader: { _ in + catalogLoadCount += 1 + return catalog + }) + + #expect(report.summary?.totalCostUSD != nil) + #expect(catalogLoadCount == 1) + } + + @Test + func `cached daily report uses complete aggregates without loading pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + _ = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 3, turnsPerFile: 4) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + let scanned = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var catalogLoadCount = 0 + let cached = CostUsageScanner.buildCodexReportFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot, + modelsDevCatalogLoader: { _ in + catalogLoadCount += 1 + return ModelsDevCatalog(providers: [:]) + }) + + #expect(cached.data.map(\.totalTokens) == scanned.data.map(\.totalTokens)) + #expect(cached.summary?.totalTokens == scanned.summary?.totalTokens) + #expect(abs((cached.summary?.totalCostUSD ?? 0) - (scanned.summary?.totalCostUSD ?? 0)) < 0.000000001) + #expect(catalogLoadCount == 0) + } + + @Test + func `legacy missing aggregate cost backfills rows before threshold pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + _ = try Self.writeSyntheticCodexCorpus( + env: env, + day: day, + files: 2, + turnsPerFile: 1, + model: "openai/gpt-5.5", + inputTokensPerTurn: 200_000) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + let scanned = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + var legacy = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + for path in legacy.files.keys { + legacy.files[path]?.codexCostCacheComplete = nil + legacy.files[path]?.codexCostNanos = nil + legacy.files[path]?.codexStandardCostNanos = nil + legacy.files[path]?.codexPriorityCostNanos = nil + } + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + #expect(legacy.files.values.allSatisfy { CostUsageScanner.needsCodexCostCache($0, range: range) }) + + let backfilled = CostUsageScanner.buildCodexReportFromCache(cache: legacy, range: range) + + #expect(abs((backfilled.summary?.totalCostUSD ?? 0) - (scanned.summary?.totalCostUSD ?? 0)) < 0.000000001) + + var mixed = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let mixedPaths = mixed.files.keys.sorted() + let legacyPath = try #require(mixedPaths.first) + let rowlessPath = try #require(mixedPaths.last) + #expect(legacyPath != rowlessPath) + mixed.files[legacyPath]?.codexCostCacheComplete = nil + mixed.files[legacyPath]?.codexCostNanos = nil + mixed.files[legacyPath]?.codexStandardCostNanos = nil + mixed.files[legacyPath]?.codexPriorityCostNanos = nil + mixed.files[rowlessPath]?.codexRows = nil + + let mixedBackfilled = CostUsageScanner.buildCodexReportFromCache(cache: mixed, range: range) + #expect(abs((mixedBackfilled.summary?.totalCostUSD ?? 0) - (scanned.summary?.totalCostUSD ?? 0)) < 0.000000001) + + let aggregateCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 400_000, + cachedInputTokens: 0, + outputTokens: 20) + #expect(abs((backfilled.summary?.totalCostUSD ?? 0) - (aggregateCost ?? 0)) > 0.1) + } + @Test func `project rollups resolve the pricing catalog once per build`() throws { let env = try CostUsageTestEnvironment() @@ -132,9 +289,10 @@ struct CostUsagePerformanceGateTests { env: CostUsageTestEnvironment, day: Date, files: Int, - turnsPerFile: Int) throws -> [URL] + turnsPerFile: Int, + model: String = "openai/gpt-5.2-codex", + inputTokensPerTurn: Int = 100) throws -> [URL] { - let model = "openai/gpt-5.2-codex" let baseISO = env.isoString(for: day) var fileURLs: [URL] = [] for fileIndex in 0.. Date: Mon, 6 Jul 2026 00:33:08 +0100 Subject: [PATCH 073/105] chore: start 0.40.1 development --- CHANGELOG.md | 7 ++++++- version.env | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1677e45956..b087579a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.40.1 — Unreleased + +### Fixed +- 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. + ## 0.40.0 — 2026-07-05 ### Added @@ -17,7 +22,7 @@ - Usage bars: distinguish full-height quota-warning thresholds from subtle workday-boundary markers. Thanks @Alekstodo! ### Fixed -- 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. +- 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! diff --git a/version.env b/version.env index f9fe5f970f..76ddbe73fe 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.40.0 -BUILD_NUMBER=99 +MARKETING_VERSION=0.40.1 +BUILD_NUMBER=100 From b43a0ef645abc5d7c20d4f700932ac4d4b470ccf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 01:00:57 +0100 Subject: [PATCH 074/105] fix: hide passive Codex credit errors (#1927) Keep optional Codex collection failures out of the glanceable menu while preserving diagnostics in Provider Settings. --- CHANGELOG.md | 1 + Sources/CodexBar/MenuCardView+Costs.swift | 1 + .../PreferencesProviderDetailView.swift | 38 ++++++++++-- .../PreferencesProvidersPane+Testing.swift | 5 ++ .../CodexBar/PreferencesProvidersPane.swift | 9 +++ .../CodexBar/StatusItemController+Menu.swift | 7 +++ .../StatusItemController+MenuCardModel.swift | 6 +- ...sumerProjectionCharacterizationTests.swift | 2 +- .../CodexUserFacingErrorTests.swift | 32 ++++++++++ .../StatusMenuNativeSectionSpacingTests.swift | 59 +++++++++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 7 ++- 11 files changed, 157 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b087579a89..095cc4357c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.40.1 — Unreleased ### Fixed +- Codex menu: hide error-only optional Credits and OpenAI web setup diagnostics while keeping them visible in provider Settings. - 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. ## 0.40.0 — 2026-07-05 diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index dbddeec183..b7d5fdbf37 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -47,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) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index c4bcacb2f5..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")) @@ -326,17 +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, !hasResetCredits { + if !hasMetrics, !hasUsageNotes, !hasProviderCost, infoRows.isEmpty, !hasTokenUsage, !hasResetCredits { Text(self.placeholderText) .font(.footnote) .foregroundStyle(.secondary) @@ -360,8 +390,8 @@ 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 { 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/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 9399bd4bb2..8cb48e2a41 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1348,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 { diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 1196619300..a97c1d8337 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -45,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) diff --git a/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift b/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift index 865e6ac15b..68fa4888c9 100644 --- a/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexConsumerProjectionCharacterizationTests.swift @@ -114,7 +114,7 @@ struct CodexConsumerProjectionCharacterizationTests { snapshotOverride: overrideSnapshot, errorOverride: "Override error")) - #expect(model.creditsText == "Credits unavailable; keep Codex running to refresh.") + #expect(model.creditsText == nil) #expect(model.tokenUsage == nil) #expect(model.metrics.contains { $0.id == "code-review" } == false) #expect(model.subtitleText == "Override error") diff --git a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift index a8e81dbb73..ec8f569e14 100644 --- a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift +++ b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift @@ -207,6 +207,38 @@ struct CodexUserFacingErrorTests { model.creditsText == "Codex usage is temporarily unavailable. Try refreshing. Cached values from 1m ago.") } + @Test + func `menu card hides optional codex setup diagnostics kept by providers pane`() throws { + let settings = self.makeSettingsStore(suite: "CodexUserFacingErrorTests-menu-diagnostics") + let store = self.makeUsageStore(settings: settings) + store.lastCreditsError = UsageError.noRateLimitsFound.errorDescription + store.lastOpenAIDashboardError = + "No matching OpenAI web session found. Sign in to chatgpt.com, then refresh OpenAI cookies." + + let fetcher = UsageFetcher(environment: [:]) + let menuModel = try withStatusItemControllerForTesting( + store: store, + settings: settings, + fetcher: fetcher) + { controller in + try #require(controller.menuCardModel(for: .codex)) + } + let pane = ProvidersPane(settings: settings, store: store) + let settingsModel = pane._test_menuCardModel(for: .codex) + let settingsDiagnostic = pane._test_openAIWebDiagnostic(for: .codex) + let settingsInfoRows = ProviderMetricsInlineView.infoRows( + for: settingsModel, + openAIWebDiagnostic: settingsDiagnostic) + + #expect(menuModel.creditsText == nil) + #expect(menuModel.creditsHintText == nil) + #expect(settingsModel.creditsText == UsageError.noRateLimitsFound.errorDescription) + #expect(settingsModel.creditsHintText?.contains("No matching OpenAI web session found") == true) + #expect(settingsInfoRows.contains { row in + row.id == .openAIWeb && row.value.contains("No matching OpenAI web session found") + }) + } + @Test func `providers pane codex error display keeps raw full text for copy`() { let settings = self.makeSettingsStore(suite: "CodexUserFacingErrorTests-pane-error-display") diff --git a/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift b/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift index 81db772f1a..91b96f2b77 100644 --- a/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift +++ b/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift @@ -6,6 +6,65 @@ import Testing @MainActor @Suite(.serialized) struct StatusMenuNativeSectionSpacingTests { + @Test + func `buy credits stays available without an error only credits section`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.showOptionalCreditsAndExtraUsage = true + self.enableOnlyCodex(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.lastCreditsError = UsageError.noRateLimitsFound.errorDescription + store.lastOpenAIDashboardError = + "No matching OpenAI web session found. Sign in to chatgpt.com, then refresh OpenAI cookies." + let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + store.openAIDashboardAttachmentAuthorized = true + store.openAIDashboardRequiresLogin = false + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardCredits" } == false) + #expect(menu.items.contains { $0.title == "Buy Credits..." }) + #expect(menu.items.contains { item in + item.submenu?.items.contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true + }) + + settings.showOptionalCreditsAndExtraUsage = false + let hiddenMenu = controller.makeMenu(for: .codex) + controller.menuWillOpen(hiddenMenu) + #expect(hiddenMenu.items.contains { $0.title == "Buy Credits..." } == false) + #expect(hiddenMenu.items.contains { item in + item.submenu?.items.contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true + } == false) + } + @Test func `usage history cost and storage stay together without adjacent separators`() throws { let previousRendering = StatusItemController.menuCardRenderingEnabled diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 3fa28f8e03..3816d6ff4e 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -1214,12 +1214,13 @@ extension StatusMenuTests { controller.menuWillOpen(menu) let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } let creditsItem = menu.items.first { ($0.representedObject as? String) == "menuCardCredits" } + let creditsHistoryItem = menu.items.first { item in + item.submenu?.items.contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true + } #expect( usageItem?.submenu?.items .contains { ($0.representedObject as? String) == "usageBreakdownChart" } == true) - #expect( - creditsItem?.submenu?.items - .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true) + #expect(creditsItem == nil && creditsHistoryItem != nil) } @Test From 94ed5d9ca522381a214db589bb4f8258fd5c8f5a Mon Sep 17 00:00:00 2001 From: Zihao Qi <35388022+Zihao-Qi@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:03:10 -0500 Subject: [PATCH 075/105] [codex] Fix Kimi rate limit ordering (#1914) * Fix Kimi rate limit ordering * Preserve Kimi metric preferences * test: preserve Kimi metric lane labels * docs: credit Kimi quota ordering fix --------- Co-authored-by: Zihaoqi Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + Sources/CodexBar/MenuCardView.swift | 6 +-- .../Providers/Kimi/KimiUsageSnapshot.swift | 26 +++++----- Tests/CodexBarTests/KimiProviderTests.swift | 52 +++++++++++++++++++ Tests/CodexBarTests/MenuCardModelTests.swift | 41 +++++++++++++++ .../ProvidersPaneCoverageTests.swift | 21 ++++++++ .../UsageStoreHighestUsageTests.swift | 4 +- 7 files changed, 133 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 095cc4357c..347bcd2ecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Codex menu: hide error-only optional Credits and OpenAI web setup diagnostics while keeping them visible in provider Settings. - 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. +- Kimi: show the five-hour rate limit before the weekly quota while preserving existing menu-bar metric preferences. Thanks @Zihao-Qi! ## 0.40.0 — 2026-07-05 diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 1c80085b52..c50f99fbfc 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1254,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) } } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift index ddc6b2b76f..8c9195505d 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift @@ -70,20 +70,20 @@ extension KimiUsageSnapshot { var rateLimitWindow: RateWindow? if let rateLimit = self.rateLimit { let rateLimitValue = Int(rateLimit.limit) ?? 0 - let rateRemaining = Int(rateLimit.remaining ?? "") - let rateUsed = Int(rateLimit.used ?? "") ?? { - guard let remaining = rateRemaining else { return 0 } - return max(0, rateLimitValue - remaining) - }() - let ratePercent = rateLimitValue > 0 - ? Self.clampedPercent(Double(rateUsed) / Double(rateLimitValue) * 100) - : 0 + if rateLimitValue > 0 { + let rateRemaining = Int(rateLimit.remaining ?? "") + let rateUsed = Int(rateLimit.used ?? "") ?? { + guard let remaining = rateRemaining else { return 0 } + return max(0, rateLimitValue - remaining) + }() + let ratePercent = Self.clampedPercent(Double(rateUsed) / Double(rateLimitValue) * 100) - rateLimitWindow = RateWindow( - usedPercent: ratePercent, - windowMinutes: 300, // 300 minutes = 5 hours - resetsAt: Self.parseDate(rateLimit.resetTime), - resetDescription: "Rate: \(rateUsed)/\(rateLimitValue) per 5 hours") + rateLimitWindow = RateWindow( + usedPercent: ratePercent, + windowMinutes: 300, // 300 minutes = 5 hours + resetsAt: Self.parseDate(rateLimit.resetTime), + resetDescription: "Rate: \(rateUsed)/\(rateLimitValue) per 5 hours") + } } let monthlyWindow = self.subscriptionBalance.flatMap { balance -> NamedRateWindow? in diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift index 839139aca7..1e15354222 100644 --- a/Tests/CodexBarTests/KimiProviderTests.swift +++ b/Tests/CodexBarTests/KimiProviderTests.swift @@ -324,6 +324,31 @@ struct KimiUsageResponseParsingTests { #expect(snapshot.weekly.used == "375") #expect(snapshot.rateLimit?.limit == "200") #expect(snapshot.rateLimit?.used == "19") + + let usage = snapshot.toUsageSnapshot() + #expect(abs((usage.primary?.usedPercent ?? -1) - 18.3105) < 0.001) + #expect(usage.primary?.resetDescription == "375/2048 requests") + #expect(abs((usage.secondary?.usedPercent ?? -1) - 9.5) < 0.001) + #expect(usage.secondary?.windowMinutes == 300) + #expect(usage.secondary?.resetDescription == "Rate: 19/200 per 5 hours") + } + + @Test + func `converts weekly-only usage into primary quota lane`() { + let snapshot = KimiUsageSnapshot( + weekly: KimiUsageDetail( + limit: "2048", + used: "512", + remaining: "1536", + resetTime: "2026-01-09T15:23:13Z"), + rateLimit: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "512/2048 requests") + #expect(usage.secondary == nil) } @Test @@ -721,6 +746,31 @@ struct KimiUsageSnapshotConversionTests { #expect(usageSnapshot.tertiary == nil) } + @Test + func `converts invalid rate limit as unavailable`() { + let now = Date() + let weeklyDetail = KimiUsageDetail( + limit: "2048", + used: "375", + remaining: "1673", + resetTime: "2026-01-09T15:23:13.373329235Z") + let invalidRateLimit = KimiUsageDetail( + limit: "0", + used: "0", + remaining: "0", + resetTime: "2026-01-06T15:05:24.374187075Z") + + let snapshot = KimiUsageSnapshot( + weekly: weeklyDetail, + rateLimit: invalidRateLimit, + updatedAt: now) + + let usageSnapshot = snapshot.toUsageSnapshot() + + #expect(usageSnapshot.primary?.resetDescription == "375/2048 requests") + #expect(usageSnapshot.secondary == nil) + } + @Test func `handles zero values correctly`() { let now = Date() @@ -737,6 +787,7 @@ struct KimiUsageSnapshotConversionTests { let usageSnapshot = snapshot.toUsageSnapshot() #expect(usageSnapshot.primary?.usedPercent == 0.0) + #expect(usageSnapshot.secondary == nil) } @Test @@ -755,6 +806,7 @@ struct KimiUsageSnapshotConversionTests { let usageSnapshot = snapshot.toUsageSnapshot() #expect(usageSnapshot.primary?.usedPercent == 100.0) + #expect(usageSnapshot.secondary == nil) } private static func date(_ text: String) -> Date? { diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 3dc706874f..ce5f01de36 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -101,6 +101,47 @@ struct OverviewMenuCardVisibilityTests { } struct ProviderInlineDashboardModelTests { + @Test + func `kimi model orders rate limit before weekly quota`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.kimi]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 18.3, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), + resetDescription: "375/2048 requests"), + secondary: RateWindow( + usedPercent: 9.5, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: "Rate: 19/200 per 5 hours"), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .kimi, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.id) == ["secondary", "primary"]) + #expect(model.metrics.map(\.title) == ["Rate Limit", "Weekly"]) + } + @Test func `openrouter period usage gets inline dashboard`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index 08acfa0dd6..f503af1fb2 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -264,6 +264,27 @@ struct ProvidersPaneCoverageTests { } } + @Test + func `kimi menu bar metric picker preserves stored lane labels`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-kimi-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .kimi) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + MenuBarMetricPreference.primary.rawValue, + MenuBarMetricPreference.secondary.rawValue, + ]) + #expect(picker?.options.map(\.title) == [ + "Automatic", + "Primary (Weekly)", + "Secondary (Rate Limit)", + ]) + } + } + @Test func `cursor menu bar metric picker omits tertiary api lane when snapshot has no api metric`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-no-tertiary-picker") diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index 5b6a7a2015..b3dd8e0d58 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -85,7 +85,7 @@ struct UsageStoreHighestUsageTests { } @Test - func `automatic metric uses secondary for kimi when ranking highest usage`() { + func `automatic metric uses rate limit for kimi when ranking highest usage`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-kimi-automatic"), zaiTokenStore: NoopZaiTokenStore(), @@ -111,7 +111,7 @@ struct UsageStoreHighestUsageTests { updatedAt: Date()) let kimiSnapshot = UsageSnapshot( primary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 300, resetsAt: nil, resetDescription: nil), updatedAt: Date()) store._setSnapshotForTesting(codexSnapshot, provider: .codex) From 76a63dc559394bdc7569629d6c7376f6e1a11171 Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Mon, 6 Jul 2026 05:36:23 +0100 Subject: [PATCH 076/105] Add Kimi widget support (#1918) Co-authored-by: JoeVenner --- CHANGELOG.md | 3 + .../CodexBar/UsageStore+WidgetSnapshot.swift | 9 +++ .../CodexBarWidgetProvider.swift | 5 +- .../CodexBarWidget/CodexBarWidgetViews.swift | 6 +- .../CodexBarWidgetProviderTests.swift | 56 +++++++++++++++++++ .../UsageStoreWidgetSnapshotTests.swift | 53 ++++++++++++++++++ 6 files changed, 129 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 347bcd2ecc..7784b45aab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.40.1 — Unreleased +### Added +- Widgets: make Kimi available with Weekly, Rate Limit, and Monthly quota rows. Thanks @joeVenner! + ### Fixed - Codex menu: hide error-only optional Credits and OpenAI web setup diagnostics while keeping them visible in provider Settings. - 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. diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index a226fb4175..b7269ab37a 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -178,6 +178,15 @@ extension UsageStore { title: metadata?.opusLabel ?? "Opus", percentLeft: snapshot.tertiary?.remainingPercent)) } + if provider == .kimi, + let monthly = snapshot.extraRateWindows?.first(where: { $0.id == "kimi-monthly" }), + monthly.usageKnown + { + rows.append(WidgetSnapshot.WidgetUsageRowSnapshot( + id: monthly.id, + title: monthly.title, + percentLeft: monthly.window.remainingPercent)) + } return rows.filter { $0.percentLeft != nil } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 4e4cd56ec0..d9eae2b97b 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -18,6 +18,7 @@ enum ProviderChoice: String, AppEnum { case opencode case opencodego case mistral + case kimi static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") @@ -36,6 +37,7 @@ enum ProviderChoice: String, AppEnum { .opencode: DisplayRepresentation(title: "OpenCode"), .opencodego: DisplayRepresentation(title: "OpenCode Go"), .mistral: DisplayRepresentation(title: "Mistral"), + .kimi: DisplayRepresentation(title: "Kimi"), ] var provider: UsageProvider { @@ -54,6 +56,7 @@ enum ProviderChoice: String, AppEnum { case .opencode: .opencode case .opencodego: .opencodego case .mistral: .mistral + case .kimi: .kimi } } @@ -82,7 +85,7 @@ enum ProviderChoice: String, AppEnum { case .kiro: return nil // Kiro not yet supported in widgets case .augment: return nil // Augment not yet supported in widgets case .jetbrains: return nil // JetBrains not yet supported in widgets - case .kimi: return nil // Kimi not yet supported in widgets + case .kimi: self = .kimi case .kimik2: return nil // Kimi K2 not yet supported in widgets case .moonshot: return nil // Moonshot not yet supported in widgets case .amp: return nil // Amp not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 9da2649e02..f1560262e2 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -591,11 +591,13 @@ struct WidgetUsageRow: Identifiable, Equatable { } static func smallWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? { - self.antigravityQuotaSummaryRowLimit(for: entry, limit: 2) + if entry.provider == .kimi { return 3 } + return self.antigravityQuotaSummaryRowLimit(for: entry, limit: 2) } static func mediumWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? { - self.antigravityQuotaSummaryRowLimit(for: entry, limit: 3) + if entry.provider == .kimi { return 3 } + return self.antigravityQuotaSummaryRowLimit(for: entry, limit: 3) } private static func antigravityQuotaSummaryRowLimit( diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 1a059d7ed0..9bc1cc1934 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -369,6 +369,44 @@ struct CodexBarWidgetProviderTests { #expect(ProviderChoice.mistral.provider == .mistral) } + @Test + func `provider choice supports Kimi`() { + #expect(ProviderChoice(provider: .kimi) == .kimi) + #expect(ProviderChoice.kimi.provider == .kimi) + } + + @Test + func `small and medium Kimi widgets keep all three persisted lane rows`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let entry = WidgetSnapshot.ProviderEntry( + provider: .kimi, + updatedAt: now, + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot(id: "primary", title: "Weekly", percentLeft: 75), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "secondary", title: "Rate Limit", percentLeft: 50), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "kimi-monthly", title: "Monthly", percentLeft: 25), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let smallRows = WidgetUsageRow.rows( + for: entry, + limit: WidgetUsageRow.smallWidgetRowLimit(for: entry)) + let mediumRows = WidgetUsageRow.rows( + for: entry, + limit: WidgetUsageRow.mediumWidgetRowLimit(for: entry)) + + #expect(WidgetUsageRow.smallWidgetRowLimit(for: entry) == 3) + #expect(WidgetUsageRow.mediumWidgetRowLimit(for: entry) == 3) + #expect(smallRows.map(\.id) == ["primary", "secondary", "kimi-monthly"]) + #expect(mediumRows == smallRows) + } + @Test func `provider choice excludes unsupported Chutes widgets`() { #expect(ProviderChoice(provider: .chutes) == nil) @@ -435,6 +473,24 @@ struct CodexBarWidgetProviderTests { #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.mistral]) } + @Test + func `supported providers keep Kimi when it is the only enabled provider`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let entry = WidgetSnapshot.ProviderEntry( + provider: .kimi, + updatedAt: now, + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + let snapshot = WidgetSnapshot(entries: [entry], enabledProviders: [.kimi], generatedAt: now) + + #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.kimi]) + } + @Test func `codex weekly only widget rows omit session`() { let now = Date(timeIntervalSince1970: 1_700_000_000) diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index 77c6ca659e..37908c2267 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -5,6 +5,59 @@ import Testing @MainActor struct UsageStoreWidgetSnapshotTests { + @Test + func `widget snapshot includes Kimi monthly quota`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-kimi-monthly" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "kimi-monthly", + title: "Monthly", + window: RateWindow( + usedPercent: 75, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .kimi, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) + store._setSnapshotForTesting(snapshot, provider: .kimi) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "kimi-monthly-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .kimi }) + // Widgets preserve persisted lane order; menu-only presentation may reorder these lanes. + #expect(entry.usageRows?.map(\.id) == ["primary", "secondary", "kimi-monthly"]) + #expect(entry.usageRows?.map(\.title) == ["Weekly", "Rate Limit", "Monthly"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [75, 50, 25]) + } + @Test func `widget snapshot includes antigravity grouped usage rows`() async throws { let suite = "UsageStoreWidgetSnapshotTests-antigravity-grouped" From ff1c6c70a634988b87d7ffb93d35b42ef76b5c66 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:31:44 +0800 Subject: [PATCH 077/105] Cap Codex session display when weekly quota is exhausted (#1930) * Cap Codex session display when weekly quota is exhausted. When the weekly lane is the binding cap, show 0% session remaining and the weekly reset time so the menu card and menu bar do not imply usable 5-hour quota. Co-authored-by: Cursor * Address weekly-cap review feedback for Codex session display. Retarget the session reset whenever the weekly cap is active, omit the 5h reset when weekly reset metadata is missing, and evaluate cap expiry against render time. Co-authored-by: Cursor * fix: cap Codex session by weekly quota Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> * fix: refresh Codex fallbacks after resets Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> * fix: ignore expired Codex limits in menu bar Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> * fix: hide expired Codex quota lanes Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> * fix: observe merged Codex reset transitions Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> * fix: exclude weekly-capped Codex from ranking Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> * fix: preserve later Codex quota reset Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> * fix: project legacy Codex widget quotas Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> * test: split legacy Codex widget regression Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> --------- Co-authored-by: Cursor Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + Sources/CodexBar/IconRemainingResolver.swift | 28 +- Sources/CodexBar/MenuContent.swift | 10 +- .../Codex/CodexConsumerProjection.swift | 142 ++++++++++- .../StatusItemController+Animation.swift | 34 ++- ...tatusItemController+CountdownRefresh.swift | 46 +++- .../CodexBar/StatusItemController+Menu.swift | 8 +- Sources/CodexBar/StatusItemController.swift | 44 +--- .../CodexBar/UsageStore+HighestUsage.swift | 31 ++- .../CodexBar/UsageStore+WidgetSnapshot.swift | 23 +- Sources/CodexBarCore/WidgetSnapshot.swift | 4 +- .../CodexBarWidgetProvider.swift | 10 +- .../CodexBarWidget/CodexBarWidgetViews.swift | 69 ++++- .../CodexBarWidgetProviderTests.swift | 44 ++++ ...CodexCombinedMetricHighestUsageTests.swift | 62 +++++ .../CodexConsumerProjectionTests.swift | 239 ++++++++++++++++++ .../CodexLegacyWidgetSnapshotTests.swift | 54 ++++ .../CodexWeeklyCapSurfaceTests.swift | 219 ++++++++++++++++ Tests/CodexBarTests/CodexbarTests.swift | 29 +++ .../MenuBarCountdownRefreshTests.swift | 119 ++++++++- ...tusItemIconObservationSignatureTests.swift | 4 + .../UsageStoreWidgetSnapshotTests.swift | 47 ++++ ...am-weekly-exhausted-session-misleading.png | Bin 0 -> 31910 bytes .../codexbar-weekly-caps-session-fix.png | Bin 0 -> 157215 bytes 24 files changed, 1157 insertions(+), 110 deletions(-) create mode 100644 Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift create mode 100644 Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift create mode 100644 docs/screenshots/codex-upstream-weekly-exhausted-session-misleading.png create mode 100644 docs/screenshots/codexbar-weekly-caps-session-fix.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 7784b45aab..14b4c8e4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed - 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. - Kimi: show the five-hour rate limit before the weekly quota while preserving existing menu-bar metric preferences. Thanks @Zihao-Qi! diff --git a/Sources/CodexBar/IconRemainingResolver.swift b/Sources/CodexBar/IconRemainingResolver.swift index 83adcf0584..49fd27b773 100644 --- a/Sources/CodexBar/IconRemainingResolver.swift +++ b/Sources/CodexBar/IconRemainingResolver.swift @@ -1,4 +1,5 @@ import CodexBarCore +import Foundation enum IconRemainingResolver { private static let visibleZeroPercent = 0.0001 @@ -7,7 +8,7 @@ enum IconRemainingResolver { private static let sessionWindowMinutes = 5 * 60 private static let weeklyWindowMinutes = 7 * 24 * 60 - private static func codexProjection(snapshot: UsageSnapshot) -> 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/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index 3d9b5feb29..df12a92f44 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -231,19 +231,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/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/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index c73fb2c060..a42f0f23aa 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -615,7 +615,11 @@ extension StatusItemController { supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider)) } - func menuBarCreditsRemainingForIcon(provider: UsageProvider, snapshot: UsageSnapshot?) -> Double? { + 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 @@ -626,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 { @@ -720,7 +724,11 @@ extension StatusItemController { 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, @@ -789,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, @@ -1055,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 @@ -1083,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) } @@ -1097,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 } @@ -1120,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+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+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 8cb48e2a41..c14fa62f35 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1366,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 @@ -1379,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.swift b/Sources/CodexBar/StatusItemController.swift index fd194c6827..d923515ec0 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -335,9 +335,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), @@ -346,45 +346,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( 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+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index b7269ab37a..ee385c2a73 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) @@ -78,7 +79,7 @@ extension UsageStore { 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, @@ -111,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" @@ -130,7 +132,8 @@ extension UsageStore { return WidgetSnapshot.WidgetUsageRowSnapshot( id: lane.rawValue, title: title, - percentLeft: window.remainingPercent) + percentLeft: window.remainingPercent, + window: window) } } if provider == .antigravity, diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index 686b65debf..5f68d59ce8 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -5,11 +5,13 @@ public struct WidgetSnapshot: Codable, Sendable { public let id: String public let title: String public let percentLeft: Double? + public let window: RateWindow? - public init(id: String, title: String, percentLeft: Double?) { + public init(id: String, title: String, percentLeft: Double?, window: RateWindow? = nil) { self.id = id self.title = title self.percentLeft = percentLeft + self.window = window } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index d9eae2b97b..df74b23d45 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -228,8 +228,9 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { { let provider = configuration.provider.provider let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() - let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) - let refresh = Date().addingTimeInterval(30 * 60) + let now = Date() + let entry = CodexBarWidgetEntry(date: now, provider: provider, snapshot: snapshot) + let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: provider, now: now) return Timeline(entries: [entry], policy: .after(refresh)) } } @@ -251,7 +252,10 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let entry = self.makeEntry() - let refresh = Date().addingTimeInterval(30 * 60) + let refresh = BurnDownRefreshSchedule.nextRefresh( + snapshot: entry.snapshot, + provider: entry.provider, + now: entry.date) completion(Timeline(entries: [entry], policy: .after(refresh))) } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index f1560262e2..471b7f4367 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -614,12 +614,36 @@ struct WidgetUsageRow: Identifiable, Equatable { return limit } - static func rows(for entry: WidgetSnapshot.ProviderEntry, limit: Int? = nil) -> [WidgetUsageRow] { + static func rows( + for entry: WidgetSnapshot.ProviderEntry, + limit: Int? = nil, + now: Date = Date()) -> [WidgetUsageRow] + { let rows: [WidgetUsageRow] if let usageRows = entry.usageRows { - rows = usageRows.map { row in - WidgetUsageRow(id: row.id, title: row.title, percentLeft: row.percentLeft) + let resolvedSnapshots = usageRows.map { row in + guard row.window == nil, + let window = self.legacyCodexRateWindow(for: row.id, entry: entry) + else { + return row + } + return WidgetSnapshot.WidgetUsageRowSnapshot( + id: row.id, + title: row.title, + percentLeft: row.percentLeft, + window: window) } + let sourceRows = resolvedSnapshots.map { row in + WidgetUsageRow( + id: row.id, + title: row.title, + percentLeft: row.window?.remainingPercent ?? row.percentLeft) + } + rows = self.applyingCodexWeeklyCap( + sourceRows, + snapshots: resolvedSnapshots, + provider: entry.provider, + now: now) } else { let metadata = ProviderDefaults.metadata[entry.provider] var defaultRows = [ @@ -672,6 +696,45 @@ struct WidgetUsageRow: Identifiable, Equatable { return Array(rows.prefix(max(0, limit))) } + private static func applyingCodexWeeklyCap( + _ rows: [WidgetUsageRow], + snapshots: [WidgetSnapshot.WidgetUsageRowSnapshot], + provider: UsageProvider, + now: Date) -> [WidgetUsageRow] + { + guard provider == .codex, + let weekly = snapshots.first(where: { $0.id == "weekly" })?.window, + weekly.remainingPercent <= 0, + weekly.resetsAt.map({ $0 > now }) ?? true + else { + return rows + } + return rows.map { row in + guard row.id == "session" else { return row } + return WidgetUsageRow(id: row.id, title: row.title, percentLeft: 0) + } + } + + private static func legacyCodexRateWindow( + for rowID: String, + entry: WidgetSnapshot.ProviderEntry) -> RateWindow? + { + guard entry.provider == .codex else { return nil } + let candidates = [(entry.primary, "session"), (entry.secondary, "weekly")] + for (window, fallbackID) in candidates { + guard let window else { continue } + let classifiedID = switch window.windowMinutes { + case 300: "session" + case 10080: "weekly" + default: fallbackID + } + if classifiedID == rowID { + return window + } + } + return nil + } + static func compactTokenUsage( for entry: WidgetSnapshot.ProviderEntry) -> WidgetSnapshot.TokenUsageSummary? { diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 9bc1cc1934..e21ab1ff47 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -555,6 +555,50 @@ struct CodexBarWidgetProviderTests { #expect(rows == [WidgetUsageRow(id: "weekly", title: "Weekly", percentLeft: 75)]) } + @Test + func `codex widget session cap lifts at weekly reset without a new snapshot`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(3600) + let sessionWindow = RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil) + let weeklyWindow = RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil) + let entry = WidgetSnapshot.ProviderEntry( + provider: .codex, + updatedAt: now.addingTimeInterval(-7200), + primary: sessionWindow, + secondary: weeklyWindow, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "session", + title: "Session", + percentLeft: 99, + window: sessionWindow), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "weekly", + title: "Weekly", + percentLeft: 0, + window: weeklyWindow), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let capped = WidgetUsageRow.rows(for: entry, now: now) + let reset = WidgetUsageRow.rows(for: entry, now: weeklyReset) + + #expect(capped.map(\.percentLeft) == [0, 0]) + #expect(reset.map(\.percentLeft) == [99, 0]) + } + @Test func `legacy widget usage rows use antigravity grouped slots`() { let now = Date(timeIntervalSince1970: 1_700_000_000) diff --git a/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift b/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift index 6218335245..31194288bb 100644 --- a/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift +++ b/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift @@ -26,6 +26,68 @@ struct CodexCombinedMetricHighestUsageTests { #expect(highest?.usedPercent == 91) } + @Test + func `combined codex metric ignores expired weekly lane when ranking highest usage`() { + let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-expired-weekly-ranking") + let now = Date(timeIntervalSince1970: 1_800_000_000) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: now, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .claude) + + let highest = store.providerWithHighestUsage(now: now) + #expect(highest?.provider == .claude) + #expect(highest?.usedPercent == 80) + } + + @Test + func `combined codex metric excludes an actively binding weekly cap`() { + let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-binding-weekly-cap") + let now = Date(timeIntervalSince1970: 1_800_000_000) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + updatedAt: now), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .claude) + + let highest = store.providerWithHighestUsage(now: now) + #expect(highest?.provider == .claude) + #expect(highest?.usedPercent == 80) + } + @Test func `combined codex metric stays eligible when only one lane is exhausted`() { let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-one-exhausted") diff --git a/Tests/CodexBarTests/CodexConsumerProjectionTests.swift b/Tests/CodexBarTests/CodexConsumerProjectionTests.swift index 63410b7310..03babc0dd1 100644 --- a/Tests/CodexBarTests/CodexConsumerProjectionTests.swift +++ b/Tests/CodexBarTests/CodexConsumerProjectionTests.swift @@ -231,6 +231,245 @@ struct CodexConsumerProjectionTests { #expect(projection.credits?.remaining == 92239) } + @Test + func `exhausted weekly lane caps session display until weekly reset`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-session") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(4 * 24 * 3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 157, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + let weekly = try #require(projection.rateWindow(for: .weekly)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == weeklyReset) + #expect(weekly.remainingPercent == 0) + #expect(weekly.resetsAt == weeklyReset) + #expect(projection.planUtilizationLanes.first?.window.usedPercent == 1) + } + + @Test + func `exhausted weekly lane retargets session reset when session is also exhausted`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-both-exhausted") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(42 * 60) + let weeklyReset = now.addingTimeInterval(4 * 24 * 3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 157, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == weeklyReset) + #expect(session.resetsAt != sessionReset) + } + + @Test + func `both exhausted lanes use the later session reset`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-session-reset-binds-later") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(60 * 60) + let sessionReset = now.addingTimeInterval(4 * 60 * 60) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: "session reset"), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: "weekly reset"), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == sessionReset) + #expect(session.resetDescription == "session reset") + } + + @Test + func `both exhausted lanes keep effective reset unknown when session reset is unknown`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-session-reset-unknown") + let now = Date(timeIntervalSince1970: 1_800_000_000) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: "weekly reset"), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == nil) + #expect(session.resetDescription == nil) + } + + @Test + func `exhausted weekly lane leaves session reset unknown when weekly reset is unknown`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-unknown-reset") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(42 * 60) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: "in 42m"), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == nil) + #expect(session.resetDescription == nil) + } + + @Test + func `weekly cap lifts after weekly reset even with stale snapshot timestamp`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-cap-stale-snapshot") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshotCapturedAt = now.addingTimeInterval(-2 * 3600) + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(-3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: snapshotCapturedAt), + provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now) + + let projection = store.codexConsumerProjection(surface: .menuBar, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 99) + #expect(session.resetsAt == sessionReset) + #expect(projection.menuBarFallback == .none) + } + + @Test + func `weekly cap does not alter session display when weekly has reset`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-reset-session-uncapped") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(-3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 99) + #expect(session.resetsAt == sessionReset) + } + + @Test + func `weekly cap lifts at the weekly reset boundary`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-reset-boundary") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3 * 3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-3600)), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 99) + #expect(session.resetsAt == sessionReset) + } + private func makeStore(suite: String) -> UsageStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift b/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift new file mode 100644 index 0000000000..97ad743689 --- /dev/null +++ b/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +@testable import CodexBarCore +@testable import CodexBarWidget + +struct CodexLegacyWidgetSnapshotTests { + @Test + func `codex widget caps legacy decoded rows without window metadata`() throws { + let json = """ + { + "entries": [ + { + "provider": "codex", + "updatedAt": "2027-01-15T08:00:00Z", + "primary": { + "usedPercent": 1, + "windowMinutes": 300, + "resetsAt": "2027-01-15T09:00:00Z", + "resetDescription": null + }, + "secondary": { + "usedPercent": 100, + "windowMinutes": 10080, + "resetsAt": "2027-01-15T10:00:00Z", + "resetDescription": null + }, + "tertiary": null, + "usageRows": [ + { "id": "session", "title": "Session", "percentLeft": 99 }, + { "id": "weekly", "title": "Weekly", "percentLeft": 0 } + ], + "creditsRemaining": null, + "codeReviewRemainingPercent": null, + "tokenUsage": null, + "dailyUsage": [] + } + ], + "enabledProviders": ["codex"], + "generatedAt": "2027-01-15T08:00:00Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(WidgetSnapshot.self, from: Data(json.utf8)) + let entry = try #require(snapshot.entries.first) + let now = try #require(ISO8601DateFormatter().date(from: "2027-01-15T08:30:00Z")) + + let rows = WidgetUsageRow.rows(for: entry, now: now) + + #expect(entry.usageRows?.allSatisfy { $0.window == nil } == true) + #expect(rows.map(\.id) == ["session", "weekly"]) + #expect(rows.map(\.percentLeft) == [0, 0]) + } +} diff --git a/Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift b/Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift new file mode 100644 index 0000000000..9f46cb6e63 --- /dev/null +++ b/Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift @@ -0,0 +1,219 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct CodexWeeklyCapSurfaceTests { + @Test + func `menu card session metric shows weekly cap and reset`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-2 * 60 * 60)) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let session = try #require(model.metrics.first { $0.id == "primary" }) + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(session.percent == 0) + #expect(session.resetText == weekly.resetText) + #expect(session.resetText != nil) + } + + @Test + func `primary menu bar metric and credits follow binding weekly reset`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "CodexWeeklyCapSurfaceTests-menu-bar"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.setMenuBarMetricPreference(.primary, for: .codex) + + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(3600) + let sessionReset = now.addingTimeInterval(1800) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now) + + let capped = controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: now) + let reset = controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: weeklyReset) + let cappedCredits = controller.menuBarCreditsRemainingForIcon( + provider: .codex, + snapshot: snapshot, + now: now) + let resetCredits = controller.menuBarCreditsRemainingForIcon( + provider: .codex, + snapshot: snapshot, + now: weeklyReset) + + #expect(capped?.remainingPercent == 0) + #expect(capped?.resetsAt == weeklyReset) + #expect(reset?.remainingPercent == 99) + #expect(reset?.resetsAt == sessionReset) + #expect(cappedCredits == 80) + #expect(resetCredits == nil) + } + + @Test + func `combined menu bar modes ignore exhausted weekly lane after its reset`() throws { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "CodexWeeklyCapSurfaceTests-combined-reset"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.usageBarsShowUsed = false + settings.resetTimesShowAbsolute = false + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let selected = try #require(controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: now)) + #expect(selected.remainingPercent == 99) + #expect(selected.resetsAt == sessionReset) + + settings.menuBarDisplayMode = .percent + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "5h 99%") + settings.menuBarDisplayMode = .pace + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "99%") + settings.menuBarDisplayMode = .both + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "99%") + settings.menuBarDisplayMode = .resetTime + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "↻ in 1h") + + settings.setMenuBarMetricPreference(.primary, for: .codex) + settings.menuBarDisplayMode = .percent + let expiredSessionSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + store._setSnapshotForTesting(expiredSessionSnapshot, provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now) + + let resetPrimary = try #require(controller.menuBarMetricWindow( + for: .codex, + snapshot: expiredSessionSnapshot, + now: now)) + let resetIcon = IconRemainingResolver.resolvedRemaining( + snapshot: expiredSessionSnapshot, + style: .codex, + now: now) + #expect(resetPrimary.remainingPercent == 60) + #expect(controller.menuBarDisplayText(for: .codex, snapshot: expiredSessionSnapshot, now: now) == "60%") + #expect(resetIcon.primary == 60) + #expect(resetIcon.secondary == nil) + #expect(store.codexConsumerProjection(surface: .menuBar, now: now).menuBarFallback == .none) + } +} diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 0d7f76db1d..7502ab9155 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -636,6 +636,35 @@ struct CodexBarTests { #expect(remaining.secondary == nil) } + @Test + func `codex icon caps session only until exhausted weekly lane resets`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + + let capped = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .codex, now: now) + let reset = IconRemainingResolver.resolvedRemaining( + snapshot: snapshot, + style: .codex, + now: weeklyReset) + + #expect(capped.primary == 0) + #expect(capped.secondary == 0) + #expect(reset.primary == 99) + #expect(reset.secondary == nil) + } + @Test func `status overlays cut halos through the quota bar and keep glyphs visible`() throws { let plain = IconRenderer.makeIcon( diff --git a/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift index e17b9508db..8596778c13 100644 --- a/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift +++ b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift @@ -33,7 +33,7 @@ struct MenuBarCountdownRefreshTests { } @Test - func `status item schedules countdown refresh only for countdown reset dates`() { + func `status item schedules countdown and exhausted lane refreshes`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "MenuBarCountdownRefreshTests-scheduling"), zaiTokenStore: NoopZaiTokenStore(), @@ -78,6 +78,58 @@ struct MenuBarCountdownRefreshTests { controller.updateIcons() #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(90), + resetDescription: nil), + updatedAt: now), + provider: .codex) + controller.updateIcons() + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(-1), + resetDescription: nil), + updatedAt: now), + provider: .codex) + controller.updateIcons() + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(90), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + updatedAt: now), + provider: .codex) + controller.updateIcons() + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + settings.resetTimesShowAbsolute = false store._setSnapshotForTesting(nil, provider: .codex) controller.updateIcons() @@ -99,4 +151,69 @@ struct MenuBarCountdownRefreshTests { controller.prepareForAppShutdown() #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) } + + @Test + func `merged highest usage observes reset for noncurrent Codex candidate`() throws { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "MenuBarCountdownRefreshTests-merged-highest"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.menuBarShowsHighestUsage = true + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .percent + settings.resetTimesShowAbsolute = true + + let registry = ProviderRegistry.shared + try settings.setProviderEnabled( + provider: .codex, + metadata: #require(registry.metadata[.codex]), + enabled: true) + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(registry.metadata[.claude]), + enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(90), + resetDescription: nil), + updatedAt: now), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + controller.updateIcons() + #expect(controller.primaryProviderForUnifiedIcon() == .claude) + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + } } diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift index d4210ac541..d6b7743578 100644 --- a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -14,7 +14,10 @@ struct StatusItemIconObservationSignatureTests { settings.statusChecksEnabled = true settings.refreshFrequency = .manual settings.menuBarShowsBrandIconWithPercent = false + settings.menuBarShowsHighestUsage = false settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = false + settings.selectedMenuProvider = .codex let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -88,6 +91,7 @@ struct StatusItemIconObservationSignatureTests { let registry = ProviderRegistry.shared let claudeMetadata = try #require(registry.metadata[.claude]) settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + settings.selectedMenuProvider = .codex store._setSnapshotForTesting( Self.makeSnapshot(provider: .claude, email: "claude@example.com"), provider: .claude) diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index 37908c2267..fbdf8124ea 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -5,6 +5,53 @@ import Testing @MainActor struct UsageStoreWidgetSnapshotTests { + @Test + func `widget snapshot preserves raw Codex windows for timeline projection`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-codex-weekly-cap" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)), + provider: .codex) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "codex-weekly-cap-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .codex }) + #expect(entry.usageRows?.map(\.id) == ["session", "weekly"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [99, 0]) + #expect(entry.usageRows?.first?.window?.usedPercent == 1) + #expect(entry.usageRows?.last?.window?.resetsAt == now.addingTimeInterval(3600)) + } + @Test func `widget snapshot includes Kimi monthly quota`() async throws { let suite = "UsageStoreWidgetSnapshotTests-kimi-monthly" diff --git a/docs/screenshots/codex-upstream-weekly-exhausted-session-misleading.png b/docs/screenshots/codex-upstream-weekly-exhausted-session-misleading.png new file mode 100644 index 0000000000000000000000000000000000000000..cb8d287ec35c1a263654979f5ab802221861e77c GIT binary patch literal 31910 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

=hr|oa+o#l`Sm9=P!7AzWaZp8_3QQR`!?? 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/codexbar-weekly-caps-session-fix.png b/docs/screenshots/codexbar-weekly-caps-session-fix.png new file mode 100644 index 0000000000000000000000000000000000000000..e3b6326920d7d33e3c4aed563fcd253ad667450b GIT binary patch literal 157215 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�FqrC~u1Z=k^W7AF-U-x^+h*vbiO zgNm(V-_F3}2%rR0{MC1JXks%1(jW+DGxrO9l|rVbzh{bs@6n={QZiP-oDW$SD^FIL zt)qoC=FUE`B9NJTy=8auv)l2C7A~-=i15d4?E_ZsLhmuC>SAmzQ)^gK(#m08eI+W; zC~Vuo-WQH1Vdi2S0orhYrHB6fw_c;=Qh7q<#DAhHU%h|bmowbwgNSL1`-9?3jp~3I zjcgWMPA1zj%-V1i=JN3Zs2y%T8aq9-?N)ulA{oWT3IW8Y(HEveT}-iY#F?-j9k2Tt zl5V$sfL5EW>|f94VoG-Tg6?h}@K7e7+kPU}B6omM)iIlHJpdHa0-y{Dp1D#h>4sUc zm#`2N}T>>OM#tJ#pLZ@Mv{7k{Via=N(6yI#477 zMyxF`PM@z1$2U8FarkX!?qYooaoVb|Ia~s|Q-Ubc5rH}o=A&zeRVFZNOF`HFiz_-ubH**z9W$v&8#EH7R1uV8(?GXUnC2qss z3pLC~DV>rZKxDC6t%Y|wUl;DIo_POQA9a)74#3jSk>+vXu~-=Be7G75{zW9CG z>a@EDTo%DU9ev-J75xa~FT35G^nCmV><_y$F<)D^ZA=>NoJm;<*yazRcTl|#Ei)3l zmnv`A7YirWoPMC-U{(_yN@hiq|9XwPHeZfjyo+*k%`|uPh7bjw$bC0QR-B)?8%V)y zA^_MeUV-}9)TKz>+Aq-q0oEW5Z`e6-{G-`yPGQq-5lT}3MCbQMN>6|->x`w$<$NJT zN($@c`I^P=+k0Hfw8Yf;|Kvyd;@u1x2&c#hWf

>5^gIUkH~_5XJle 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

LVU{Q|AGb4+PMEPj#fU>?6g?#9a{^|* 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 s`-;wB?!T4fMJ<_2Rg!JqqOjLl@tZ_5vY{jgxOg Date: Mon, 6 Jul 2026 10:05:44 +0100 Subject: [PATCH 078/105] Reject non-finite Kimi K2 usage values (#1915) * Reject nonfinite Kimi K2 usage * fix: harden Kimi K2 numeric parsing --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../Providers/KimiK2/KimiK2UsageFetcher.swift | 26 +++++++++-------- .../KimiK2UsageFetcherTests.swift | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b4c8e4e0..14e3ed0d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - 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. +- Kimi K2: reject non-finite credit and token values before they reach menus, CLI output, or widgets. Thanks @joeVenner! - Kimi: show the five-hour rate limit before the weekly quota while preserving existing menu-bar metric preferences. Thanks @Zihao-Qi! ## 0.40.0 — 2026-07-05 diff --git a/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift b/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift index 22f9255e64..70536e649b 100644 --- a/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift @@ -113,7 +113,10 @@ public struct KimiK2UsageFetcher: Sendable { ["lastUpdated"], ] - public static func fetchUsage(apiKey: String) async throws -> KimiK2UsageSnapshot { + public static func fetchUsage( + apiKey: String, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> KimiK2UsageSnapshot + { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw KimiK2UsageError.missingCredentials } @@ -123,7 +126,7 @@ public struct KimiK2UsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await transport.response(for: request) let data = response.data guard response.statusCode == 200 else { let body = String(data: data, encoding: .utf8) ?? "HTTP \(response.statusCode)" @@ -240,16 +243,17 @@ public struct KimiK2UsageFetcher: Sendable { } private static func double(from raw: Any) -> Double? { - if let value = raw as? Double { - return value - } - if let value = raw as? Int { - return Double(value) - } - if let value = raw as? String { - return Double(value) + let value: Double? = if let raw = raw as? Double { + raw + } else if let raw = raw as? Int { + Double(raw) + } else if let raw = raw as? String { + Double(raw) + } else { + nil } - return nil + guard let value, value.isFinite else { return nil } + return value } private static func date(from raw: Any) -> Date? { diff --git a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift b/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift index 9b549bfbf7..b38179bf41 100644 --- a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift +++ b/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift @@ -40,6 +40,34 @@ struct KimiK2UsageFetcherTests { #expect(summary.remaining == 25) } + @Test + func `fetch ignores non-finite usage values`() async throws { + let json = """ + { + "total_credits_consumed": "NaN", + "credits_remaining": "Infinity", + "average_tokens": "1e309" + } + """ + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-key") + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["X-Credits-Remaining": "-Infinity"])) + return (Data(json.utf8), response) + } + + let snapshot = try await KimiK2UsageFetcher.fetchUsage(apiKey: "test-key", transport: transport) + let summary = snapshot.summary + + #expect(summary.consumed == 0) + #expect(summary.remaining == 0) + #expect(summary.averageTokens == nil) + } + @Test func `parses numeric timestamp seconds`() throws { let json = """ From 8479841c162203d59e0f8d348c349b43ef96d2b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 16:50:38 +0100 Subject: [PATCH 079/105] fix: keep settings selectors interactive on macOS 27 (#1936) --- CHANGELOG.md | 1 + Sources/CodexBar/PreferencesGeneralPane.swift | 43 +++++++------ Sources/CodexBar/PreferencesMenuPicker.swift | 64 +++++++++++++++++++ .../PreferencesPaneSmokeTests.swift | 42 +++++++++++- 4 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 Sources/CodexBar/PreferencesMenuPicker.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e3ed0d86..87eba0a171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Widgets: make Kimi available with Weekly, Rate Limit, and Monthly quota rows. Thanks @joeVenner! ### Fixed +- Settings: keep Language, Default Terminal, and Refresh cadence selectors interactive on macOS 27. - 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. diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 6065075f9f..fb577f4bba 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -82,27 +82,30 @@ struct GeneralPane: View { var body: some View { Form { Section { - Picker(selection: self.$settings.appLanguage) { - ForEach(AppLanguage.allCases) { option in - Text(verbatim: option.label).tag(option.rawValue) - } - } label: { - SettingsRowLabel(L("language_title"), subtitle: L("language_subtitle")) - } - - Picker(selection: self.$settings.terminalApp) { - ForEach(TerminalApp.pickerOptions(selected: self.settings.terminalApp)) { option in + 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) + }) + + 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: { @@ -110,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) 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/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index a555edaa26..e0a3d0c9d4 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -48,6 +48,40 @@ struct PreferencesPaneSmokeTests { _ = SettingsSidebarView(settings: settings, store: store, selection: .constant(.provider(.codex))).body } + @Test + func `general menu options cover persisted settings`() { + let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") + let previousAppleLanguages = UserDefaults.standard.object(forKey: "AppleLanguages") + defer { + if let previousLanguage { + UserDefaults.standard.set(previousLanguage, forKey: "appLanguage") + } else { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + if let previousAppleLanguages { + UserDefaults.standard.set(previousAppleLanguages, forKey: "AppleLanguages") + } else { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } + } + + #expect(GeneralSettingsMenuOptions.languages == AppLanguage.allCases.map(\.rawValue)) + #expect(GeneralSettingsMenuOptions.refreshFrequencies == RefreshFrequency.allCases) + #expect(GeneralSettingsMenuOptions.terminalApps(selected: .terminal) { _ in nil } == [.terminal]) + #expect(GeneralSettingsMenuOptions.terminalApps(selected: .iTerm) { _ in nil } == [.terminal, .iTerm]) + + let suite = "PreferencesPaneSmokeTests-general-menu-persistence" + let settings = Self.makeSettingsStore(suite: suite) + settings.appLanguage = "ja" + settings.terminalApp = .iTerm + settings.refreshFrequency = .fiveMinutes + + let reloaded = Self.makeSettingsStore(suite: suite, reset: false) + #expect(reloaded.appLanguage == "ja") + #expect(reloaded.terminalApp == .iTerm) + #expect(reloaded.refreshFrequency == .fiveMinutes) + } + @Test func `overview provider limit text formats numeric limit as object argument`() { let text = DisplayPane.overviewProviderLimitText(limit: 3) @@ -180,10 +214,12 @@ struct PreferencesPaneSmokeTests { } } - private static func makeSettingsStore(suite: String) -> SettingsStore { + private static func makeSettingsStore(suite: String, reset: Bool = true) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) + if reset { + defaults.removePersistentDomain(forName: suite) + } + let configStore = testConfigStore(suiteName: suite, reset: reset) return SettingsStore( userDefaults: defaults, From 4fe943fd2a6984f93ea1b0d2c57d1652190860a3 Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Mon, 6 Jul 2026 17:18:58 +0100 Subject: [PATCH 080/105] fix: reject invalid Kimi K2 timestamps (#1916) Reject non-finite, non-positive, and out-of-range numeric timestamps while preserving valid Unix seconds and milliseconds, including the exact cutoff. Make fallback-time coverage deterministic and retain contributor changelog credit. Co-authored-by: JoeVenner --- CHANGELOG.md | 1 + .../Providers/KimiK2/KimiK2UsageFetcher.swift | 25 ++++++---- .../KimiK2UsageFetcherTests.swift | 48 +++++++++++++++++++ 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87eba0a171..d175744cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - 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. +- 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: show the five-hour rate limit before the weekly quota while preserving existing menu-bar metric preferences. Thanks @Zihao-Qi! diff --git a/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift b/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift index 70536e649b..98c2dac47b 100644 --- a/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift @@ -142,11 +142,19 @@ public struct KimiK2UsageFetcher: Sendable { return KimiK2UsageSnapshot(summary: summary) } - static func _parseSummaryForTesting(_ data: Data, headers: [AnyHashable: Any] = [:]) throws -> KimiK2UsageSummary { - try self.parseSummary(data: data, headers: headers) + static func _parseSummaryForTesting( + _ data: Data, + headers: [AnyHashable: Any] = [:], + now: Date = Date()) throws -> KimiK2UsageSummary + { + try self.parseSummary(data: data, headers: headers, now: now) } - private static func parseSummary(data: Data, headers: [AnyHashable: Any]) throws -> KimiK2UsageSummary { + private static func parseSummary( + data: Data, + headers: [AnyHashable: Any], + now: Date = Date()) throws -> KimiK2UsageSummary + { guard let json = try? jsonSerializer.jsonObject(with: data), let dictionary = json as? [String: Any] else { @@ -159,7 +167,7 @@ public struct KimiK2UsageFetcher: Sendable { ?? Self.doubleValueFromHeaders(headers: headers, key: "x-credits-remaining") ?? 0 let averageTokens = Self.doubleValue(for: Self.averageTokenPaths, in: contexts) - let updatedAt = Self.dateValue(for: Self.timestampPaths, in: contexts) ?? Date() + let updatedAt = Self.dateValue(for: Self.timestampPaths, in: contexts) ?? now return KimiK2UsageSummary( consumed: consumed, @@ -284,11 +292,10 @@ public struct KimiK2UsageFetcher: Sendable { } private static func dateFromNumeric(_ value: Double) -> Date? { - guard value > 0 else { return nil } - if value > 1_000_000_000_000 { - return Date(timeIntervalSince1970: value / 1000) - } - return Date(timeIntervalSince1970: value) + guard value.isFinite, value > 0 else { return nil } + let seconds = value >= 1_000_000_000_000 ? value / 1000 : value + guard seconds.isFinite, seconds <= Date.distantFuture.timeIntervalSince1970 else { return nil } + return Date(timeIntervalSince1970: seconds) } private static func doubleValueFromHeaders(headers: [AnyHashable: Any], key: String) -> Double? { diff --git a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift b/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift index b38179bf41..9cbd375a26 100644 --- a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift +++ b/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift @@ -100,6 +100,54 @@ struct KimiK2UsageFetcherTests { #expect(abs(summary.updatedAt.timeIntervalSince1970 - expected.timeIntervalSince1970) < 0.5) } + @Test + func `treats exact millisecond cutoff as milliseconds`() throws { + let json = """ + { + "timestamp": 1000000000000, + "credits_remaining": 10, + "total_credits_consumed": 5 + } + """ + + let summary = try KimiK2UsageFetcher._parseSummaryForTesting(Data(json.utf8)) + + #expect(summary.updatedAt == Date(timeIntervalSince1970: 1_000_000_000)) + } + + @Test(arguments: ["NaN", "Infinity", "1e308", "0", "-1"]) + func `ignores invalid numeric timestamps`(timestamp: String) throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let json = """ + { + "timestamp": "\(timestamp)", + "credits_remaining": 10, + "total_credits_consumed": 5 + } + """ + + let summary = try KimiK2UsageFetcher._parseSummaryForTesting(Data(json.utf8), now: now) + + #expect(summary.updatedAt == now) + } + + @Test + func `ignores timestamps beyond distant future`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let timestamp = Date.distantFuture.timeIntervalSince1970 + 1 + let json = """ + { + "timestamp": "\(timestamp)", + "credits_remaining": 10, + "total_credits_consumed": 5 + } + """ + + let summary = try KimiK2UsageFetcher._parseSummaryForTesting(Data(json.utf8), now: now) + + #expect(summary.updatedAt == now) + } + @Test func `invalid root returns parse error`() { let json = """ From 5f909879f8a7d541b2db56c9165f250cd5ee314d Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 6 Jul 2026 12:57:17 -0400 Subject: [PATCH 081/105] fix: update Amp usage dashboard (#1933) Point Amp's Usage Dashboard action at the current usage route while leaving fetch, sign-in, and cookie URLs unchanged. Add exact metadata regression coverage and contributor changelog credit. Co-authored-by: Echo --- CHANGELOG.md | 1 + .../CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift | 2 +- Tests/CodexBarTests/AmpUsageFetcherTests.swift | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d175744cb8..d24ef98784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Widgets: make Kimi available with Weekly, Rate Limit, and Monthly quota rows. Thanks @joeVenner! ### Fixed +- Amp: open the current Usage page from the menu dashboard action. Thanks @3kh0! - Settings: keep Language, Default Terminal, and Refresh cadence selectors interactive on macOS 27. - 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! diff --git a/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift index e6295cb718..91ecef4ac4 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift @@ -21,7 +21,7 @@ public enum AmpProviderDescriptor { isPrimaryProvider: false, usesAccountFallback: false, browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, - dashboardURL: "https://ampcode.com/settings#billing", + dashboardURL: "https://ampcode.com/settings/usage", statusPageURL: nil), branding: ProviderBranding( iconStyle: .amp, diff --git a/Tests/CodexBarTests/AmpUsageFetcherTests.swift b/Tests/CodexBarTests/AmpUsageFetcherTests.swift index 150042c73c..14fb5c9282 100644 --- a/Tests/CodexBarTests/AmpUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AmpUsageFetcherTests.swift @@ -29,6 +29,11 @@ struct AmpUsageFetcherTests { "https://ampcode.com/api/internal?userDisplayBalanceInfo") } + @Test + func `provider dashboard points to current usage page`() { + #expect(AmpProviderDescriptor.descriptor.metadata.dashboardURL == "https://ampcode.com/settings/usage") + } + @Test func `web fallback requires browser import or a manual session cookie`() { let disabled = ProviderSettingsSnapshot.AmpProviderSettings(cookieSource: .off, manualCookieHeader: nil) From 1c80b20f5c897f69127894ffa2e9cab289593548 Mon Sep 17 00:00:00 2001 From: Yash Raj Pandey <55940078+devYRPauli@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:15:08 -0400 Subject: [PATCH 082/105] fix: show sub-1% usage accurately (#1946) Show every positive usage value below one percent as `<1%` before integer rounding, and lock both prefix-first and suffix-first localized formatting with focused regression coverage. Co-authored-by: Yash Raj Pandey --- CHANGELOG.md | 1 + .../CodexBar/Resources/zh-Hant.lproj/Localizable.strings | 1 + Sources/CodexBarCore/UsageFormatter.swift | 7 ++++--- Tests/CodexBarTests/UsageFormatterTests.swift | 9 +++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d24ef98784..c98054f94b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Amp: open the current Usage page from the menu dashboard action. Thanks @3kh0! - 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. diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index dc257068f7..a9c37f979e 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1015,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/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 75699be4ae..4b469d67e2 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -82,9 +82,10 @@ public enum UsageFormatter { public static func percentText(_ percent: Double, suffix: String) -> String { let clamped = min(100, max(0, percent)) - let text = self.localized("%.0f%% %@", clamped, suffix) - guard clamped > 0, clamped < 1 else { return text } - return text.replacingOccurrences(of: "0%", with: "<1%") + if clamped > 0, clamped < 1 { + return self.localized("<1%% %@", suffix) + } + return self.localized("%.0f%% %@", clamped, suffix) } public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index e33ed09d2b..e3d82a9772 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -54,6 +54,12 @@ struct UsageFormatterTests { #expect(UsageFormatter.percentString(1) == "1%") #expect(UsageFormatter.percentString(101) == "100%") #expect(UsageFormatter.usageLine(remaining: 99.9, used: 0.1, showUsed: true) == "<1% used") + // Values in (0.5, 1) round up to "1%" under %.0f, so the old post-format + // "0%" -> "<1%" replacement missed them. percentText must show "<1%" + // across the whole sub-1% range, matching percentString above. + #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "<1% used") + #expect(UsageFormatter.usageLine(remaining: 99.25, used: 0.75, showUsed: true) == "<1% used") + #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "<1% left") let usedWindow = RateWindow(usedPercent: 0.1, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let leftWindow = RateWindow(usedPercent: 99.9, windowMinutes: nil, resetsAt: nil, resetDescription: nil) @@ -66,6 +72,7 @@ struct UsageFormatterTests { UsageFormatter.setLocalizationProvider { key in switch key { case "%.0f%% %@": "%2$@ %1$.0f%%" + case "<1%% %@": "%1$@ <1%%" case "usage_percent_suffix_left": "剩余" case "usage_percent_suffix_used": "已使用" default: key @@ -75,6 +82,8 @@ struct UsageFormatterTests { #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: false) == "剩余 22%") #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "已使用 78%") + #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "剩余 <1%") + #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "已使用 <1%") } @Test From 70ef85083ad074c2e7a8bc64c37fc01881dce4a9 Mon Sep 17 00:00:00 2001 From: Yash Raj Pandey <55940078+devYRPauli@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:33:43 -0400 Subject: [PATCH 083/105] fix: roll Claude reset dates across the year boundary (#1947) Roll yearless Claude quota reset dates into the next year when their current-year interpretation is already past, covering both minute-bearing and hour-only formats. Co-authored-by: Yash Raj Pandey --- CHANGELOG.md | 1 + .../Providers/Claude/ClaudeStatusProbe.swift | 11 ++++++++-- Tests/CodexBarTests/StatusProbeTests.swift | 20 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c98054f94b..4a591710ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed - Amp: open the current Usage page from the menu dashboard action. Thanks @3kh0! +- Claude: keep yearless reset dates in the upcoming year when a quota crosses New Year's Day. Thanks @devYRPauli! - 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. diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index 4912f2cc6f..042cd2e096 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -628,13 +628,13 @@ public struct ClaudeStatusProbe: Sendable { if let date = self.parseDate(raw, formats: Self.resetDateTimeWithMinutes, formatter: formatter) { var comps = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) comps.second = 0 - return calendar.date(from: comps) + return self.bumpYearIfNeeded(calendar.date(from: comps), now: now, calendar: calendar) } if let date = self.parseDate(raw, formats: Self.resetDateTimeHourOnly, formatter: formatter) { var comps = calendar.dateComponents([.year, .month, .day, .hour], from: date) comps.minute = 0 comps.second = 0 - return calendar.date(from: comps) + return self.bumpYearIfNeeded(calendar.date(from: comps), now: now, calendar: calendar) } if let time = self.parseDate(raw, formats: Self.resetTimeWithMinutes, formatter: formatter) { @@ -659,6 +659,13 @@ public struct ClaudeStatusProbe: Sendable { return calendar.date(byAdding: .day, value: 1, to: anchored) } + /// Yearless dates parsed just before New Year's can otherwise land nearly a year in the past. + private static func bumpYearIfNeeded(_ date: Date?, now: Date, calendar: Calendar) -> Date? { + guard let date else { return nil } + if date >= now { return date } + return calendar.date(byAdding: .year, value: 1, to: date) + } + private static let resetTimeWithMinutes = ["h:mma", "h:mm a", "HH:mm", "H:mm"] private static let resetTimeHourOnly = ["ha", "h a"] diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift index 611a2c1c1d..5d31129eda 100644 --- a/Tests/CodexBarTests/StatusProbeTests.swift +++ b/Tests/CodexBarTests/StatusProbeTests.swift @@ -659,6 +659,26 @@ struct StatusProbeTests { #expect(parsed == expected) } + @Test + func `rolls claude reset date forward across the year boundary`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let now = try #require(calendar.date(from: DateComponents( + year: 2026, month: 12, day: 31, hour: 23, minute: 0, second: 0))) + let cases = [ + ("Resets Jan 2, 3:15am (UTC)", 15), + ("Resets Jan 2, 3am (UTC)", 0), + ] + + for (text, minute) in cases { + let parsed = ClaudeStatusProbe.parseResetDate(from: text, now: now) + let expected = calendar.date(from: DateComponents( + year: 2027, month: 1, day: 2, hour: 3, minute: minute, second: 0)) + #expect(parsed == expected, "Failed to roll forward: \(text)") + #expect(try #require(parsed) > now) + } + } + @Test func `parses claude reset with dot separated time`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) From e2c1c6f6eeeaaff03e1f3cf721819aa809faf98e Mon Sep 17 00:00:00 2001 From: Yash Raj Pandey <55940078+devYRPauli@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:51:49 -0400 Subject: [PATCH 084/105] fix: preserve fractional Claude web utilization (#1948) Preserve fractional Claude web session and weekly utilization by routing both fields through the established numeric parser, with regression coverage through the downstream session window. Co-authored-by: Yash Raj Pandey --- CHANGELOG.md | 1 + .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 8 ++------ .../RateWindowSyntheticPlaceholderTests.swift | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a591710ed..56ad2bbca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - Amp: open the current Usage page from the menu dashboard action. Thanks @3kh0! - 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! - 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. diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index 70e0e375ed..59fac139fb 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -614,9 +614,7 @@ public enum ClaudeWebAPIFetcher { var sessionResets: Date? let fiveHour = json["five_hour"] as? [String: Any] if let fiveHour { - if let utilization = fiveHour["utilization"] as? Int { - sessionPercent = Double(utilization) - } + sessionPercent = Self.percentValue(from: fiveHour["utilization"]) if let resetsAt = fiveHour["resets_at"] as? String { sessionResets = self.parseISO8601Date(resetsAt) } @@ -631,9 +629,7 @@ public enum ClaudeWebAPIFetcher { var weeklyPercent: Double? var weeklyResets: Date? if let sevenDay = json["seven_day"] as? [String: Any] { - if let utilization = sevenDay["utilization"] as? Int { - weeklyPercent = Double(utilization) - } + weeklyPercent = Self.percentValue(from: sevenDay["utilization"]) if let resetsAt = sevenDay["resets_at"] as? String { weeklyResets = self.parseISO8601Date(resetsAt) } diff --git a/Tests/CodexBarTests/RateWindowSyntheticPlaceholderTests.swift b/Tests/CodexBarTests/RateWindowSyntheticPlaceholderTests.swift index dcf0ab6a29..e2bfe04986 100644 --- a/Tests/CodexBarTests/RateWindowSyntheticPlaceholderTests.swift +++ b/Tests/CodexBarTests/RateWindowSyntheticPlaceholderTests.swift @@ -101,6 +101,25 @@ struct RateWindowSyntheticPlaceholderTests { #expect(primary.usedPercent == 11) } + @Test + func `web mapping keeps fractional session and weekly utilization`() throws { + let json = """ + { + "five_hour": { "utilization": 45.5, "resets_at": "2025-12-29T20:00:00.000Z" }, + "seven_day": { "utilization": 12.25, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let webData = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(Data(json.utf8)) + #expect(webData.sessionPercentUsed == 45.5) + #expect(webData.weeklyPercentUsed == 12.25) + #expect(webData.hasLiveSessionWindow == true) + + let primary = ClaudeUsageFetcher.webPrimaryWindow(from: webData) + #expect(primary.isSyntheticPlaceholder == false) + #expect(primary.usedPercent == 45.5) + #expect(primary.remainingPercent == 54.5) + } + @Test func `web mapping keeps a real zero-usage session that omits a reset`() throws { // A reported `five_hour` object at 0% with no `resets_at` is a real idle session, not the From b5d4e27342f3374757d5b597455f6e8172ced4ca Mon Sep 17 00:00:00 2001 From: Yash Raj Pandey <55940078+devYRPauli@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:10:30 -0400 Subject: [PATCH 085/105] fix: use Gemini Flash metrics when Pro is absent (#1950) Keep Gemini's Pro lane absent when no Pro quota is reported so menu-bar metric selection falls back to the real Flash usage, with direct resolver regression coverage. Co-authored-by: Yash Raj Pandey --- CHANGELOG.md | 1 + .../Providers/Gemini/GeminiStatusProbe.swift | 13 +++--- .../GeminiPrimaryWindowTests.swift | 41 +++++++++++++++++++ .../MenuBarMetricWindowResolverTests.swift | 19 +++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 Tests/CodexBarTests/GeminiPrimaryWindowTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ad2bbca1..83a7e64b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Amp: open the current Usage page from the menu dashboard action. Thanks @3kh0! - 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: use the real Flash quota in menu-bar metrics when an account has no Pro quota. Thanks @devYRPauli! - 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. diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 7e65919b3c..ddd39cd05a 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -50,11 +50,14 @@ public struct GeminiStatusSnapshot: Sendable { let flashMin = flashQuotas.min(by: { $0.percentLeft < $1.percentLeft }) let proMin = proQuotas.min(by: { $0.percentLeft < $1.percentLeft }) - let primary = RateWindow( - usedPercent: proMin.map { 100 - $0.percentLeft } ?? 0, - windowMinutes: 1440, - resetsAt: proMin?.resetTime, - resetDescription: proMin?.resetDescription) + // Keep missing tiers nil so downstream selectors can fall back to a quota the account actually reports. + let primary: RateWindow? = proMin.map { + RateWindow( + usedPercent: 100 - $0.percentLeft, + windowMinutes: 1440, + resetsAt: $0.resetTime, + resetDescription: $0.resetDescription) + } let secondary: RateWindow? = flashMin.map { RateWindow( diff --git a/Tests/CodexBarTests/GeminiPrimaryWindowTests.swift b/Tests/CodexBarTests/GeminiPrimaryWindowTests.swift new file mode 100644 index 0000000000..105d15438e --- /dev/null +++ b/Tests/CodexBarTests/GeminiPrimaryWindowTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct GeminiPrimaryWindowTests { + @Test + func `flash-only account does not fabricate a phantom 0% primary window`() { + let snapshot = GeminiStatusSnapshot( + modelQuotas: [ + GeminiModelQuota(modelId: "gemini-2.5-flash", percentLeft: 5, resetTime: nil, resetDescription: nil), + GeminiModelQuota( + modelId: "gemini-2.5-flash-lite", percentLeft: 60, resetTime: nil, resetDescription: nil), + ], + rawText: "", + accountEmail: nil, + accountPlan: nil) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 95) + #expect(usage.tertiary?.usedPercent == 40) + } + + @Test + func `pro quota still populates the primary window`() { + let snapshot = GeminiStatusSnapshot( + modelQuotas: [ + GeminiModelQuota(modelId: "gemini-2.5-pro", percentLeft: 30, resetTime: nil, resetDescription: nil), + GeminiModelQuota(modelId: "gemini-2.5-flash", percentLeft: 70, resetTime: nil, resetDescription: nil), + ], + rawText: "", + accountEmail: nil, + accountPlan: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 70) + #expect(usage.secondary?.usedPercent == 30) + } +} diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index 564c7dd4e3..e6a499fd2e 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -4,6 +4,25 @@ import Testing @testable import CodexBar struct MenuBarMetricWindowResolverTests { + @Test + func `gemini metrics fall back to Flash when Pro is unavailable`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 95, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 40, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + for preference in [MenuBarMetricPreference.automatic, .primary, .average] { + let window = MenuBarMetricWindowResolver.rateWindow( + preference: preference, + provider: .gemini, + snapshot: snapshot, + supportsAverage: true) + + #expect(window?.usedPercent == 95, "Failed preference: \(preference)") + } + } + @Test func `automatic metric uses zai 5-hour token lane when it is most constrained`() { let snapshot = UsageSnapshot( From 72aaaf3d302f5933a41649714bb7282ff3500611 Mon Sep 17 00:00:00 2001 From: Lex <90120521+Lex-ic-on@users.noreply.github.com> Date: Tue, 7 Jul 2026 03:29:45 +0900 Subject: [PATCH 086/105] fix: preserve Devin one-percent usage (#1938) Correct Devin's mixed-scale top-level percentage boundary so exact one means 1% while preserving fractional fallback quota-object semantics. Co-authored-by: Lex Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Providers/Devin/DevinUsageSnapshot.swift | 2 +- .../DevinUsageFetcherTests.swift | 41 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a7e64b57..1c089411c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - 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: show the five-hour rate limit before the weekly quota while preserving existing menu-bar metric preferences. Thanks @Zihao-Qi! diff --git a/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift index 68a09aeaa5..8b7b2b79cb 100644 --- a/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Devin/DevinUsageSnapshot.swift @@ -146,7 +146,7 @@ public enum DevinUsageParser { private static func currentQuotaWindow(percent: Any?, resetsAt: Any?) -> DevinQuotaWindow? { guard let usedPercent = self.double(percent) else { return nil } return DevinQuotaWindow( - usedPercent: usedPercent <= 1 ? usedPercent * 100 : usedPercent, + usedPercent: usedPercent < 1 ? usedPercent * 100 : usedPercent, resetsAt: self.date(from: resetsAt)) } diff --git a/Tests/CodexBarTests/DevinUsageFetcherTests.swift b/Tests/CodexBarTests/DevinUsageFetcherTests.swift index 4745fce77a..437e0369a5 100644 --- a/Tests/CodexBarTests/DevinUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DevinUsageFetcherTests.swift @@ -141,6 +141,47 @@ struct DevinUsageFetcherTests { #expect(usage.secondary?.usedPercent == 25) } + @Test + func `normalizes mixed-scale current percentages at the one-percent boundary`() throws { + let cases: [(input: Double, expected: Double)] = [ + (0.5, 50), + (1, 1), + (1.5, 1.5), + ] + + for value in cases { + let response: [String: Any] = [ + "daily_percentage": value.input, + "weekly_percentage": value.input, + ] + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.daily?.usedPercent == value.expected) + #expect(snapshot.weekly?.usedPercent == value.expected) + } + } + + @Test + func `preserves fractional boundaries for fallback quota percentages`() throws { + let response: [String: Any] = [ + "quota_usage": [ + "daily_quota": [ + "used_percent": 1, + "reset_at": "2026-06-01T08:00:00Z", + ], + "weekly_quota": [ + "remaining_percent": 1, + "next_reset_at": 1_780_560_000, + ], + ], + ] + + let snapshot = try DevinUsageParser.parse(response, organization: nil, now: Self.now) + + #expect(snapshot.daily?.usedPercent == 100) + #expect(snapshot.weekly?.usedPercent == 0) + } + @Test func `parses zero percentages from JSON response`() throws { let data = Data(""" From dcccce8fb20b93bebde5e7755bde66a0adee4ec0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 20:14:37 +0100 Subject: [PATCH 087/105] Fix Kimi subscription quota rows (#1953) Update Kimi membership quota enrichment to the current endpoint, add the Code 7-day quota, harden ratio validation, and preserve compact widget fit. Co-authored-by: skyzer --- CHANGELOG.md | 2 + .../CodexBar/UsageStore+WidgetSnapshot.swift | 19 +++-- .../Providers/Kimi/KimiModels.swift | 9 ++- .../Providers/Kimi/KimiUsageFetcher.swift | 35 ++++----- .../Providers/Kimi/KimiUsageSnapshot.swift | 21 ++++- .../CodexBarWidgetProviderTests.swift | 5 +- Tests/CodexBarTests/KimiProviderTests.swift | 76 ++++++++++++++++++- .../UsageStoreWidgetSnapshotTests.swift | 28 +++++-- 8 files changed, 158 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c089411c3..c130d23420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - 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! ### Fixed - Amp: open the current Usage page from the menu dashboard action. Thanks @3kh0! @@ -18,6 +19,7 @@ - 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! ## 0.40.0 — 2026-07-05 diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index ee385c2a73..485b97cf2e 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -181,14 +181,17 @@ extension UsageStore { title: metadata?.opusLabel ?? "Opus", percentLeft: snapshot.tertiary?.remainingPercent)) } - if provider == .kimi, - let monthly = snapshot.extraRateWindows?.first(where: { $0.id == "kimi-monthly" }), - monthly.usageKnown - { - rows.append(WidgetSnapshot.WidgetUsageRowSnapshot( - id: monthly.id, - title: monthly.title, - percentLeft: monthly.window.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/CodexBarCore/Providers/Kimi/KimiModels.swift b/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift index e4cdcc3835..7b1a6d73fb 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift @@ -9,8 +9,9 @@ struct KimiCodeAPIUsageResponse: Codable { let limits: [KimiRateLimit]? } -struct KimiSubscriptionStatResponse: Codable { +struct KimiSubscriptionStatsResponse: Codable { let subscriptionBalance: KimiSubscriptionBalance? + let ratelimitCode7d: KimiSubscriptionRateLimit? } struct KimiSubscriptionBalance: Codable, Sendable { @@ -20,6 +21,12 @@ struct KimiSubscriptionBalance: Codable, Sendable { let expireTime: String? } +struct KimiSubscriptionRateLimit: Codable, Sendable { + let ratio: Double? + let enabled: Bool? + let resetTime: String? +} + struct KimiUsage: Codable { let scope: String let detail: KimiUsageDetail diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift index ecfcc2bc41..d9029263b8 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift @@ -9,8 +9,8 @@ public struct KimiUsageFetcher: Sendable { private static let subscriptionGraceSeconds: TimeInterval = 2 private static let usageURL = URL(string: "https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages")! - private static let subscriptionStatURL = - URL(string: "https://www.kimi.com/apiv2/kimi.gateway.membership.v2.MembershipService/GetSubscriptionStat")! + private static let subscriptionStatsURL = + URL(string: "https://www.kimi.com/apiv2/kimi.gateway.membership.v2.MembershipService/GetSubscriptionStats")! public static func fetchCodeAPIUsage( apiKey: String, @@ -83,8 +83,8 @@ public struct KimiUsageFetcher: Sendable { { let sessionInfo = self.decodeSessionInfo(from: authToken) - let subscriptionTask = Task { - try await self.fetchSubscriptionStat( + let subscriptionTask = Task { + try await self.fetchSubscriptionStats( authToken: authToken, sessionInfo: sessionInfo, transport: transport) @@ -117,22 +117,23 @@ public struct KimiUsageFetcher: Sendable { } try Task.checkCancellation() - let subscriptionStat: KimiSubscriptionStatResponse? + let subscriptionStats: KimiSubscriptionStatsResponse? switch subscriptionOutcome { case let .value(response): - subscriptionStat = response + subscriptionStats = response case .timedOut: - Self.log.warning("Kimi subscription stat timed out") - subscriptionStat = nil + Self.log.warning("Kimi subscription stats timed out") + subscriptionStats = nil case let .failure(error): - Self.log.warning("Kimi subscription stat unavailable: \(error.localizedDescription)") - subscriptionStat = nil + Self.log.warning("Kimi subscription stats unavailable: \(error.localizedDescription)") + subscriptionStats = nil } return KimiUsageSnapshot( weekly: codingUsage.detail, rateLimit: codingUsage.limits?.first?.detail, - subscriptionBalance: subscriptionStat?.subscriptionBalance, + subscriptionBalance: subscriptionStats?.subscriptionBalance, + subscriptionCodeWeeklyLimit: subscriptionStats?.ratelimitCode7d, updatedAt: now) } @@ -196,26 +197,26 @@ public struct KimiUsageFetcher: Sendable { .appendingPathComponent("usages") } - private static func fetchSubscriptionStat( + private static func fetchSubscriptionStats( authToken: String, sessionInfo: SessionInfo?, - transport: any ProviderHTTPTransport) async throws -> KimiSubscriptionStatResponse? + transport: any ProviderHTTPTransport) async throws -> KimiSubscriptionStatsResponse? { - var request = self.webRequest(url: self.subscriptionStatURL, authToken: authToken, sessionInfo: sessionInfo) + var request = self.webRequest(url: self.subscriptionStatsURL, authToken: authToken, sessionInfo: sessionInfo) request.httpBody = Data("{}".utf8) do { let response = try await transport.response(for: request) guard response.statusCode == 200 else { - Self.log.warning("Kimi subscription stat returned \(response.statusCode)") + Self.log.warning("Kimi subscription stats returned \(response.statusCode)") return nil } - return try JSONDecoder().decode(KimiSubscriptionStatResponse.self, from: response.data) + return try JSONDecoder().decode(KimiSubscriptionStatsResponse.self, from: response.data) } catch { if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled { throw CancellationError() } - Self.log.warning("Kimi subscription stat unavailable: \(error.localizedDescription)") + Self.log.warning("Kimi subscription stats unavailable: \(error.localizedDescription)") return nil } } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift index 8c9195505d..98433d9db5 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift @@ -5,23 +5,27 @@ public struct KimiUsageSnapshot: Sendable { public let rateLimit: KimiUsageDetail? public let updatedAt: Date let subscriptionBalance: KimiSubscriptionBalance? + let subscriptionCodeWeeklyLimit: KimiSubscriptionRateLimit? public init(weekly: KimiUsageDetail, rateLimit: KimiUsageDetail?, updatedAt: Date) { self.weekly = weekly self.rateLimit = rateLimit self.updatedAt = updatedAt self.subscriptionBalance = nil + self.subscriptionCodeWeeklyLimit = nil } init( weekly: KimiUsageDetail, rateLimit: KimiUsageDetail?, subscriptionBalance: KimiSubscriptionBalance?, + subscriptionCodeWeeklyLimit: KimiSubscriptionRateLimit? = nil, updatedAt: Date) { self.weekly = weekly self.rateLimit = rateLimit self.subscriptionBalance = subscriptionBalance + self.subscriptionCodeWeeklyLimit = subscriptionCodeWeeklyLimit self.updatedAt = updatedAt } @@ -91,7 +95,7 @@ extension KimiUsageSnapshot { // the pool is shared across features, so amountUsedRatio is the real "subscription remaining". guard balance.feature == nil || balance.feature == "FEATURE_OMNI" else { return nil } guard balance.type == nil || balance.type == "SUBSCRIPTION" else { return nil } - guard let ratio = balance.amountUsedRatio else { return nil } + guard let ratio = balance.amountUsedRatio, ratio.isFinite else { return nil } let window = RateWindow( usedPercent: Self.clampedPercent(ratio * 100), windowMinutes: nil, @@ -100,6 +104,19 @@ extension KimiUsageSnapshot { return NamedRateWindow(id: "kimi-monthly", title: "Monthly", window: window) } + let subscriptionCodeWeeklyWindow = self.subscriptionCodeWeeklyLimit.flatMap { limit -> NamedRateWindow? in + guard limit.enabled != false else { return nil } + guard let ratio = limit.ratio, ratio.isFinite else { return nil } + let window = RateWindow( + usedPercent: Self.clampedPercent(ratio * 100), + windowMinutes: 7 * 24 * 60, + resetsAt: Self.parseDate(limit.resetTime), + resetDescription: nil) + return NamedRateWindow(id: "kimi-code-7d", title: "Code 7-day", window: window) + } + + let extraRateWindows = [monthlyWindow, subscriptionCodeWeeklyWindow].compactMap(\.self) + let identity = ProviderIdentitySnapshot( providerID: .kimi, accountEmail: nil, @@ -110,7 +127,7 @@ extension KimiUsageSnapshot { primary: weeklyWindow, secondary: rateLimitWindow, tertiary: nil, - extraRateWindows: monthlyWindow.map { [$0] }, + extraRateWindows: extraRateWindows.isEmpty ? nil : extraRateWindows, providerCost: nil, updatedAt: self.updatedAt, identity: identity) diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index e21ab1ff47..3adcd9d2f0 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -376,7 +376,7 @@ struct CodexBarWidgetProviderTests { } @Test - func `small and medium Kimi widgets keep all three persisted lane rows`() { + func `compact Kimi widgets keep established row fit while large widgets show all quotas`() { let now = Date(timeIntervalSince1970: 1_700_000_000) let entry = WidgetSnapshot.ProviderEntry( provider: .kimi, @@ -388,6 +388,7 @@ struct CodexBarWidgetProviderTests { WidgetSnapshot.WidgetUsageRowSnapshot(id: "primary", title: "Weekly", percentLeft: 75), WidgetSnapshot.WidgetUsageRowSnapshot(id: "secondary", title: "Rate Limit", percentLeft: 50), WidgetSnapshot.WidgetUsageRowSnapshot(id: "kimi-monthly", title: "Monthly", percentLeft: 25), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "kimi-code-7d", title: "Code 7-day", percentLeft: 90), ], creditsRemaining: nil, codeReviewRemainingPercent: nil, @@ -400,11 +401,13 @@ struct CodexBarWidgetProviderTests { let mediumRows = WidgetUsageRow.rows( for: entry, limit: WidgetUsageRow.mediumWidgetRowLimit(for: entry)) + let largeRows = WidgetUsageRow.rows(for: entry) #expect(WidgetUsageRow.smallWidgetRowLimit(for: entry) == 3) #expect(WidgetUsageRow.mediumWidgetRowLimit(for: entry) == 3) #expect(smallRows.map(\.id) == ["primary", "secondary", "kimi-monthly"]) #expect(mediumRows == smallRows) + #expect(largeRows.map(\.id) == ["primary", "secondary", "kimi-monthly", "kimi-code-7d"]) } @Test diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift index 1e15354222..77b3598735 100644 --- a/Tests/CodexBarTests/KimiProviderTests.swift +++ b/Tests/CodexBarTests/KimiProviderTests.swift @@ -426,12 +426,15 @@ struct KimiUsageResponseParsingTests { } """ - let response = try JSONDecoder().decode(KimiSubscriptionStatResponse.self, from: Data(json.utf8)) + let response = try JSONDecoder().decode(KimiSubscriptionStatsResponse.self, from: Data(json.utf8)) #expect(response.subscriptionBalance?.feature == "FEATURE_OMNI") #expect(response.subscriptionBalance?.type == "SUBSCRIPTION") #expect(response.subscriptionBalance?.amountUsedRatio == 1) #expect(response.subscriptionBalance?.expireTime == "2026-07-23T00:00:00Z") + #expect(response.ratelimitCode7d?.ratio == 0.0946) + #expect(response.ratelimitCode7d?.enabled == true) + #expect(response.ratelimitCode7d?.resetTime == "2026-07-09T06:56:36.876796734Z") } @Test @@ -511,6 +514,11 @@ struct KimiUsageResponseParsingTests { "type": "SUBSCRIPTION", "amountUsedRatio": 0.42, "expireTime": "2026-07-23T00:00:00Z" + }, + "ratelimitCode7d": { + "ratio": 0.17, + "enabled": true, + "resetTime": "2026-07-13T15:28:00Z" } } """ @@ -524,6 +532,7 @@ struct KimiUsageResponseParsingTests { if url.path.hasSuffix("/GetUsages") { return (Data(usageJSON.utf8), response) } + #expect(url.path.hasSuffix("/GetSubscriptionStats")) return (Data(subscriptionJSON.utf8), response) } @@ -531,10 +540,15 @@ struct KimiUsageResponseParsingTests { authToken: "test-token", transport: transport, subscriptionGrace: .seconds(1)) - let monthly = try #require(snapshot.toUsageSnapshot().extraRateWindows?.first) + let windows = try #require(snapshot.toUsageSnapshot().extraRateWindows) + let monthly = try #require(windows.first { $0.id == "kimi-monthly" }) + let weeklyCode = try #require(windows.first { $0.id == "kimi-code-7d" }) #expect(monthly.id == "kimi-monthly") #expect(monthly.window.usedPercent == 42) + #expect(weeklyCode.title == "Code 7-day") + #expect(weeklyCode.window.usedPercent == 17) + #expect(weeklyCode.window.windowMinutes == 7 * 24 * 60) } @Test @@ -723,6 +737,64 @@ struct KimiUsageSnapshotConversionTests { #expect(abs(monthly.window.usedPercent - 77.16) < 0.0001) } + @Test + func `converts subscription code weekly limit to extra window`() throws { + let now = Date() + let weeklyDetail = KimiUsageDetail( + limit: "2048", + used: "375", + remaining: "1673", + resetTime: "2026-01-09T15:23:13.373329235Z") + let subscriptionCodeWeeklyLimit = KimiSubscriptionRateLimit( + ratio: 0.0946, + enabled: true, + resetTime: "2026-07-13T15:28:00Z") + + let snapshot = KimiUsageSnapshot( + weekly: weeklyDetail, + rateLimit: nil, + subscriptionBalance: nil, + subscriptionCodeWeeklyLimit: subscriptionCodeWeeklyLimit, + updatedAt: now) + let usageSnapshot = snapshot.toUsageSnapshot() + + let weeklyCode = try #require(usageSnapshot.extraRateWindows?.first) + #expect(weeklyCode.id == "kimi-code-7d") + #expect(weeklyCode.title == "Code 7-day") + #expect(abs(weeklyCode.window.usedPercent - 9.46) < 0.0001) + #expect(weeklyCode.window.windowMinutes == 7 * 24 * 60) + #expect(weeklyCode.window.resetsAt == Self.date("2026-07-13T15:28:00Z")) + } + + @Test + func `omits disabled and nonfinite subscription quota ratios`() { + let weeklyDetail = KimiUsageDetail( + limit: "2048", + used: "375", + remaining: "1673", + resetTime: "2026-01-09T15:23:13.373329235Z") + let invalidLimits = [ + KimiSubscriptionRateLimit(ratio: 0.25, enabled: false, resetTime: nil), + KimiSubscriptionRateLimit(ratio: .nan, enabled: true, resetTime: nil), + KimiSubscriptionRateLimit(ratio: .infinity, enabled: true, resetTime: nil), + ] + + for limit in invalidLimits { + let snapshot = KimiUsageSnapshot( + weekly: weeklyDetail, + rateLimit: nil, + subscriptionBalance: KimiSubscriptionBalance( + feature: "FEATURE_OMNI", + type: "SUBSCRIPTION", + amountUsedRatio: .nan, + expireTime: nil), + subscriptionCodeWeeklyLimit: limit, + updatedAt: Date()) + + #expect(snapshot.toUsageSnapshot().extraRateWindows == nil) + } + } + @Test func `converts to usage snapshot without rate limit`() { let now = Date() diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index fbdf8124ea..502c315534 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -53,8 +53,8 @@ struct UsageStoreWidgetSnapshotTests { } @Test - func `widget snapshot includes Kimi monthly quota`() async throws { - let suite = "UsageStoreWidgetSnapshotTests-kimi-monthly" + func `widget snapshot includes Kimi subscription quota rows`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-kimi-subscription-rows" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -74,6 +74,22 @@ struct UsageStoreWidgetSnapshotTests { secondary: RateWindow(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil), tertiary: nil, extraRateWindows: [ + NamedRateWindow( + id: "kimi-code-7d", + title: "Code 7-day", + window: RateWindow( + usedPercent: 10, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "kimi-future-quota", + title: "Future quota", + window: RateWindow( + usedPercent: 5, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), NamedRateWindow( id: "kimi-monthly", title: "Monthly", @@ -95,14 +111,14 @@ struct UsageStoreWidgetSnapshotTests { store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } defer { store._test_widgetSnapshotSaveOverride = nil } - store.persistWidgetSnapshot(reason: "kimi-monthly-test") + store.persistWidgetSnapshot(reason: "kimi-subscription-rows-test") await store.widgetSnapshotPersistTask?.value let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .kimi }) // Widgets preserve persisted lane order; menu-only presentation may reorder these lanes. - #expect(entry.usageRows?.map(\.id) == ["primary", "secondary", "kimi-monthly"]) - #expect(entry.usageRows?.map(\.title) == ["Weekly", "Rate Limit", "Monthly"]) - #expect(entry.usageRows?.compactMap(\.percentLeft) == [75, 50, 25]) + #expect(entry.usageRows?.map(\.id) == ["primary", "secondary", "kimi-monthly", "kimi-code-7d"]) + #expect(entry.usageRows?.map(\.title) == ["Weekly", "Rate Limit", "Monthly", "Code 7-day"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [75, 50, 25, 90]) } @Test From 4484fad21c84f74c0f00f4e7daff3b58a056944d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 20:18:24 +0100 Subject: [PATCH 088/105] fix: detect Tahoe no-window menu block (#1954) --- CHANGELOG.md | 1 + .../MenuBarStatusItemDefaultsRepair.swift | 15 +- .../CodexBar/MenuBarVisibilityWatcher.swift | 75 +++++++-- Sources/CodexBar/StatusItemController.swift | 11 +- .../MenuBarVisibilityWatcherTests.swift | 144 +++++++++++++++++- ...tusItemControllerSplitLifecycleTests.swift | 22 +++ 6 files changed, 253 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c130d23420..9f6705c815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - 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 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/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index d923515ec0..500ca28d79 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -130,6 +130,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() @@ -743,8 +745,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) } @@ -758,12 +765,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/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift index d61069995a..cd100a2b49 100644 --- a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift +++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift @@ -244,7 +244,147 @@ struct MenuBarVisibilityWatcherTests { now: launchedAt.addingTimeInterval(2), snapshots: [detachedProxy], windowSnapshots: [blockedWindow], - detectTahoeBlockedProxy: true)) + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery retries expected hidden Tahoe item with enabled default and no window`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery ignores hidden Tahoe item without app and defaults visibility agreement`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let intentionallyHidden = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: false, + visibilityDefault: true, + snapshot: hidden) + let disabledByUser = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: false, + snapshot: hidden) + let unknownDefault = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: nil, + snapshot: hidden) + + for evidence in [intentionallyHidden, disabledByUser, unknownDefault] { + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + detectTahoeBlockedStatusItem: true)) + } + } + + @Test + func `startup recovery ignores hidden item when matching window still exists`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + let existingWindow = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 1500, y: 0, width: 76, height: 24), + isOnscreen: true, + displayBounds: CGRect(x: 0, y: 0, width: 2056, height: 1329)) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + windowSnapshots: [existingWindow], + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery ignores stale hidden matching window record`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + let staleWindow = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 1500, y: 0, width: 76, height: 24), + isOnscreen: false, + displayBounds: CGRect(x: 0, y: 0, width: 2056, height: 1329)) + + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence], + windowSnapshots: [staleWindow], + detectTahoeBlockedStatusItem: true)) + } + + @Test + func `startup recovery keeps hidden no-window detection Tahoe only`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 76) + let evidence = StatusItemStartupVisibilityEvidence( + autosaveName: "codexbar-merged", + expectsVisibility: true, + visibilityDefault: true, + snapshot: hidden) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [hidden], + evidence: [evidence])) } @Test @@ -262,7 +402,7 @@ struct MenuBarVisibilityWatcherTests { appLaunchedAt: launchedAt, now: launchedAt.addingTimeInterval(2), snapshots: [managed], - detectTahoeBlockedProxy: true)) + detectTahoeBlockedStatusItem: true)) } @Test diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index 6d4f605390..6b6cca6ff6 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -72,12 +72,14 @@ struct StatusItemControllerSplitLifecycleTests { #expect(controller.statusItems[.codex] != nil) #expect(controller.statusItems[.claude] != nil) + #expect(controller.expectedVisibleStatusItemAutosaveNames == ["codexbar-codex", "codexbar-claude"]) settings.mergeIcons = true controller.handleProviderConfigChange(reason: "test") #expect(controller.statusItem.isVisible == true) #expect(controller.statusItems.isEmpty) + #expect(controller.expectedVisibleStatusItemAutosaveNames == ["codexbar-merged"]) } @Test @@ -413,6 +415,26 @@ struct StatusItemControllerSplitLifecycleTests { #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-2") != nil) } + @Test + func `status item visibility default distinguishes enabled disabled and unset`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-visibility-default-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "NSStatusItem VisibleCC codexbar-merged") + defaults.set(false, forKey: "NSStatusItem VisibleCC codexbar-claude") + defer { defaults.removePersistentDomain(forName: suite) } + + #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: defaults, + autosaveName: "codexbar-merged") == true) + #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: defaults, + autosaveName: "codexbar-claude") == false) + #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( + defaults: defaults, + autosaveName: "codexbar-codex") == nil) + } + @Test func `non destructive visibility refresh preserves split provider status items`() throws { let (_, controller) = try self.makeSplitController() From 53f81ceeaf88d188d6b71374c7087aa0d14da3f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 21:04:29 +0100 Subject: [PATCH 089/105] fix: stop repeated browser keychain prompts (#1955) --- CHANGELOG.md | 1 + .../StatusItemController+Actions.swift | 21 ++- .../BrowserCookieAccessGate.swift | 175 ++++++++++++++++-- ...hboardBrowserCookieImporter+Deadline.swift | 6 +- .../CodexBarTests/BrowserDetectionTests.swift | 90 ++++++++- ...IDashboardBrowserCookieImporterTests.swift | 24 +++ docs/keychain-prompts.md | 5 + 7 files changed, 297 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f6705c815..6a92a60845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed - 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: use the real Flash quota in menu-bar metrics when an account has no Pro quota. Thanks @devYRPauli! diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 4067c2a4ad..92bb0a49dc 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -39,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) @@ -56,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 } @@ -88,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, diff --git a/Sources/CodexBarCore/BrowserCookieAccessGate.swift b/Sources/CodexBarCore/BrowserCookieAccessGate.swift index 0e10c92762..d896eb6fb0 100644 --- a/Sources/CodexBarCore/BrowserCookieAccessGate.swift +++ b/Sources/CodexBarCore/BrowserCookieAccessGate.swift @@ -19,12 +19,46 @@ public enum BrowserCookieAccessGate { private struct State { var loaded = false var deniedUntilByBrowser: [String: Date] = [:] + var chromiumFamilyDeniedUntil: Date? + } + + private final class ExplicitRetryScope: @unchecked Sendable { + private struct State { + var selectedBrowser: Browser? + var cookieReadClaimed = false + } + + private let lock = OSAllocatedUnfairLock(initialState: State()) + + func allows(_ browser: Browser) -> Bool { + self.lock.withLock { state in + if let selectedBrowser = state.selectedBrowser { + return selectedBrowser == browser && !state.cookieReadClaimed + } + state.selectedBrowser = browser + return true + } + } + + func contains(_ browser: Browser) -> Bool { + self.lock.withLock { $0.selectedBrowser == browser } + } + + func claimCookieRead(for browser: Browser) -> Bool { + self.lock.withLock { state in + guard state.selectedBrowser == browser, !state.cookieReadClaimed else { return false } + state.cookieReadClaimed = true + return true + } + } } private static let lock = OSAllocatedUnfairLock(initialState: State()) private static let defaultsKey = "browserCookieAccessDeniedUntil" + private static let chromiumFamilyDefaultsKey = "__chromiumFamily__" private static let cooldownInterval: TimeInterval = 60 * 60 * 6 private static let log = CodexBarLog.logger(LogCategories.browserCookieGate) + @TaskLocal private static var explicitRetryScope: ExplicitRetryScope? static let allowTestCookieAccessEnvironmentKey = "CODEXBAR_ALLOW_TEST_BROWSER_COOKIE_ACCESS" @@ -51,6 +85,12 @@ public enum BrowserCookieAccessGate { self.loadIfNeeded(&state) if let blockedUntil = state.deniedUntilByBrowser[browser.rawValue] { if blockedUntil > now { + if self.isExplicitRetryAllowed(for: browser) { + self.log.info( + "Explicit browser cookie retry allowed", + metadata: ["browser": browser.displayName]) + return true + } self.log.debug( "Cookie access blocked", metadata: ["browser": browser.displayName, "until": "\(blockedUntil.timeIntervalSince1970)"]) @@ -59,23 +99,64 @@ public enum BrowserCookieAccessGate { state.deniedUntilByBrowser.removeValue(forKey: browser.rawValue) self.persist(state) } + if let blockedUntil = state.chromiumFamilyDeniedUntil { + if blockedUntil > now { + self.log.debug( + "Chromium-family cookie access blocked", + metadata: ["browser": browser.displayName, "until": "\(blockedUntil.timeIntervalSince1970)"]) + return false + } + state.chromiumFamilyDeniedUntil = nil + self.persist(state) + } return true } guard shouldCheckKeychain else { return false } let requiresInteraction = self.chromiumKeychainRequiresInteraction(for: browser) - return self.lock.withLock { state in - self.loadIfNeeded(&state) - if requiresInteraction { - state.deniedUntilByBrowser[browser.rawValue] = now.addingTimeInterval(self.cooldownInterval) - self.persist(state) + if requiresInteraction { + self.recordDenied(for: browser, now: now) + if self.isExplicitRetryAllowed(for: browser) { self.log.info( - "Cookie access requires keychain interaction; suppressing", + "Browser cookie interaction allowed for explicit retry", metadata: ["browser": browser.displayName]) - return false + return true + } + self.log.info( + "Cookie access requires keychain interaction; suppressing Chromium family", + metadata: ["browser": browser.displayName]) + return false + } + self.log.debug("Cookie access allowed", metadata: ["browser": browser.displayName]) + return true + } + + public static func withExplicitRetry(_ operation: () throws -> T) rethrows -> T { + try self.$explicitRetryScope.withValue(ExplicitRetryScope()) { + try operation() + } + } + + public static func withExplicitRetry( + isolation: isolated (any Actor)? = #isolation, + operation: () async throws -> T) async rethrows -> T + { + try await self.$explicitRetryScope.withValue(ExplicitRetryScope()) { + try await operation() + } + } + + static func operationPreservingAccessContext( + _ operation: @escaping @Sendable () throws -> T) -> @Sendable () throws -> T + { + let interaction = ProviderInteractionContext.current + let retryScope = self.explicitRetryScope + return { + try ProviderInteractionContext.$current.withValue(interaction) { + try self.$explicitRetryScope.withValue(retryScope) { + try operation() + } } - self.log.debug("Cookie access allowed", metadata: ["browser": browser.displayName]) - return true } } @@ -91,25 +172,66 @@ public enum BrowserCookieAccessGate { self.lock.withLock { state in self.loadIfNeeded(&state) state.deniedUntilByBrowser[browser.rawValue] = blockedUntil + state.chromiumFamilyDeniedUntil = blockedUntil self.persist(state) } self.log .info( - "Browser cookie access denied; suppressing prompts", + "Browser cookie access denied; suppressing Chromium family", metadata: [ "browser": browser.displayName, "until": "\(blockedUntil.timeIntervalSince1970)", ]) } + static func recordAllowed(for browser: Browser) { + guard browser.usesKeychainForCookieDecryption, + ProviderInteractionContext.current == .userInitiated, + self.explicitRetryScope?.contains(browser) == true + else { return } + let clearedCooldown = self.lock.withLock { state in + self.loadIfNeeded(&state) + guard state.deniedUntilByBrowser[browser.rawValue] != nil, + state.chromiumFamilyDeniedUntil != nil + else { return false } + state.deniedUntilByBrowser.removeValue(forKey: browser.rawValue) + state.chromiumFamilyDeniedUntil = state.deniedUntilByBrowser.values.max() + self.persist(state) + return true + } + guard clearedCooldown else { return } + self.log.info( + "Explicit browser cookie retry succeeded", + metadata: ["browser": browser.displayName]) + } + + static func claimExplicitRetryCookieReadIfNeeded(for browser: Browser) -> Bool { + guard browser.usesKeychainForCookieDecryption, + ProviderInteractionContext.current == .userInitiated, + let retryScope = self.explicitRetryScope, + retryScope.contains(browser) + else { return true } + let hasActiveBrowserCooldown = self.lock.withLock { state in + self.loadIfNeeded(&state) + return state.deniedUntilByBrowser[browser.rawValue] != nil + } + guard hasActiveBrowserCooldown else { return true } + return retryScope.claimCookieRead(for: browser) + } + public static func resetForTesting() { self.lock.withLock { state in state.loaded = true state.deniedUntilByBrowser.removeAll() + state.chromiumFamilyDeniedUntil = nil UserDefaults.standard.removeObject(forKey: self.defaultsKey) } } + private static func isExplicitRetryAllowed(for browser: Browser) -> Bool { + ProviderInteractionContext.current == .userInitiated && self.explicitRetryScope?.allows(browser) == true + } + private static func chromiumKeychainRequiresInteraction(for browser: Browser) -> Bool { let labels = browser.safeStorageLabels.isEmpty ? self.safeStorageLabels : browser.safeStorageLabels for label in labels { @@ -137,11 +259,19 @@ public enum BrowserCookieAccessGate { guard let raw = UserDefaults.standard.dictionary(forKey: self.defaultsKey) as? [String: Double] else { return } - state.deniedUntilByBrowser = raw.compactMapValues { Date(timeIntervalSince1970: $0) } + state.chromiumFamilyDeniedUntil = raw[self.chromiumFamilyDefaultsKey].map(Date.init(timeIntervalSince1970:)) + state.deniedUntilByBrowser = raw + .filter { $0.key != self.chromiumFamilyDefaultsKey } + .mapValues { Date(timeIntervalSince1970: $0) } + if state.chromiumFamilyDeniedUntil == nil { + // Existing per-browser cooldowns become family-wide on upgrade. + state.chromiumFamilyDeniedUntil = state.deniedUntilByBrowser.values.max() + } } private static func persist(_ state: State) { - let raw = state.deniedUntilByBrowser.mapValues { $0.timeIntervalSince1970 } + var raw = state.deniedUntilByBrowser.mapValues { $0.timeIntervalSince1970 } + raw[self.chromiumFamilyDefaultsKey] = state.chromiumFamilyDeniedUntil?.timeIntervalSince1970 UserDefaults.standard.set(raw, forKey: self.defaultsKey) } } @@ -168,7 +298,15 @@ extension BrowserCookieClient { throw BrowserCookieStoreAccessSuppressedError() } guard BrowserCookieAccessGate.shouldAttempt(browser) else { return [] } - return try self.records(matching: query, in: browser, logger: logger) + guard BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: browser) else { return [] } + do { + let records = try self.records(matching: query, in: browser, logger: logger) + BrowserCookieAccessGate.recordAllowed(for: browser) + return records + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + throw error + } } } #else @@ -177,6 +315,17 @@ public enum BrowserCookieAccessGate { true } + public static func withExplicitRetry(_ operation: () throws -> T) rethrows -> T { + try operation() + } + + public static func withExplicitRetry( + isolation: isolated (any Actor)? = #isolation, + operation: () async throws -> T) async rethrows -> T + { + try await operation() + } + public static func recordIfNeeded(_ error: Error, now: Date = Date()) {} public static func recordDenied(for browser: Browser, now: Date = Date()) {} public static func resetForTesting() {} diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter+Deadline.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter+Deadline.swift index 751c4dacd7..49961abccc 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter+Deadline.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter+Deadline.swift @@ -47,14 +47,16 @@ extension OpenAIDashboardBrowserCookieImporter { timeoutObserver: (@Sendable () -> Void)? = nil, operation: @escaping @Sendable () throws -> T) async throws -> T { + // The detached/GCD loader does not inherit TaskLocal prompt policy or retry selection. + let contextualOperation = BrowserCookieAccessGate.operationPreservingAccessContext(operation) guard let deadline else { - return try await Task.detached(priority: .userInitiated, operation: operation).value + return try await Task.detached(priority: .userInitiated, operation: contextualOperation).value } let timeout = try self.remainingTimeout(until: deadline) let completion = CookieLoadCompletion() return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { - let result = Result(catching: operation) + let result = Result(catching: contextualOperation) completion.finish { continuation.resume(with: result) } } self.deadlineQueue.asyncAfter(deadline: .now() + timeout) { diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index 46be2cca91..b585d655a0 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -213,7 +213,7 @@ struct BrowserDetectionTests { } @Test - func `keychain interaction suppresses chromium cookie source during cooldown`() { + func `keychain interaction suppresses chromium family during cooldown`() { BrowserCookieAccessGate.resetForTesting() defer { BrowserCookieAccessGate.resetForTesting() } @@ -221,7 +221,7 @@ struct BrowserDetectionTests { var preflightCount = 0 KeychainAccessGate.withTaskOverrideForTesting(false) { - ProviderInteractionContext.$current.withValue(.userInitiated) { + ProviderInteractionContext.$current.withValue(.background) { KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in preflightCount += 1 return .interactionRequired @@ -234,6 +234,7 @@ struct BrowserDetectionTests { return .allowed } operation: { #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false) #expect( BrowserCookieAccessGate.shouldAttempt( .chrome, @@ -289,6 +290,68 @@ struct BrowserDetectionTests { #expect(preflightCount == 1) } + @Test + func `recorded browser denial suppresses automatic family and permits explicit source retry`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 1500) + var preflightCount = 0 + + KeychainAccessGate.withTaskOverrideForTesting(false) { + BrowserCookieAccessGate.recordIfNeeded( + BrowserCookieError.accessDenied(browser: .arc, details: "denied"), + now: start) + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + preflightCount += 1 + return .allowed + } operation: { + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(1)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(1)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.safari, now: start.addingTimeInterval(1)) == true) + } + BrowserCookieAccessGate.withExplicitRetry { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate + .shouldAttempt(.chrome, now: start.addingTimeInterval(2)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(2)) == true) + #expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc)) + BrowserCookieAccessGate.recordAllowed(for: .arc) + } + } + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(3)) == true) + } + } + } + + #expect(preflightCount == 2) + } + + @Test + func `denied explicit cookie read closes retry scope`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 1700) + BrowserCookieAccessGate.recordDenied(for: .arc, now: start) + + KeychainAccessGate.withTaskOverrideForTesting(false) { + BrowserCookieAccessGate.withExplicitRetry { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(1))) + #expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc)) + + BrowserCookieAccessGate.recordDenied(for: .arc, now: start.addingTimeInterval(2)) + + #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(3)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(3)) == false) + } + } + } + } + @Test func `chrome keychain preflight queries only chrome labels`() { BrowserCookieAccessGate.resetForTesting() @@ -336,7 +399,7 @@ struct BrowserDetectionTests { } @Test - func `browser keychain interaction suppresses only that browser`() throws { + func `browser keychain interaction suppresses family and permits scoped explicit retry`() throws { BrowserCookieAccessGate.resetForTesting() defer { BrowserCookieAccessGate.resetForTesting() } @@ -356,14 +419,25 @@ struct BrowserDetectionTests { if label == firstDiaLabel { return .interactionRequired } return .notFound } operation: { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == true) - #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(1)) == false) - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == true) - #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false) + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == true) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(1)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false) + } + BrowserCookieAccessGate.withExplicitRetry { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate + .shouldAttempt(.chrome, now: start.addingTimeInterval(61)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(61)) == true) + #expect(BrowserCookieAccessGate + .shouldAttempt(.edge, now: start.addingTimeInterval(61)) == false) + } + } } } - #expect(queriedLabels == [firstChromeLabel, firstDiaLabel, firstChromeLabel]) + #expect(queriedLabels == [firstChromeLabel, firstDiaLabel, firstDiaLabel]) #expect(queriedLabels.allSatisfy { allowedLabels.contains($0) }) } diff --git a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift index ec67914aa1..f581f01e55 100644 --- a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift @@ -188,6 +188,30 @@ struct OpenAIDashboardBrowserCookieImporterTests { #expect(timeoutProbe.firedAt == nil) } + @Test + func `bounded cookie loads preserve explicit retry context`() async throws { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + let start = Date() + + for deadline in [nil, Date().addingTimeInterval(1)] { + BrowserCookieAccessGate.resetForTesting() + BrowserCookieAccessGate.recordDenied(for: .arc, now: start) + + let allowed = try await BrowserCookieAccessGate.withExplicitRetry { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieLoad(deadline: deadline) { + KeychainAccessGate.withTaskOverrideForTesting(false) { + ProviderInteractionContext.current == .userInitiated && + BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(1)) + } + } + } + } + #expect(allowed) + } + } + @Test func `timed out cookie cache work stays ordered before retry`() async throws { let log = CookieOperationLog() diff --git a/docs/keychain-prompts.md b/docs/keychain-prompts.md index 1cb34c4a79..3b9c57d248 100644 --- a/docs/keychain-prompts.md +++ b/docs/keychain-prompts.md @@ -68,6 +68,11 @@ on Keychain can still work where the provider supports them. ## Browser Safe Storage prompts +If a Chromium-family Safe Storage check requires interaction or is denied, CodexBar pauses automatic cookie imports +for every Chromium-family browser for six hours. This prevents a denial in Arc, Edge, Brave, or another Chromium +browser from immediately moving to the next browser and showing another prompt. **Refresh Now** is an explicit retry +for the browser that was blocked; Safari and Firefox-family cookie imports remain available during the pause. + For normal browser-cookie import prompts, either allow CodexBar in the Keychain item's Access Control list or disable Keychain access: From 3e5ace217c25f0d8b280ac7be85b4460be2076cf Mon Sep 17 00:00:00 2001 From: Harsha Vardhan <80092815+harshav167@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:51:52 +1000 Subject: [PATCH 090/105] fix: add Model Studio region support to Alibaba Token Plan provider (#1934) * Fix Alibaba Token Plan for Model Studio region The Token Plan provider was hardcoded to bailian.console.aliyun.com (China mainland). International users are now on modelstudio.console. alibabacloud.com, so their cookies never matched and usage never loaded. Add AlibabaTokenPlanAPIRegion following the same pattern as the Coding Plan provider, defaulting to International (modelstudio). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: harden Alibaba Token Plan regions --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + ...ibabaTokenPlanProviderImplementation.swift | 35 ++- .../AlibabaTokenPlanSettingsStore.swift | 15 +- .../Shared/ProviderCookieSourceUI.swift | 4 +- .../StatusItemController+Actions.swift | 5 + Sources/CodexBarCLI/TokenAccountCLI.swift | 15 +- .../CodexBarCore/Config/CodexBarConfig.swift | 25 ++- .../Config/CodexBarConfigValidation.swift | 7 + Sources/CodexBarCore/CookieHeaderCache.swift | 16 ++ .../Alibaba/AlibabaTokenPlanAPIRegion.swift | 60 ++++++ .../AlibabaTokenPlanCookieHeader.swift | 5 +- .../AlibabaTokenPlanProviderDescriptor.swift | 47 +++- .../AlibabaTokenPlanSettingsReader.swift | 25 ++- .../AlibabaTokenPlanUsageFetcher.swift | 94 ++++---- .../Providers/ProviderSettingsSnapshot.swift | 12 +- ...AlibabaTokenPlanDashboardActionTests.swift | 27 +++ .../AlibabaTokenPlanProviderTests.swift | 202 +++++++++++++++--- .../CodexBarTests/ConfigValidationTests.swift | 41 ++++ Tests/CodexBarTests/TestStores.swift | 13 +- ...kenAccountEnvironmentPrecedenceTests.swift | 54 +++++ 20 files changed, 602 insertions(+), 101 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift create mode 100644 Tests/CodexBarTests/AlibabaTokenPlanDashboardActionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a92a60845..34bfd7a848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Kimi: show the subscription 7-day Code quota in menus and large widgets. Thanks @skyzer! ### Fixed +- 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! 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/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/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 92bb0a49dc..6fce93f050 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -279,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/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 2c712652c2..3ffd0a3b42 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -178,7 +178,11 @@ struct TokenAccountCLIContext { manualCookieHeader: cookieSettings.manualCookieHeader, apiRegion: self.resolveAlibabaCodingPlanRegion(config))) case .alibabatokenplan: - return self.makeSnapshot(alibabaTokenPlan: self.makeProviderCookieSettings(cookieSettings)) + return self.makeSnapshot( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: cookieSettings.cookieSource, + manualCookieHeader: cookieSettings.manualCookieHeader, + apiRegion: self.resolveAlibabaTokenPlanRegion(config))) case .factory: return self.makeSnapshot(factory: self.makeProviderCookieSettings(cookieSettings)) case .minimax: @@ -606,6 +610,15 @@ struct TokenAccountCLIContext { return AlibabaCodingPlanAPIRegion(rawValue: raw) ?? .international } + private func resolveAlibabaTokenPlanRegion(_ config: ProviderConfig?) -> AlibabaTokenPlanAPIRegion { + guard let raw = config?.region?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + return .chinaMainland + } + return AlibabaTokenPlanAPIRegion(rawValue: raw) ?? .chinaMainland + } + private static func kiloUsageDataSource(from source: ProviderSourceMode?) -> KiloUsageDataSource { guard let source else { return .auto } switch source { diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index 8e5d53f3a8..515f488fef 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -15,9 +15,10 @@ public struct CodexBarConfig: Codable, Sendable { metadata: [UsageProvider: ProviderMetadata] = ProviderDescriptorRegistry.metadata) -> CodexBarConfig { let providers = UsageProvider.allCases.map { provider in - ProviderConfig( - id: provider, - enabled: metadata[provider]?.defaultEnabled) + Self.defaultProviderConfig( + provider, + metadata: metadata, + alibabaTokenPlanRegion: .international) } return CodexBarConfig(version: Self.currentVersion, providers: providers) } @@ -57,9 +58,10 @@ public struct CodexBarConfig: Codable, Sendable { } for provider in UsageProvider.allCases where !seen.contains(provider) { - normalized.append(ProviderConfig( - id: provider, - enabled: metadata[provider]?.defaultEnabled)) + normalized.append(Self.defaultProviderConfig( + provider, + metadata: metadata, + alibabaTokenPlanRegion: .chinaMainland)) } return CodexBarConfig( @@ -91,6 +93,17 @@ public struct CodexBarConfig: Codable, Sendable { self.providers.append(config) } } + + private static func defaultProviderConfig( + _ provider: UsageProvider, + metadata: [UsageProvider: ProviderMetadata], + alibabaTokenPlanRegion: AlibabaTokenPlanAPIRegion) -> ProviderConfig + { + ProviderConfig( + id: provider, + enabled: metadata[provider]?.defaultEnabled, + region: provider == .alibabatokenplan ? alibabaTokenPlanRegion.rawValue : nil) + } } public struct ProviderConfig: Codable, Sendable, Identifiable { diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index 705fefd2be..bda5e5a8ea 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -271,6 +271,13 @@ public enum CodexBarConfigValidator { isValid: AlibabaCodingPlanAPIRegion(rawValue: region) != nil, displayName: "Alibaba Coding Plan", issues: &issues) + case .alibabatokenplan: + self.validateKnownRegion( + region, + provider: provider, + isValid: AlibabaTokenPlanAPIRegion(rawValue: region) != nil, + displayName: "Alibaba Token Plan", + issues: &issues) case .moonshot: self.validateKnownRegion( region, diff --git a/Sources/CodexBarCore/CookieHeaderCache.swift b/Sources/CodexBarCore/CookieHeaderCache.swift index f1b84d5a93..569cf8cb0c 100644 --- a/Sources/CodexBarCore/CookieHeaderCache.swift +++ b/Sources/CodexBarCore/CookieHeaderCache.swift @@ -15,6 +15,7 @@ public enum CookieHeaderCache { case managedAccount(UUID) case managedStoreUnreadable case profileHome(String) + case providerVariant(String) public var isolationIdentifier: String { switch self { @@ -24,6 +25,8 @@ public enum CookieHeaderCache { "managed-store-unreadable" case let .profileHome(path): "profile-home.\(Self.profileHomeDigest(path))" + case let .providerVariant(variant): + "provider-variant.\(Self.providerVariantDigest(variant))" } } @@ -44,6 +47,19 @@ public enum CookieHeaderCache { return String(digest, radix: 16) #endif } + + private static func providerVariantDigest(_ variant: String) -> String { + #if canImport(CryptoKit) + return SHA256.hash(data: Data(variant.utf8)) + .map { String(format: "%02x", $0) } + .joined() + #else + let digest = variant.utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in + (partial ^ UInt64(byte)) &* 1_099_511_628_211 + } + return String(digest, radix: 16) + #endif + } } public struct Entry: Codable, Sendable { diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift new file mode 100644 index 0000000000..ae7453f901 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift @@ -0,0 +1,60 @@ +import Foundation + +public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable { + case international = "intl" + case chinaMainland = "cn" + + public var displayName: String { + switch self { + case .international: + "International (modelstudio.console.alibabacloud.com)" + case .chinaMainland: + "China mainland (bailian.console.aliyun.com)" + } + } + + public var gatewayBaseURLString: String { + switch self { + case .international: + "https://modelstudio.console.alibabacloud.com" + case .chinaMainland: + "https://bailian.console.aliyun.com" + } + } + + public var dashboardOriginURLString: String { + self.gatewayBaseURLString + } + + public var dashboardURL: URL { + switch self { + case .international: + URL( + string: "https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=plan#/efm/subscription/token-plan")! + case .chinaMainland: + URL(string: "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan")! + } + } + + public var currentRegionID: String { + switch self { + case .international: + "ap-southeast-1" + case .chinaMainland: + "cn-beijing" + } + } + + public var tokenPlanProductCode: String { + switch self { + case .international: + "sfm_tokenplanteams_dp_intl" + case .chinaMainland: + "sfm_tokenplanteams_dp_cn" + } + } + + public var cookieCacheScope: CookieHeaderCache.Scope { + .providerVariant("alibaba-token-plan.\(self.rawValue)") + } +} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift index 11f515028c..14a99af975 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift @@ -80,14 +80,15 @@ struct AlibabaTokenPlanCookieHeaders { enum AlibabaTokenPlanCookieHeader { static func headers( from cookies: [HTTPCookie], + region: AlibabaTokenPlanAPIRegion = .international, environment: [String: String] = ProcessInfo.processInfo.environment) -> AlibabaTokenPlanCookieHeaders? { guard let apiHeader = self.header( from: cookies, - targetURL: AlibabaTokenPlanUsageFetcher.resolveQuotaURL(environment: environment)), + targetURL: AlibabaTokenPlanUsageFetcher.resolveQuotaURL(region: region, environment: environment)), let dashboardHeader = self.header( from: cookies, - targetURL: AlibabaTokenPlanUsageFetcher.dashboardURL(environment: environment)) + targetURL: AlibabaTokenPlanUsageFetcher.dashboardURL(region: region, environment: environment)) else { return nil } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift index 3f47d549a2..6b5012a91c 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift @@ -77,6 +77,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { func isAvailable(_ context: ProviderFetchContext) async -> Bool { guard context.settings?.alibabaTokenPlan?.cookieSource != .off else { return false } + let region = context.settings?.alibabaTokenPlan?.apiRegion ?? .international if AlibabaTokenPlanSettingsReader.cookieHeader(environment: context.env) != nil { return true @@ -89,7 +90,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { } #if os(macOS) - if let cached = CookieHeaderCache.load(provider: .alibabatokenplan), + if let cached = Self.cachedCookieEntry(region: region), !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true @@ -102,22 +103,25 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { let cookieSource = context.settings?.alibabaTokenPlan?.cookieSource ?? .auto - let cookieHeaders = try Self.resolveCookieHeaders(context: context, allowCached: true) + let region = context.settings?.alibabaTokenPlan?.apiRegion ?? .international + let cookieHeaders = try Self.resolveCookieHeaders(context: context, allowCached: true, region: region) do { let usage = try await AlibabaTokenPlanUsageFetcher.fetchUsage( apiCookieHeader: cookieHeaders.apiCookieHeader, dashboardCookieHeader: cookieHeaders.dashboardCookieHeader, + region: region, environment: context.env) return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") } catch let error as AlibabaTokenPlanUsageError where error.isCredentialFailure && cookieSource != .manual { #if os(macOS) - CookieHeaderCache.clear(provider: .alibabatokenplan) - let refreshedHeaders = try Self.resolveCookieHeaders(context: context, allowCached: false) + CookieHeaderCache.clear(provider: .alibabatokenplan, scope: region.cookieCacheScope) + let refreshedHeaders = try Self.resolveCookieHeaders(context: context, allowCached: false, region: region) let usage = try await AlibabaTokenPlanUsageFetcher.fetchUsage( apiCookieHeader: refreshedHeaders.apiCookieHeader, dashboardCookieHeader: refreshedHeaders.dashboardCookieHeader, + region: region, environment: context.env) return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") #else @@ -131,12 +135,14 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { } static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { - try self.resolveCookieHeaders(context: context, allowCached: allowCached).apiCookieHeader + try self.resolveCookieHeaders(context: context, allowCached: allowCached, region: .international) + .apiCookieHeader } static func resolveCookieHeaders( context: ProviderFetchContext, - allowCached: Bool) throws -> AlibabaTokenPlanCookieHeaders + allowCached: Bool, + region: AlibabaTokenPlanAPIRegion = .international) throws -> AlibabaTokenPlanCookieHeaders { if let settings = context.settings?.alibabaTokenPlan, settings.cookieSource == .manual @@ -170,7 +176,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { #if os(macOS) if allowCached, - let cached = CookieHeaderCache.load(provider: .alibabatokenplan), + let cached = Self.cachedCookieEntry(region: region), let headers = AlibabaTokenPlanCookieHeaders(cachedHeader: cached.cookieHeader) { Self.log.info( @@ -192,6 +198,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { let rawCookieNames = session.cookies.map(\.name).filter { !$0.isEmpty }.uniquedSorted() guard let headers = AlibabaTokenPlanCookieHeader.headers( from: session.cookies, + region: region, environment: context.env) else { Self.log.warning( @@ -205,6 +212,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { } CookieHeaderCache.store( provider: .alibabatokenplan, + scope: region.cookieCacheScope, cookieHeader: headers.cacheCookieHeader, sourceLabel: session.sourceLabel) Self.log.info( @@ -229,6 +237,31 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { #endif } + #if os(macOS) + /// The former unscoped cache only ever represented the China gateway. Never expose it to + /// International requests; migrate it into the China scope after a successful scoped write. + private static func cachedCookieEntry(region: AlibabaTokenPlanAPIRegion) -> CookieHeaderCache.Entry? { + if let scoped = CookieHeaderCache.load(provider: .alibabatokenplan, scope: region.cookieCacheScope) { + return scoped + } + guard region == .chinaMainland, + let legacy = CookieHeaderCache.load(provider: .alibabatokenplan) + else { return nil } + + CookieHeaderCache.store( + provider: .alibabatokenplan, + scope: region.cookieCacheScope, + cookieHeader: legacy.cookieHeader, + sourceLabel: legacy.sourceLabel, + now: legacy.storedAt) + if let migrated = CookieHeaderCache.load(provider: .alibabatokenplan, scope: region.cookieCacheScope) { + CookieHeaderCache.clear(provider: .alibabatokenplan) + return migrated + } + return legacy + } + #endif + private static func missingCookieDetails(from error: Error) -> String? { if case let AlibabaCodingPlanSettingsError.missingCookie(details) = error { return details diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift index 8a0f6c1dad..1074d6c252 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanSettingsReader.swift @@ -5,6 +5,12 @@ public struct AlibabaTokenPlanSettingsReader: Sendable { public static let hostKey = "ALIBABA_TOKEN_PLAN_HOST" public static let quotaURLKey = "ALIBABA_TOKEN_PLAN_QUOTA_URL" + private static let endpointValidator = ProviderEndpointOverrideValidator( + allowedHosts: [ + "modelstudio.console.alibabacloud.com", + "bailian.console.aliyun.com", + ]) + public static func cookieHeader( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { @@ -14,21 +20,17 @@ public struct AlibabaTokenPlanSettingsReader: Sendable { public static func hostOverride( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { - guard let raw = self.cleaned(environment[self.hostKey]) else { return nil } - if let scheme = URL(string: raw)?.scheme { - return scheme.lowercased() == "https" ? raw : nil - } - return raw + self.endpointValidator.validatedHost( + self.cleaned(environment[self.hostKey]), + policy: .allowAnyHTTPSHost) } public static func quotaURL( environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { - guard let raw = self.cleaned(environment[self.quotaURLKey]) else { return nil } - if let url = URL(string: raw), let scheme = url.scheme { - return scheme.lowercased() == "https" ? url : nil - } - return URL(string: "https://\(raw)") + self.endpointValidator.validatedURL( + self.cleaned(environment[self.quotaURLKey]), + policy: .allowAnyHTTPSHost) } static func cleaned(_ raw: String?) -> String? { @@ -53,7 +55,8 @@ public enum AlibabaTokenPlanSettingsError: LocalizedError, Sendable { switch self { case let .missingCookie(details): let base = "No Alibaba Token Plan session cookies found in browsers. " + - "Sign in to Bailian in Chrome, allow CodexBar to access Chrome Safe Storage in Keychain Access, " + + "Sign in to Model Studio/Bailian in Chrome, " + + "allow CodexBar to access Chrome Safe Storage in Keychain Access, " + "or paste a manual Cookie header." guard let details, !details.isEmpty else { return base } return "\(base) \(details)" diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 93bc5d93ab..27aee5a635 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -29,12 +29,8 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { // swiftlint:disable:next type_body_length public struct AlibabaTokenPlanUsageFetcher: Sendable { private static let log = CodexBarLog.logger("alibaba-token-plan") - private static let gatewayBaseURLString = "https://bailian.console.aliyun.com" - private static let dashboardOriginURLString = "https://bailian.console.aliyun.com" - private static let currentRegionID = "cn-beijing" private static let bssServiceCode = "BssOpenAPI-V3" private static let subscriptionSummaryAction = "GetSubscriptionSummary" - private static let tokenPlanProductCode = "sfm_tokenplanteams_dp_cn" private static let browserLikeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" @@ -43,11 +39,29 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15" public static var dashboardURL: URL { - URL(string: "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan")! + Self.dashboardURL(region: .international, environment: ProcessInfo.processInfo.environment) + } + + public static func dashboardURL( + region: AlibabaTokenPlanAPIRegion, + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let host = AlibabaTokenPlanSettingsReader.hostOverride(environment: environment), + let base = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: host), + var components = URLComponents(url: base, resolvingAgainstBaseURL: false), + let dashboardComponents = URLComponents(url: region.dashboardURL, resolvingAgainstBaseURL: false) + { + components.path = dashboardComponents.path + components.percentEncodedQuery = dashboardComponents.percentEncodedQuery + components.fragment = dashboardComponents.fragment + return components.url ?? region.dashboardURL + } + return region.dashboardURL } public static func fetchUsage( cookieHeader: String, + region: AlibabaTokenPlanAPIRegion = .international, environment: [String: String] = ProcessInfo.processInfo.environment, now: Date = Date()) async throws -> AlibabaTokenPlanUsageSnapshot { @@ -57,6 +71,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return try await self.fetchUsage( apiCookieHeader: headers.apiCookieHeader, dashboardCookieHeader: headers.dashboardCookieHeader, + region: region, environment: environment, now: now) } @@ -64,6 +79,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { static func fetchUsage( apiCookieHeader: String, dashboardCookieHeader: String, + region: AlibabaTokenPlanAPIRegion = .international, environment: [String: String] = ProcessInfo.processInfo.environment, now: Date = Date(), session overrideSession: URLSession? = nil) async throws -> AlibabaTokenPlanUsageSnapshot @@ -74,7 +90,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { throw AlibabaTokenPlanSettingsError.invalidCookie } - let url = self.resolveQuotaURL(environment: environment) + let url = self.resolveQuotaURL(region: region, environment: environment) let apiRedirectDiagnostics = RedirectDiagnostics(cookieHeader: normalizedAPIHeader) let dashboardRedirectDiagnostics: RedirectDiagnostics? let apiSession: URLSession @@ -104,12 +120,14 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { let secToken = await self.resolveSECToken( dashboardCookieHeader: normalizedDashboardHeader, apiCookieHeader: normalizedAPIHeader, + region: region, environment: environment, session: dashboardSession) Self.log.info( "Fetching Alibaba Token Plan usage", metadata: [ "apiHost": url.host ?? "unknown", + "region": region.rawValue, "apiCookieNames": self.cookieNamesDescription(from: normalizedAPIHeader), "dashboardCookieNames": self.cookieNamesDescription(from: normalizedDashboardHeader), "hasCSRF": self.hasCSRF(in: normalizedAPIHeader) ? "1" : "0", @@ -119,7 +137,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 20 - request.httpBody = self.subscriptionSummaryRequestBody(secToken: secToken) + request.httpBody = self.subscriptionSummaryRequestBody(region: region, secToken: secToken) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("*/*", forHTTPHeaderField: "Accept") request.setValue(normalizedAPIHeader, forHTTPHeaderField: "Cookie") @@ -131,8 +149,10 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") request.setValue(Self.browserLikeUserAgent, forHTTPHeaderField: "User-Agent") - request.setValue(Self.dashboardOriginURLString, forHTTPHeaderField: "Origin") - request.setValue(Self.dashboardURL.absoluteString, forHTTPHeaderField: "Referer") + request.setValue(region.dashboardOriginURLString, forHTTPHeaderField: "Origin") + request.setValue( + Self.dashboardURL(region: region, environment: environment).absoluteString, + forHTTPHeaderField: "Referer") let data: Data let response: URLResponse @@ -185,7 +205,10 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return try self.parseUsageSnapshot(from: data, now: now) } - static func resolveQuotaURL(environment: [String: String]) -> URL { + static func resolveQuotaURL( + region: AlibabaTokenPlanAPIRegion, + environment: [String: String]) -> URL + { if let override = AlibabaTokenPlanSettingsReader.quotaURL(environment: environment) { return override } @@ -194,11 +217,15 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { { return hostURL } - return self.defaultQuotaURL + return self.defaultQuotaURL(region: region) } static var defaultQuotaURL: URL { - var components = URLComponents(string: Self.gatewayBaseURLString)! + self.defaultQuotaURL(region: .international) + } + + static func defaultQuotaURL(region: AlibabaTokenPlanAPIRegion) -> URL { + var components = URLComponents(string: region.gatewayBaseURLString)! components.path = "/data/api.json" components.queryItems = [ URLQueryItem(name: "action", value: Self.subscriptionSummaryAction), @@ -253,8 +280,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { updatedAt: now) } - private static func subscriptionSummaryRequestBody(secToken: String?) -> Data { - let paramsObject = ["ProductCode": Self.tokenPlanProductCode] + private static func subscriptionSummaryRequestBody(region: AlibabaTokenPlanAPIRegion, secToken: String?) -> Data { + let paramsObject = ["ProductCode": region.tokenPlanProductCode] guard let paramsData = try? JSONSerialization.data(withJSONObject: paramsObject, options: []), let paramsString = String(data: paramsData, encoding: .utf8) else { @@ -266,7 +293,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { URLQueryItem(name: "product", value: Self.bssServiceCode), URLQueryItem(name: "action", value: Self.subscriptionSummaryAction), URLQueryItem(name: "params", value: paramsString), - URLQueryItem(name: "region", value: Self.currentRegionID), + URLQueryItem(name: "region", value: region.currentRegionID), ] if let secToken, !secToken.isEmpty { queryItems.append(URLQueryItem(name: "sec_token", value: secToken)) @@ -278,12 +305,13 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { private static func resolveSECToken( dashboardCookieHeader: String, apiCookieHeader: String, + region: AlibabaTokenPlanAPIRegion, environment: [String: String], session: URLSession) async -> String? { let cookieSECToken = self.extractCookieValue(name: "sec_token", from: dashboardCookieHeader) ?? self.extractCookieValue(name: "sec_token", from: apiCookieHeader) - var request = URLRequest(url: self.dashboardURL(environment: environment)) + var request = URLRequest(url: self.dashboardURL(region: region, environment: environment)) request.httpMethod = "GET" request.timeoutInterval = 10 request.setValue(dashboardCookieHeader, forHTTPHeaderField: "Cookie") @@ -309,6 +337,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { if let token = await self.fetchSECTokenFromUserInfo( cookieHeader: dashboardCookieHeader, + region: region, environment: environment, session: session) { @@ -331,10 +360,11 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { private static func fetchSECTokenFromUserInfo( cookieHeader: String, + region: AlibabaTokenPlanAPIRegion, environment: [String: String], session: URLSession) async -> String? { - let baseURL = self.consoleBaseURL(environment: environment) + let baseURL = self.consoleBaseURL(region: region, environment: environment) let userInfoURL = baseURL.appendingPathComponent("tool/user/info.json") var request = URLRequest(url: userInfoURL) request.httpMethod = "GET" @@ -369,41 +399,31 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return token } - private static func consoleBaseURL(environment: [String: String]) -> URL { - let dashboard = self.dashboardURL(environment: environment) + private static func consoleBaseURL( + region: AlibabaTokenPlanAPIRegion, + environment: [String: String]) -> URL + { + let dashboard = self.dashboardURL(region: region, environment: environment) var components = URLComponents() components.scheme = dashboard.scheme components.host = dashboard.host components.port = dashboard.port - return components.url ?? URL(string: Self.dashboardOriginURLString)! + return components.url ?? URL(string: region.dashboardOriginURLString)! } private static func quotaURL(from rawHost: String) -> URL? { let cleaned = AlibabaTokenPlanSettingsReader.cleaned(rawHost) guard let cleaned else { return nil } - let base = URL(string: cleaned)?.scheme == nil ? URL(string: "https://\(cleaned)") : URL(string: cleaned) - guard let base else { return nil } + guard let base = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: cleaned) else { return nil } var components = URLComponents(url: base, resolvingAgainstBaseURL: false) - let defaultComponents = URLComponents(url: Self.defaultQuotaURL, resolvingAgainstBaseURL: false) + let defaultComponents = URLComponents( + url: Self.defaultQuotaURL(region: .international), + resolvingAgainstBaseURL: false) components?.path = "/data/api.json" components?.queryItems = defaultComponents?.queryItems return components?.url } - static func dashboardURL(environment: [String: String]) -> URL { - if let host = AlibabaTokenPlanSettingsReader.hostOverride(environment: environment), - let base = URL(string: host)?.scheme == nil ? URL(string: "https://\(host)") : URL(string: host), - var components = URLComponents(url: base, resolvingAgainstBaseURL: false), - let dashboardComponents = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) - { - components.path = dashboardComponents.path - components.percentEncodedQuery = dashboardComponents.percentEncodedQuery - components.fragment = dashboardComponents.fragment - return components.url ?? self.dashboardURL - } - return Self.dashboardURL - } - private final class RedirectDiagnostics: NSObject, URLSessionTaskDelegate, @unchecked Sendable { private let cookieHeader: String var redirects: [String] = [] diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 108c25d7c5..09a53275de 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -179,10 +179,20 @@ public struct ProviderSettingsSnapshot: Sendable { public struct AlibabaTokenPlanProviderSettings: ProviderCookieSettings { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? + public let apiRegion: AlibabaTokenPlanAPIRegion - public init(cookieSource: ProviderCookieSource = .auto, manualCookieHeader: String? = nil) { + public init( + cookieSource: ProviderCookieSource = .auto, + manualCookieHeader: String? = nil, + apiRegion: AlibabaTokenPlanAPIRegion = .international) + { self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader + self.apiRegion = apiRegion + } + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.init(cookieSource: cookieSource, manualCookieHeader: manualCookieHeader, apiRegion: .international) } } diff --git a/Tests/CodexBarTests/AlibabaTokenPlanDashboardActionTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanDashboardActionTests.swift new file mode 100644 index 0000000000..e9401ffb03 --- /dev/null +++ b/Tests/CodexBarTests/AlibabaTokenPlanDashboardActionTests.swift @@ -0,0 +1,27 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct AlibabaTokenPlanDashboardActionTests { + @Test + func `dashboard action follows selected region`() { + let settings = testSettingsStore(suiteName: "AlibabaTokenPlanDashboardActionTests") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.alibabaTokenPlanAPIRegion = .chinaMainland + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in + #expect(controller.dashboardURL(for: .alibabatokenplan) == + AlibabaTokenPlanAPIRegion.chinaMainland.dashboardURL) + } + } +} diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index fb17b4d0fa..e6233e0ca2 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -47,13 +47,21 @@ struct AlibabaTokenPlanSettingsReaderTests { ]) #expect(httpHost == nil) - #expect(httpsHost == "https://dashboard.token-plan.test") + #expect(httpsHost == "dashboard.token-plan.test") #expect(bareHost == "dashboard.token-plan.test") } @Test func `default quota URL targets subscription summary API`() { let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL + #expect(url.host == "modelstudio.console.alibabacloud.com") + #expect(url.absoluteString.contains("GetSubscriptionSummary")) + #expect(url.absoluteString.contains("BssOpenAPI-V3")) + } + + @Test + func `default quota URL for china mainland targets bailian`() { + let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .chinaMainland) #expect(url.host == "bailian.console.aliyun.com") #expect(url.absoluteString.contains("GetSubscriptionSummary")) #expect(url.absoluteString.contains("BssOpenAPI-V3")) @@ -63,6 +71,26 @@ struct AlibabaTokenPlanSettingsReaderTests { struct AlibabaTokenPlanCookieHeaderTests { @Test func `builds URL scoped headers for API and dashboard`() throws { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".alibabacloud.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".alibabacloud.com"), + self.cookie(name: "sec_token", value: "shared", domain: ".console.alibabacloud.com"), + self.cookie(name: "sec_token", value: "dashboard", domain: "modelstudio.console.alibabacloud.com"), + self.cookie(name: "bailian_only", value: "bailian", domain: "bailian.console.aliyun.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies)) + + #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) + #expect(headers.apiCookieHeader.contains("login_current_pk=account")) + #expect(headers.apiCookieHeader.contains("sec_token=dashboard")) + #expect(!headers.apiCookieHeader.contains("bailian_only=bailian")) + #expect(headers.dashboardCookieHeader.contains("sec_token=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("bailian_only=bailian")) + } + + @Test + func `builds URL scoped headers for china mainland region`() throws { let cookies = [ self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"), self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), @@ -71,7 +99,7 @@ struct AlibabaTokenPlanCookieHeaderTests { self.cookie(name: "modelstudio_only", value: "modelstudio", domain: "modelstudio.console.alibabacloud.com"), ] - let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies)) + let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies, region: .chinaMainland)) #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) #expect(headers.apiCookieHeader.contains("login_current_pk=account")) @@ -101,8 +129,11 @@ struct AlibabaTokenPlanCookieHeaderTests { self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), - self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"), - self.cookie(name: "prod_dashboard_only", value: "prod-dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "prod_api_only", value: "prod-api", domain: "modelstudio.console.alibabacloud.com"), + self.cookie( + name: "prod_dashboard_only", + value: "prod-dashboard", + domain: "modelstudio.console.alibabacloud.com"), ] let headers = try #require(AlibabaTokenPlanCookieHeader.headers( @@ -346,11 +377,19 @@ struct AlibabaTokenPlanUsageParsingTests { AlibabaTokenPlanStubURLProtocol.handler = nil } + let hostOverride = "https://alibaba-token-plan.test:9443" + let environment: [String: String] = [ + AlibabaTokenPlanSettingsReader.hostKey: hostOverride, + ] + let expectedReferer = AlibabaTokenPlanUsageFetcher.dashboardURL( + region: .international, + environment: environment).absoluteString + AlibabaTokenPlanStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } if url.host == "alibaba-token-plan.test", - url.path == "/cn-beijing", + url.path == "/ap-southeast-1/", request.httpMethod == "GET" { #expect(url.port == 9443) @@ -378,15 +417,14 @@ struct AlibabaTokenPlanUsageParsingTests { if url.host == "alibaba-token-plan.test", request.httpMethod == "POST" { #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") - #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com") - #expect(request.value(forHTTPHeaderField: "Referer") == AlibabaTokenPlanUsageFetcher.dashboardURL - .absoluteString) + #expect(request.value(forHTTPHeaderField: "Origin") == "https://modelstudio.console.alibabacloud.com") + #expect(request.value(forHTTPHeaderField: "Referer") == expectedReferer) let body = Self.requestBodyString(from: request) #expect(body.contains("sec_token=user-info-token")) #expect(body.contains("GetSubscriptionSummary")) #expect(body.contains("BssOpenAPI-V3")) #expect(body.contains("ProductCode")) - #expect(body.contains("sfm_tokenplanteams_dp_cn")) + #expect(body.contains("sfm_tokenplanteams_dp_intl")) let json = """ { "Success": true, @@ -409,7 +447,7 @@ struct AlibabaTokenPlanUsageParsingTests { let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( apiCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", dashboardCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", - environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://alibaba-token-plan.test:9443"], + environment: environment, session: session) #expect(snapshot.planName == "TOKEN PLAN") @@ -565,6 +603,13 @@ struct AlibabaTokenPlanWebStrategyTests { } } + private func clearCookieCaches() { + CookieHeaderCache.clear(provider: .alibabatokenplan) + for region in AlibabaTokenPlanAPIRegion.allCases { + CookieHeaderCache.clear(provider: .alibabatokenplan, scope: region.cookieCacheScope) + } + } + @Test func `auto web strategy surfaces cookie import errors`() async throws { let strategy = AlibabaTokenPlanWebFetchStrategy() @@ -585,13 +630,14 @@ struct AlibabaTokenPlanWebStrategyTests { claudeFetcher: StubClaudeFetcher(), browserDetection: BrowserDetection(cacheTTL: 0)) - CookieHeaderCache.clear(provider: .alibabatokenplan) + self.clearCookieCaches() AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in throw AlibabaCodingPlanSettingsError.missingCookie( details: "macOS Keychain denied access to Chrome Safe Storage.") } defer { AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + self.clearCookieCaches() } #expect(await strategy.isAvailable(context)) @@ -630,35 +676,38 @@ struct AlibabaTokenPlanWebStrategyTests { claudeFetcher: StubClaudeFetcher(), browserDetection: BrowserDetection(cacheTTL: 0)) - CookieHeaderCache.clear(provider: .alibabatokenplan) + self.clearCookieCaches() AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in AlibabaCodingPlanCookieImporter.SessionInfo( cookies: [ - self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"), - self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), - self.cookie(name: "dashboard_only", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".alibabacloud.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".alibabacloud.com"), self.cookie( - name: "modelstudio_only", - value: "modelstudio", + name: "dashboard_only", + value: "dashboard", domain: "modelstudio.console.alibabacloud.com"), - self.cookie(name: "alibabacloud_only", value: "cloud", domain: ".alibabacloud.com"), + self.cookie( + name: "bailian_only", + value: "bailian", + domain: "bailian.console.aliyun.com"), + self.cookie(name: "aliyun_only", value: "aliyun", domain: ".aliyun.com"), ], sourceLabel: "Chrome Default") } defer { AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil - CookieHeaderCache.clear(provider: .alibabatokenplan) + self.clearCookieCaches() } let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) #expect(headers.apiCookieHeader == headers.dashboardCookieHeader) #expect(headers.apiCookieHeader.contains("dashboard_only=dashboard")) - #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) - #expect(!headers.apiCookieHeader.contains("alibabacloud_only=cloud")) + #expect(!headers.apiCookieHeader.contains("bailian_only=bailian")) + #expect(!headers.apiCookieHeader.contains("aliyun_only=aliyun")) #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) - #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) - #expect(!headers.dashboardCookieHeader.contains("alibabacloud_only=cloud")) + #expect(!headers.dashboardCookieHeader.contains("bailian_only=bailian")) + #expect(!headers.dashboardCookieHeader.contains("aliyun_only=aliyun")) AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import") @@ -694,7 +743,7 @@ struct AlibabaTokenPlanWebStrategyTests { claudeFetcher: StubClaudeFetcher(), browserDetection: BrowserDetection(cacheTTL: 0)) - CookieHeaderCache.clear(provider: .alibabatokenplan) + self.clearCookieCaches() AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in AlibabaCodingPlanCookieImporter.SessionInfo( cookies: [ @@ -711,7 +760,7 @@ struct AlibabaTokenPlanWebStrategyTests { } defer { AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil - CookieHeaderCache.clear(provider: .alibabatokenplan) + self.clearCookieCaches() } let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) @@ -722,6 +771,109 @@ struct AlibabaTokenPlanWebStrategyTests { #expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard")) } + @Test + func `cached browser cookies stay isolated by gateway region`() throws { + self.clearCookieCaches() + defer { self.clearCookieCaches() } + CookieHeaderCache.store( + provider: .alibabatokenplan, + scope: AlibabaTokenPlanAPIRegion.international.cookieCacheScope, + cookieHeader: "login_aliyunid_ticket=intl-ticket; gateway=intl", + sourceLabel: "International fixture") + CookieHeaderCache.store( + provider: .alibabatokenplan, + scope: AlibabaTokenPlanAPIRegion.chinaMainland.cookieCacheScope, + cookieHeader: "login_aliyunid_ticket=cn-ticket; gateway=cn", + sourceLabel: "China fixture") + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import") + } + defer { AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil } + + let context = self.context(region: .international) + let international = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true, + region: .international) + let china = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true, + region: .chinaMainland) + + #expect(international.apiCookieHeader.contains("gateway=intl")) + #expect(!international.apiCookieHeader.contains("gateway=cn")) + #expect(china.apiCookieHeader.contains("gateway=cn")) + #expect(!china.apiCookieHeader.contains("gateway=intl")) + } + + @Test + func `legacy unscoped cache migrates only to China gateway`() throws { + self.clearCookieCaches() + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + self.clearCookieCaches() + } + CookieHeaderCache.store( + provider: .alibabatokenplan, + cookieHeader: "login_aliyunid_ticket=legacy; gateway=legacy-cn", + sourceLabel: "Legacy fixture") + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + AlibabaCodingPlanCookieImporter.SessionInfo( + cookies: [ + self.cookie( + name: "login_aliyunid_ticket", + value: "intl", + domain: ".alibabacloud.com"), + self.cookie( + name: "gateway", + value: "intl", + domain: "modelstudio.console.alibabacloud.com"), + ], + sourceLabel: "International fixture") + } + let context = self.context(region: .international) + + let international = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true, + region: .international) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected China import") + } + let china = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true, + region: .chinaMainland) + + #expect(international.apiCookieHeader.contains("gateway=intl")) + #expect(!international.apiCookieHeader.contains("gateway=legacy-cn")) + #expect(china.apiCookieHeader.contains("gateway=legacy-cn")) + #expect(CookieHeaderCache.load(provider: .alibabatokenplan) == nil) + #expect(CookieHeaderCache.load( + provider: .alibabatokenplan, + scope: AlibabaTokenPlanAPIRegion.chinaMainland.cookieCacheScope) != nil) + } + + private func context(region: AlibabaTokenPlanAPIRegion) -> ProviderFetchContext { + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil, + apiRegion: region)) + return ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + private func cookie( name: String, value: String, diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 1e1c0a500c..8cdab9e3df 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -3,6 +3,47 @@ import Foundation import Testing struct ConfigValidationTests { + @Test + func `fresh config defaults Alibaba Token Plan to International`() throws { + let config = CodexBarConfig.makeDefault() + let provider = try #require(config.providerConfig(for: .alibabatokenplan)) + let issues = CodexBarConfigValidator.validate(config) + + #expect(provider.region == AlibabaTokenPlanAPIRegion.international.rawValue) + #expect(!issues.contains(where: { $0.provider == .alibabatokenplan })) + } + + @Test + func `normalization preserves legacy Alibaba Token Plan region`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .alibabatokenplan, region: nil), + ]).normalized() + let provider = try #require(config.providerConfig(for: .alibabatokenplan)) + + #expect(provider.region == nil) + } + + @Test + func `normalization adds missing Alibaba Token Plan as China mainland`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .codex), + ]).normalized() + let provider = try #require(config.providerConfig(for: .alibabatokenplan)) + + #expect(provider.region == AlibabaTokenPlanAPIRegion.chinaMainland.rawValue) + } + + @Test + func `reports invalid Alibaba Token Plan region`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .alibabatokenplan, region: "nowhere")) + let issues = CodexBarConfigValidator.validate(config) + + #expect(issues.contains(where: { + $0.provider == .alibabatokenplan && $0.code == "invalid_region" + })) + } + @Test func `reports unsupported source`() { var config = CodexBarConfig.makeDefault() diff --git a/Tests/CodexBarTests/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index c42b6f854b..9098bf46eb 100644 --- a/Tests/CodexBarTests/TestStores.swift +++ b/Tests/CodexBarTests/TestStores.swift @@ -139,16 +139,25 @@ func testConfigStore(suiteName: String, reset: Bool = true) -> CodexBarConfigSto @MainActor func testSettingsStore( suiteName: String, - tokenAccountStore: any ProviderTokenAccountStoring = InMemoryTokenAccountStore()) -> SettingsStore + tokenAccountStore: any ProviderTokenAccountStoring = InMemoryTokenAccountStore(), + config: CodexBarConfig? = nil) -> SettingsStore { let isolatedSuiteName = "\(suiteName)-\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: isolatedSuiteName) else { preconditionFailure("Could not create test defaults suite") } defaults.removePersistentDomain(forName: isolatedSuiteName) + let configStore = testConfigStore(suiteName: isolatedSuiteName) + if let config { + do { + try configStore.save(config) + } catch { + preconditionFailure("Could not save test config: \(error)") + } + } return SettingsStore( userDefaults: defaults, - configStore: testConfigStore(suiteName: isolatedSuiteName), + configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore(), codexCookieStore: InMemoryCookieHeaderStore(), diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 1966a925c2..bac3bbb3fa 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -4,6 +4,60 @@ import Testing @testable import CodexBar @testable import CodexBarCLI +@Suite(.serialized) +struct AlibabaTokenPlanRegionSelectionTests { + @Test @MainActor + func `fresh app settings default to International`() { + let settings = testSettingsStore(suiteName: "AlibabaTokenPlanRegionSelectionTests-fresh") + + #expect(settings.alibabaTokenPlanAPIRegion == .international) + } + + @Test @MainActor + func `legacy app settings without region remain China mainland`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .alibabatokenplan, region: nil)) + let settings = testSettingsStore( + suiteName: "AlibabaTokenPlanRegionSelectionTests-legacy", + config: config) + + #expect(settings.alibabaTokenPlanAPIRegion == .chinaMainland) + } + + @Test @MainActor + func `app settings trim configured region`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .alibabatokenplan, region: " intl ")) + let settings = testSettingsStore( + suiteName: "AlibabaTokenPlanRegionSelectionTests-trimmed", + config: config) + + #expect(settings.alibabaTokenPlanAPIRegion == .international) + } + + @Test + func `CLI honors explicit region and keeps legacy config on China mainland`() throws { + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let internationalContext = try TokenAccountCLIContext( + selection: selection, + config: CodexBarConfig(providers: [ + ProviderConfig(id: .alibabatokenplan, region: AlibabaTokenPlanAPIRegion.international.rawValue), + ]), + verbose: false) + let legacyContext = try TokenAccountCLIContext( + selection: selection, + config: CodexBarConfig(providers: [ + ProviderConfig(id: .alibabatokenplan, region: nil), + ]), + verbose: false) + + #expect(internationalContext.settingsSnapshot(for: .alibabatokenplan, account: nil)? + .alibabaTokenPlan?.apiRegion == .international) + #expect(legacyContext.settingsSnapshot(for: .alibabatokenplan, account: nil)? + .alibabaTokenPlan?.apiRegion == .chinaMainland) + } +} + @Suite(.serialized) struct ZaiTokenAccountEnvironmentPrecedenceTests { @Test From 63c69fdcc47f82847928922dac004c2cd293e440 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:21:31 +0800 Subject: [PATCH 091/105] Guide Gemini users to Antigravity on consumer-tier shutdown (#1931) * Guide Gemini users to Antigravity on consumer-tier shutdown. Detect Google's unsupported-client migration responses during quota, loadCodeAssist, and token refresh so individual accounts see actionable Antigravity setup guidance instead of generic auth failures. Fixes steipete/CodexBar#1929 Co-authored-by: Cursor * Complete Gemini-to-Antigravity migration UX for consumer tiers. Surface migration guidance at login, in not-logged-in and Antigravity setup errors, and via a Settings action to enable Antigravity when Google returns the unsupported-client shutdown signal. Co-authored-by: Cursor * Clarify gemini migration docs for signal-gated vs proactive copy. Co-authored-by: Cursor * Key Settings Antigravity action off typed deprecation state. Replace user-facing error string matching with a Gemini refresh sentinel set only when consumerTierDeprecated is observed. Co-authored-by: Cursor * fix: gate Gemini migration guidance * test: clarify fake Gemini refresh token --------- Co-authored-by: Cursor Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../Gemini/GeminiProviderImplementation.swift | 32 ++++ Sources/CodexBar/UsageStore+Accessors.swift | 4 + .../UsageStore+BackgroundRefresh.swift | 3 + .../CodexBar/UsageStore+GeminiMigration.swift | 16 ++ Sources/CodexBar/UsageStore+Refresh.swift | 15 ++ Sources/CodexBar/UsageStore.swift | 5 +- Sources/CodexBarCLI/CLIHelpers.swift | 3 +- .../Gemini/GeminiConsumerTierMigration.swift | 9 + .../Providers/Gemini/GeminiStatusProbe.swift | 39 ++++- .../CodexBarTests/GeminiAPITestHelpers.swift | 14 ++ .../GeminiConsumerTierMigrationTests.swift | 163 ++++++++++++++++++ ...GeminiProviderMigrationSettingsTests.swift | 134 ++++++++++++++ docs/antigravity.md | 6 + docs/gemini.md | 13 ++ 15 files changed, 450 insertions(+), 7 deletions(-) create mode 100644 Sources/CodexBar/UsageStore+GeminiMigration.swift create mode 100644 Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift create mode 100644 Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift create mode 100644 Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 34bfd7a848..e5ea89cc71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 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! - 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! 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/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 63dd738395..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] } 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+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+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 644f40f52a..e52d32cbb3 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -285,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 { @@ -601,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.swift b/Sources/CodexBar/UsageStore.swift index d7429d696e..8f1cb353bc 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -141,6 +141,7 @@ 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]] = [:] @@ -409,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 } diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index 17ce5c4c82..5bb576f0ef 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -310,7 +310,8 @@ extension CodexBarCLI { CostUsageError.unsupportedProvider, UsageError.decodeFailed, UsageError.noRateLimitsFound, - GeminiStatusProbeError.parseFailed: + GeminiStatusProbeError.parseFailed, + GeminiStatusProbeError.consumerTierDeprecated: ExitCode(3) default: .failure diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift new file mode 100644 index 0000000000..2cb0be8346 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiConsumerTierMigration.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Shared copy for Google's June 2026 Gemini CLI consumer-tier shutdown. +public enum GeminiConsumerTierMigration { + public static let deprecationError = """ + Google no longer supports Gemini CLI OAuth for individual, AI Pro, or Ultra accounts. \ + Enable CodexBar's Antigravity provider, sign in to Antigravity or run `agy`, then refresh. + """ +} diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index ddd39cd05a..5863ff98ce 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -104,6 +104,7 @@ public enum GeminiStatusProbeError: LocalizedError, Sendable, Equatable { case geminiNotInstalled case notLoggedIn case unsupportedAuthType(String) + case consumerTierDeprecated case parseFailed(String) case timedOut case apiError(String) @@ -116,6 +117,8 @@ public enum GeminiStatusProbeError: LocalizedError, Sendable, Equatable { "Not logged in to Gemini. Run 'gemini' in Terminal to authenticate." case let .unsupportedAuthType(authType): "Gemini \(authType) auth not supported. Use Google account (OAuth) instead." + case .consumerTierDeprecated: + GeminiConsumerTierMigration.deprecationError case let .parseFailed(msg): "Could not parse Gemini usage: \(msg)" case .timedOut: @@ -124,6 +127,33 @@ public enum GeminiStatusProbeError: LocalizedError, Sendable, Equatable { "Gemini API error: \(msg)" } } + + /// Detects Google's consumer-tier Gemini CLI shutdown responses. + public static func isConsumerTierDeprecationSignal(_ text: String) -> Bool { + let normalized = text.lowercased() + if normalized.contains("unsupported_client") { + return true + } + if normalized.contains("ineligibletiererror") { + return true + } + if normalized.contains("no longer supported"), normalized.contains("gemini code assist") { + return true + } + if normalized.contains("migrate"), normalized.contains("antigravity"), normalized.contains("gemini") { + return true + } + return false + } + + public static func throwIfConsumerTierDeprecated(data: Data) throws { + guard let text = String(data: data, encoding: .utf8), + isConsumerTierDeprecationSignal(text) + else { + return + } + throw GeminiStatusProbeError.consumerTierDeprecated + } } public enum GeminiAuthType: String, Sendable { @@ -255,7 +285,7 @@ public struct GeminiStatusProbe: Sendable { let claims = Self.extractClaimsFromToken(idToken) // Load Code Assist status to get project ID and tier (aligned with CLI setupUser logic) - let caStatus = await Self.loadCodeAssistStatus( + let caStatus = try await Self.loadCodeAssistStatus( accessToken: accessToken, timeout: timeout, dataLoader: dataLoader) @@ -296,10 +326,12 @@ public struct GeminiStatusProbe: Sendable { } if httpResponse.statusCode == 401 { + try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data) throw GeminiStatusProbeError.notLoggedIn } guard httpResponse.statusCode == 200 else { + try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data) throw GeminiStatusProbeError.apiError("HTTP \(httpResponse.statusCode)") } @@ -386,7 +418,8 @@ public struct GeminiStatusProbe: Sendable { private static func loadCodeAssistStatus( accessToken: String, timeout: TimeInterval, - dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async -> CodeAssistStatus + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws + -> CodeAssistStatus { guard let url = URL(string: loadCodeAssistEndpoint) else { self.log.warning("loadCodeAssist: invalid endpoint URL") @@ -415,6 +448,7 @@ public struct GeminiStatusProbe: Sendable { } guard httpResponse.statusCode == 200 else { + try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data) Self.log.warning("loadCodeAssist: HTTP error", metadata: [ "statusCode": "\(httpResponse.statusCode)", "body": String(data: data, encoding: .utf8) ?? "", @@ -851,6 +885,7 @@ public struct GeminiStatusProbe: Sendable { } guard httpResponse.statusCode == 200 else { + try GeminiStatusProbeError.throwIfConsumerTierDeprecated(data: data) Self.log.error("Token refresh failed", metadata: [ "statusCode": "\(httpResponse.statusCode)", ]) diff --git a/Tests/CodexBarTests/GeminiAPITestHelpers.swift b/Tests/CodexBarTests/GeminiAPITestHelpers.swift index b243f22ab7..22fba5651f 100644 --- a/Tests/CodexBarTests/GeminiAPITestHelpers.swift +++ b/Tests/CodexBarTests/GeminiAPITestHelpers.swift @@ -100,4 +100,18 @@ enum GeminiAPITestHelpers { static func loadCodeAssistLegacyTierResponse() -> Data { self.loadCodeAssistResponse(tierId: "legacy-tier") } + + static func consumerTierDeprecationResponse() -> Data { + self.jsonData([ + "error": [ + "code": 403, + "message": """ + IneligibleTierError / UNSUPPORTED_CLIENT: This client is no longer supported for \ + Gemini Code Assist for individuals. To continue using Gemini, please migrate to the \ + Antigravity suite of products. + """, + "status": "PERMISSION_DENIED", + ], + ]) + } } diff --git a/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift b/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift new file mode 100644 index 0000000000..a223f49ee8 --- /dev/null +++ b/Tests/CodexBarTests/GeminiConsumerTierMigrationTests.swift @@ -0,0 +1,163 @@ +import CodexBarCore +import Foundation +import Testing +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +@Suite(.serialized) +struct GeminiConsumerTierMigrationTests { + @Test(arguments: [ + "UNSUPPORTED_CLIENT", + "IneligibleTierError", + "no longer supported for Gemini Code Assist for individuals", + "please migrate Gemini to the Antigravity suite", + ]) + func `detects consumer tier deprecation signals`(signal: String) { + #expect(GeminiStatusProbeError.isConsumerTierDeprecationSignal(signal)) + } + + @Test(arguments: [ + "UNAUTHENTICATED", + "HTTP 500", + "quota bucket missing", + ]) + func `ignores unrelated api errors`(signal: String) { + #expect(!GeminiStatusProbeError.isConsumerTierDeprecationSignal(signal)) + } + + @Test + func `reports consumer tier deprecation from loadCodeAssist`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 403, + body: GeminiAPITestHelpers.consumerTierDeprecationResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.consumerTierDeprecated) { + _ = try await probe.fetch() + } + } + + @Test + func `reports consumer tier deprecation from quota api`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 403, + body: GeminiAPITestHelpers.consumerTierDeprecationResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.consumerTierDeprecated) { + _ = try await probe.fetch() + } + } + + @Test + func `reports consumer tier deprecation from token refresh`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh", + expiry: Date().addingTimeInterval(-3600), + idToken: nil) + + let binURL = try env.writeFakeGeminiCLI() + let previousValue = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + defer { + if let previousValue { + setenv("GEMINI_CLI_PATH", previousValue, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + } + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "oauth2.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 400, + body: GeminiAPITestHelpers.consumerTierDeprecationResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + await Self.expectError(.consumerTierDeprecated) { + _ = try await probe.fetch() + } + } + + private static func expectError( + _ expected: GeminiStatusProbeError, + operation: () async throws -> Void) async + { + do { + try await operation() + #expect(Bool(false)) + } catch { + #expect(error as? GeminiStatusProbeError == expected) + } + } +} diff --git a/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift b/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift new file mode 100644 index 0000000000..c7f84b12bb --- /dev/null +++ b/Tests/CodexBarTests/GeminiProviderMigrationSettingsTests.swift @@ -0,0 +1,134 @@ +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct GeminiProviderMigrationSettingsTests { + private func makeSettings() -> SettingsStore { + let suite = "GeminiProviderMigrationSettingsTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + return settings + } + + private func makeStore(settings: SettingsStore) -> UsageStore { + UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } + + private func makeContext(settings: SettingsStore, store: UsageStore) -> ProviderSettingsContext { + ProviderSettingsContext( + provider: .gemini, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } + + @Test + func `typed predicate accepts consumer tier deprecated only`() { + #expect(UsageStore.isGeminiConsumerTierDeprecationError(GeminiStatusProbeError.consumerTierDeprecated)) + #expect(!UsageStore.isGeminiConsumerTierDeprecationError(GeminiStatusProbeError.notLoggedIn)) + #expect(!UsageStore.isGeminiConsumerTierDeprecationError(nil)) + } + + @Test + func `ordinary auth errors do not set migration observation`() { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + + store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn) + + #expect(!store.geminiObservedConsumerTierDeprecation) + } + + @Test + func `settings action appears when deprecation was observed`() { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.consumerTierDeprecated) + + let impl = GeminiProviderImplementation() + let antigravity = ProviderDescriptorRegistry.descriptor(for: .antigravity).metadata + let wasEnabled = settings.isProviderEnabled(provider: .antigravity, metadata: antigravity) + let actions = impl.settingsActions(context: self.makeContext(settings: settings, store: store)) + + #expect(actions.map(\.id) == ["gemini-antigravity-migration"]) + #expect(settings.isProviderEnabled(provider: .antigravity, metadata: antigravity) == wasEnabled) + } + + @Test + func `settings action hidden for ordinary not logged in errors`() { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + store.errors[.gemini] = GeminiStatusProbeError.notLoggedIn.errorDescription + store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn) + + let impl = GeminiProviderImplementation() + let actions = impl.settingsActions(context: self.makeContext(settings: settings, store: store)) + + #expect(actions.isEmpty) + #expect(!store.geminiObservedConsumerTierDeprecation) + } + + @Test + func `settings action hidden for unauthenticated401 style failures`() { + let unauthenticatedBody = """ + {"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}} + """ + #expect(!GeminiStatusProbeError.isConsumerTierDeprecationSignal(unauthenticatedBody)) + + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + store.errors[.gemini] = GeminiStatusProbeError.notLoggedIn.errorDescription + store.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn) + + let impl = GeminiProviderImplementation() + let actions = impl.settingsActions(context: self.makeContext(settings: settings, store: store)) + + #expect(actions.isEmpty) + } + + @Test + func `migration observation is store scoped and survives unrelated failures`() { + let firstSettings = self.makeSettings() + let firstStore = self.makeStore(settings: firstSettings) + let secondSettings = self.makeSettings() + let secondStore = self.makeStore(settings: secondSettings) + + firstStore.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.consumerTierDeprecated) + firstStore.observeGeminiConsumerTierDeprecation(from: GeminiStatusProbeError.notLoggedIn) + + #expect(firstStore.geminiObservedConsumerTierDeprecation) + #expect(!secondStore.geminiObservedConsumerTierDeprecation) + + firstStore.clearGeminiConsumerTierDeprecationObservation() + #expect(!firstStore.geminiObservedConsumerTierDeprecation) + } +} diff --git a/docs/antigravity.md b/docs/antigravity.md index e625977b1d..5ad0dd43d9 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -9,6 +9,12 @@ read_when: # Antigravity provider +For Google individual, AI Pro, and Ultra accounts blocked by the June 2026 Gemini CLI OAuth +shutdown, Antigravity is the replacement path for Gemini quota tracking in CodexBar. Launch +the Antigravity app or run `agy`, sign in, then refresh. See `docs/gemini.md` for the Gemini +provider migration notes. CodexBar offers the handoff only after an observed Google migration +signal and never enables or falls back to Antigravity automatically. + Antigravity supports four usage data sources: 1. The Antigravity 2.0 app's local `language_server` (preferred when the app is open). diff --git a/docs/gemini.md b/docs/gemini.md index c3c3f915ed..1b007a5f76 100644 --- a/docs/gemini.md +++ b/docs/gemini.md @@ -70,5 +70,18 @@ Gemini uses the Gemini CLI OAuth credentials and private quota APIs. No browser - `legacy-tier` → "Legacy" - Email from `id_token` JWT claims. +## Consumer-tier migration (June 2026) +- [Google stopped serving](https://developers.google.com/gemini-code-assist/docs/deprecations/code-assist-individuals) + Gemini CLI OAuth for individual, AI Pro, and Ultra accounts on 2026-06-18. Standard and Enterprise + subscriptions remain supported; paid API-key access is outside CodexBar's OAuth-backed Gemini provider. +- When quota, `loadCodeAssist`, or token-refresh responses include Google's unsupported-client + migration signal (`UNSUPPORTED_CLIENT`, `IneligibleTierError`, or Antigravity migration copy), + CodexBar surfaces `consumerTierDeprecated` with guidance to use the Antigravity provider. +- Settings shows an **Enable Antigravity provider** action only after CodexBar observes + `consumerTierDeprecated` during a Gemini refresh (typed sentinel state, not user-facing text matching). +- The action is explicit: CodexBar never automatically enables Antigravity or falls back to it. +- Ordinary Gemini login, `notLoggedIn`, and Antigravity setup errors remain unchanged. CodexBar does not + capture Terminal `gemini` OAuth output, so Terminal-only failures cannot activate the migration action. + ## Key files - `Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift` From d370d35daa61098177fc866fa575cacd5b8fa671 Mon Sep 17 00:00:00 2001 From: Zihao Qi <35388022+Zihao-Qi@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:14:15 -0500 Subject: [PATCH 092/105] Add Antigravity pace (#1951) * Add Antigravity pace * Limit Antigravity session pace * fix: preserve Antigravity icon semantics --------- Co-authored-by: Zihaoqi Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../CodexBar/MenuCardView+ModelHelpers.swift | 40 ++++++- Sources/CodexBar/UsagePaceText.swift | 4 +- .../MenuCardAntigravityTests.swift | 113 ++++++++++++++++++ .../MenuDescriptorAntigravityTests.swift | 51 ++++++++ Tests/CodexBarTests/UsagePaceTextTests.swift | 29 +++++ 6 files changed, 232 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ea89cc71..0932899784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.40.1 — Unreleased ### Added +- 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! diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 030e13fd47..3ae74955d5 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -626,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( @@ -651,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, @@ -674,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, @@ -681,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/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/Tests/CodexBarTests/MenuCardAntigravityTests.swift b/Tests/CodexBarTests/MenuCardAntigravityTests.swift index cd48764d91..b07ef767c6 100644 --- a/Tests/CodexBarTests/MenuCardAntigravityTests.swift +++ b/Tests/CodexBarTests/MenuCardAntigravityTests.swift @@ -91,6 +91,53 @@ struct MenuCardAntigravityTests { #expect(model.metrics[0].percentLabel == "95% left") } + @Test + func `legacy antigravity family row renders session pace without mutating duration`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + let snapshot = UsageSnapshot( + primary: window, + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(snapshot.primary?.windowMinutes == nil) + #expect(model.metrics.map(\.detailLeftText) == ["20% in deficit"]) + #expect(model.metrics.map(\.detailRightText) == ["Projected empty in 45m"]) + #expect(model.metrics[0].pacePercent == 40) + #expect(model.metrics[0].paceOnTop == false) + } + @Test func `antigravity untracked known row does not duplicate grouped summary`() throws { let now = Date(timeIntervalSince1970: 1_735_000_000) @@ -363,6 +410,72 @@ struct MenuCardAntigravityTests { #expect(model.metrics[2].resetText == "Resets in 3 hours") } + @Test + func `antigravity quota summary rows render pace details`() throws { + let now = Date(timeIntervalSince1970: 0) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Models Five Hour Limit", + window: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil)), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.detailLeftText) == [ + "20% in deficit", + "7% in deficit", + ]) + #expect(model.metrics.map(\.detailRightText) == [ + "Projected empty in 45m", + "Runs out in 3d", + ]) + #expect(model.metrics[0].pacePercent == 40) + #expect(abs((model.metrics[1].pacePercent ?? 0) - (400.0 / 7.0)) < 0.01) + #expect(model.metrics.map(\.paceOnTop) == [false, false]) + } + @Test func `antigravity missing groups are omitted in used mode`() throws { let now = Date() diff --git a/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift b/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift index 392c114d36..8660018b2a 100644 --- a/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift +++ b/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift @@ -104,4 +104,55 @@ struct MenuDescriptorAntigravityTests { #expect(!lines.contains("Gemini Flash unavailable.")) #expect(!lines.contains("Limits not available")) } + + @Test + func `antigravity descriptor does not render session pace for weekly primary window`() throws { + let suite = "MenuDescriptorAntigravityTests-weekly-primary-pace" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(2 * 24 * 3600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + let descriptor = MenuDescriptor.build( + provider: .antigravity, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(!lines.contains { $0.hasPrefix("Pace:") }) + } } diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 8e3bc88b2c..69c39d9815 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -299,6 +299,35 @@ struct UsagePaceTextTests { #expect(detail?.rightLabel == "Projected empty in 45m") } + @Test + func `session pace detail supports Antigravity five hour window`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .antigravity, window: window, now: now) + + #expect(detail?.leftLabel == "20% in deficit") + #expect(detail?.rightLabel == "Projected empty in 45m") + } + + @Test + func `session pace detail hides Antigravity weekly window`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .antigravity, window: window, now: now) + + #expect(detail == nil) + } + @Test func `session pace detail hides Ollama window without explicit duration`() { let now = Date(timeIntervalSince1970: 0) From 8155f18732fde04e6a927a08ced034fda0790e4e Mon Sep 17 00:00:00 2001 From: Donnie Fiander <44792682+DonnieFi@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:27:23 -0300 Subject: [PATCH 093/105] Add CLI cards view (#1925) Adds responsive codexbar cards and brief views with bounded terminal rendering, correct reset semantics, partial-success handling, and focused regression coverage. Co-authored-by: Donnie Fiander <44792682+DonnieFi@users.noreply.github.com> --- CHANGELOG.md | 1 + .../CodexBarCLI/CLICardsBriefRenderer.swift | 590 +++++++++++++++++ Sources/CodexBarCLI/CLICardsCommand.swift | 212 ++++++ Sources/CodexBarCLI/CLICardsRenderer.swift | 529 +++++++++++++++ Sources/CodexBarCLI/CLIEntry.swift | 12 + Sources/CodexBarCLI/CLIHelp.swift | 39 ++ Sources/CodexBarCLI/CLIIO.swift | 2 + Sources/CodexBarCLI/CLIRenderer.swift | 481 +++++++++++++- .../CodexBarCLI/CLITerminalCapabilities.swift | 47 ++ Sources/CodexBarCLI/CLIUsageCommand.swift | 118 +++- .../CodexBarTests/CLICardsRendererTests.swift | 615 ++++++++++++++++++ TestsLinux/CLICardsRendererTests.swift | 615 ++++++++++++++++++ TestsLinux/CLITerminalCapabilitiesTests.swift | 49 ++ docs/cli.md | 10 + 14 files changed, 3294 insertions(+), 26 deletions(-) create mode 100644 Sources/CodexBarCLI/CLICardsBriefRenderer.swift create mode 100644 Sources/CodexBarCLI/CLICardsCommand.swift create mode 100644 Sources/CodexBarCLI/CLICardsRenderer.swift create mode 100644 Sources/CodexBarCLI/CLITerminalCapabilities.swift create mode 100644 Tests/CodexBarTests/CLICardsRendererTests.swift create mode 100644 TestsLinux/CLICardsRendererTests.swift create mode 100644 TestsLinux/CLITerminalCapabilitiesTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0932899784..e5d59239a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Antigravity: show pace details for legacy model-family and current session/weekly quota rows without changing compact icon lane semantics. Thanks @Zihao-Qi! +- CLI: add responsive `codexbar cards` and compact `--brief` terminal usage views. Thanks @DonnieFi! - 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! 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/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index ce2cc281b5..319dff3d1b 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) @@ -79,6 +85,7 @@ enum CodexBarCLI { } private static func commandDescriptors() -> [CommandDescriptor] { + let cardsSignature = CommandSignature.describe(CardsOptions()) let usageSignature = CommandSignature.describe(UsageOptions()) let costSignature = CommandSignature.describe(CostOptions()) let serveSignature = CommandSignature.describe(ServeOptions()) @@ -89,6 +96,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", diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index ac8e503abf..2f92597a55 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

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!
  • +
+

View full changelog

+]]> + +
0.40.0 Mon, 06 Jul 2026 00:10:16 +0100 @@ -78,50 +121,6 @@ ]]> - - 0.38.1 - Sat, 04 Jul 2026 10:21:12 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 95 - 0.38.1 - 14.0 - CodexBar 0.38.1 -

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!
  • -
-

Changed

-
    -
  • Branding: replace the app and website icon with a usage-meter prompt mark that matches CodexBar's core UI.
  • -
  • Website: redesign codexbar.app around faster download, provider discovery, feature, CLI, and widget paths with responsive dark/light and localized layouts. Thanks @vyctorbrzezowski!
  • -
  • Architecture: accept a bounded opt-in adaptive refresh design with a deterministic 2–30-minute cadence and no behavioral telemetry. Thanks @hhh2210!
  • -
  • Architecture: define the security and identity boundaries required before custom HTTP JSON providers can be implemented safely.
  • -
  • Claude: accept a display-only multi-account design based on read-only claude-swap --list --json, without account switching or credential storage.
  • -
  • Notifications: accept a default-off predictive pace warning design that alerts once per risk episode and re-arms only after authoritative recovery.
  • -
  • OpenCode Go: accept bounded automatic multi-workspace fan-out while preserving the configured workspace as an exact single-workspace override.
  • -
  • 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!
  • -
-

View full changelog

-]]>
- -
0.14.0 Thu, 25 Dec 2025 03:56:15 +0100 From c923e3e9fd8af24c2280d14e0d7b143a303c4efc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Jul 2026 01:26:54 +0100 Subject: [PATCH 100/105] fix: keep release builds from dirtying projects --- Scripts/package_app.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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() { From 46edb383c0ac8eb0e01b8df7291d78f127ced793 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Jul 2026 01:27:11 +0100 Subject: [PATCH 101/105] chore: start 0.41.1 development --- CHANGELOG.md | 2 ++ version.env | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d007d5267..380fe4e46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.41.1 — Unreleased + ## 0.41.0 — 2026-07-06 ### Added diff --git a/version.env b/version.env index e43618e362..415f08102f 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.41.0 -BUILD_NUMBER=100 +MARKETING_VERSION=0.41.1 +BUILD_NUMBER=101 From 8106e7d3cc31e206bc75f6f967b7cc37d8c2006e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Jul 2026 10:51:32 +0100 Subject: [PATCH 102/105] Agent Sessions: track live Codex and Claude sessions across Macs (#1968) * feat: track live Codex and Claude agent sessions across Macs Adds an Agent Sessions section to the menu: local sessions discovered via process scan correlated with Codex rollout and Claude project JSONL metadata, remote Macs discovered via Tailscale (plus manual SSH hosts) and fetched over ssh with the bundled CLI. Click a session to focus its terminal window via Accessibility. New 'codexbar sessions' CLI (table/JSON) and 'sessions focus'. Design notes in docs/agent-sessions-design.md, user docs in docs/sessions.md. * refactor: collapse sessions settings to a single toggle Tailscale discovery now runs automatically whenever Agent Sessions is enabled; drops the separate auto-discover toggle and its setting. --- CHANGELOG.md | 3 + Sources/CodexBar/AgentSessionsStore.swift | 155 ++++++ Sources/CodexBar/MenuContent.swift | 6 + Sources/CodexBar/MenuDescriptor.swift | 77 ++- Sources/CodexBar/PreferencesGeneralPane.swift | 13 + Sources/CodexBar/SettingsStore+Defaults.swift | 16 + .../SettingsStore+MenuObservation.swift | 2 + Sources/CodexBar/SettingsStore.swift | 7 +- Sources/CodexBar/SettingsStoreState.swift | 2 + .../StatusItemController+AgentSessions.swift | 19 + .../CodexBar/StatusItemController+Menu.swift | 14 +- ...atusItemController+MenuActionMapping.swift | 2 + .../StatusItemController+MenuWidthCache.swift | 9 +- .../StatusItemController+Shutdown.swift | 1 + Sources/CodexBar/StatusItemController.swift | 19 + Sources/CodexBarCLI/CLIEntry.swift | 24 + Sources/CodexBarCLI/CLIHelp.swift | 23 + Sources/CodexBarCLI/CLIIO.swift | 2 + Sources/CodexBarCLI/CLISessionsCommand.swift | 94 ++++ Sources/CodexBarCore/AgentSession.swift | 445 ++++++++++++++++++ .../LocalAgentSessionScanner.swift | 229 +++++++++ .../CodexBarCore/RemoteSessionFetcher.swift | 191 ++++++++ .../CodexBarCore/SessionWindowFocuser.swift | 107 +++++ .../CodexBarTests/AgentSessionJSONTests.swift | 33 ++ .../AgentSessionMenuDescriptorTests.swift | 90 ++++ .../AgentSessionParserTests.swift | 74 +++ .../ClaudeSessionMappingTests.swift | 36 ++ .../CodexSessionRolloutTests.swift | 65 +++ .../Fixtures/agent-session-rollout.jsonl | 2 + .../Fixtures/agent-sessions-lsof.txt | 4 + .../Fixtures/agent-sessions-ps.txt | 9 + .../Fixtures/agent-sessions-tailscale.json | 10 + .../CodexBarTests/TailscaleSessionTests.swift | 29 ++ .../xcshareddata/swiftpm/Package.resolved | 77 +++ docs/agent-sessions-design.md | 98 ++++ docs/sessions.md | 17 + 36 files changed, 1994 insertions(+), 10 deletions(-) create mode 100644 Sources/CodexBar/AgentSessionsStore.swift create mode 100644 Sources/CodexBar/StatusItemController+AgentSessions.swift create mode 100644 Sources/CodexBarCLI/CLISessionsCommand.swift create mode 100644 Sources/CodexBarCore/AgentSession.swift create mode 100644 Sources/CodexBarCore/LocalAgentSessionScanner.swift create mode 100644 Sources/CodexBarCore/RemoteSessionFetcher.swift create mode 100644 Sources/CodexBarCore/SessionWindowFocuser.swift create mode 100644 Tests/CodexBarTests/AgentSessionJSONTests.swift create mode 100644 Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift create mode 100644 Tests/CodexBarTests/AgentSessionParserTests.swift create mode 100644 Tests/CodexBarTests/ClaudeSessionMappingTests.swift create mode 100644 Tests/CodexBarTests/CodexSessionRolloutTests.swift create mode 100644 Tests/CodexBarTests/Fixtures/agent-session-rollout.jsonl create mode 100644 Tests/CodexBarTests/Fixtures/agent-sessions-lsof.txt create mode 100644 Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt create mode 100644 Tests/CodexBarTests/Fixtures/agent-sessions-tailscale.json create mode 100644 Tests/CodexBarTests/TailscaleSessionTests.swift create mode 100644 WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 docs/agent-sessions-design.md create mode 100644 docs/sessions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 380fe4e46d..2fd84cd320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 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. + ## 0.41.0 — 2026-07-06 ### Added 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/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index df12a92f44..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 } } } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 15606c80ed..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, @@ -822,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/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index fb577f4bba..eee0e36a8d 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -154,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() diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 724eabaa58..de3f257616 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -760,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 fca59dfb84..03bf729aa9 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -50,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.swift b/Sources/CodexBar/SettingsStore.swift index e13e568616..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:)) @@ -460,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, @@ -519,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( diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 567f5f3309..10286d4d37 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -60,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+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+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index c14fa62f35..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)) } @@ -787,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 { @@ -857,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, 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+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+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift index e4b5f94a09..a5a6ce8827 100644 --- a/Sources/CodexBar/StatusItemController+Shutdown.swift +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -22,6 +22,7 @@ extension StatusItemController { } private func cancelShutdownTasks() { + self.agentSessions.stop() self.blinkTask?.cancel() self.blinkTask = nil self.menuBarCountdownRefreshTask?.cancel() diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 500ca28d79..17b4e3b921 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -117,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) } @@ -243,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 @@ -372,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 @@ -387,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 @@ -409,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( @@ -640,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 diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index 319dff3d1b..37a5d6a105 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -48,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"]: @@ -88,6 +92,8 @@ enum CodexBarCLI { 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()) @@ -111,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 2f92597a55..8b299dc1ef 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -109,6 +109,26 @@ extension CodexBarCLI { """ } + static func sessionsHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar sessions [--json] [--pretty] + codexbar sessions focus + + Description: + List live local Codex and Claude Code agent sessions. + JSON uses stable AgentSession field names and ISO-8601 dates. + Focus activates the owning terminal or desktop app on macOS. + + Examples: + codexbar sessions + codexbar sessions --json + codexbar sessions focus 019f3497-73bf-7df3-a173-4f67d968914a + """ + } + static func serveHelp(version: String) -> String { """ CodexBar \(version) @@ -256,6 +276,8 @@ extension CodexBarCLI { [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh] [--days ] [--group-by project] + codexbar sessions [--json] [--pretty] + codexbar sessions focus codexbar serve [--port ] [--refresh-interval ] [--request-timeout ] [--json-output] [--log-level ] [-v|--verbose] @@ -289,6 +311,7 @@ extension CodexBarCLI { codexbar cards --provider all --status codexbar cards --brief codexbar cost --provider claude --format json --pretty + codexbar sessions --json codexbar serve --port 8080 codexbar config validate --format json --pretty codexbar config enable --provider grok diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index bdece804bf..0dd03065a4 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -31,6 +31,8 @@ extension CodexBarCLI { print(Self.usageHelp(version: version)) case "cost": print(Self.costHelp(version: version)) + case "sessions", "focus": + print(Self.sessionsHelp(version: version)) case "serve": print(Self.serveHelp(version: version)) case "config", "validate", "dump": diff --git a/Sources/CodexBarCLI/CLISessionsCommand.swift b/Sources/CodexBarCLI/CLISessionsCommand.swift new file mode 100644 index 0000000000..437f4ccb6f --- /dev/null +++ b/Sources/CodexBarCLI/CLISessionsCommand.swift @@ -0,0 +1,94 @@ +import CodexBarCore +import Commander +import Foundation + +extension CodexBarCLI { + static func runSessions(_ values: ParsedValues) async { + let sessions = await LocalAgentSessionScanner().scan() + if values.flags.contains("jsonShortcut") { + Self.printJSON(sessions, pretty: values.flags.contains("pretty")) + } else { + print(Self.renderSessionsTable(sessions)) + } + } + + static func runSessionsFocus(_ values: ParsedValues) async { + guard let sessionID = values.positional.first, !sessionID.isEmpty else { + writeStderr("Missing session id.\n") + platformExit(1) + } + let sessions = await LocalAgentSessionScanner().scan() + guard let session = sessions.first(where: { $0.id == sessionID }) else { + Self.writeStderr("Unknown session: \(sessionID)\n") + Self.platformExit(1) + } + + #if os(macOS) + let result = await MainActor.run { + SessionWindowFocuser.focus(session) + } + switch result { + case .focused, .activatedApplicationOnly: + Self.platformExit(0) + case .failed: + Self.writeStderr("Could not focus session: \(sessionID)\n") + Self.platformExit(2) + } + #else + Self.writeStderr("Session focus is only available on macOS.\n") + Self.platformExit(2) + #endif + } + + static func renderSessionsTable(_ sessions: [AgentSession], now: Date = Date()) -> String { + guard !sessions.isEmpty else { return "No agent sessions found." } + let rows = sessions.map { session in + [ + session.state == .active ? "active" : "idle", + session.provider.rawValue, + session.source.rawValue, + session.projectName ?? "—", + Self.sessionAge(session, now: now), + session.id, + ] + } + let headers = ["STATE", "PROVIDER", "SOURCE", "PROJECT", "ACTIVITY", "ID"] + let widths = headers.indices.map { index in + ([headers[index]] + rows.map { $0[index] }).map(\ .count).max() ?? headers[index].count + } + let render: ([String]) -> String = { columns in + columns.indices.map { index in + columns[index].padding(toLength: widths[index], withPad: " ", startingAt: 0) + }.joined(separator: " ") + } + return ([render(headers)] + rows.map(render)).joined(separator: "\n") + } + + private static func sessionAge(_ session: AgentSession, now: Date) -> String { + guard let date = session.lastActivityAt ?? session.startedAt else { return "now" } + let seconds = max(0, Int(now.timeIntervalSince(date))) + if seconds < 60 { + return "\(seconds)s" + } + if seconds < 3600 { + return "\(seconds / 60)m" + } + if seconds < 86400 { + return "\(seconds / 3600)h" + } + return "\(seconds / 86400)d" + } +} + +struct SessionsOptions: CommanderParsable { + @Flag(name: .long("json"), help: "Emit JSON") + var jsonShortcut: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false +} + +struct SessionsFocusOptions: CommanderParsable { + @Argument(help: "Session identifier") + var id: String = "" +} diff --git a/Sources/CodexBarCore/AgentSession.swift b/Sources/CodexBarCore/AgentSession.swift new file mode 100644 index 0000000000..0aa0343de3 --- /dev/null +++ b/Sources/CodexBarCore/AgentSession.swift @@ -0,0 +1,445 @@ +import Foundation + +public struct AgentSession: Codable, Equatable, Sendable, Identifiable { + public enum Provider: String, Codable, Sendable { + case codex + case claude + } + + public enum Source: String, Codable, Sendable { + case cli + case desktopApp + case ide + case unknown + } + + public enum State: String, Codable, Sendable { + case active + case idle + } + + public var id: String + public var provider: Provider + public var source: Source + public var state: State + public var pid: Int32? + public var cwd: String? + public var projectName: String? + public var startedAt: Date? + public var lastActivityAt: Date? + public var transcriptPath: String? + public var host: String + + public init( + id: String, + provider: Provider, + source: Source, + state: State, + pid: Int32?, + cwd: String?, + projectName: String?, + startedAt: Date?, + lastActivityAt: Date?, + transcriptPath: String?, + host: String) + { + self.id = id + self.provider = provider + self.source = source + self.state = state + self.pid = pid + self.cwd = cwd + self.projectName = projectName + self.startedAt = startedAt + self.lastActivityAt = lastActivityAt + self.transcriptPath = transcriptPath + self.host = host + } +} + +public struct SessionScanConfig: Equatable, Sendable { + public var activeWindow: TimeInterval + public var fileOnlyWindow: TimeInterval + + public init(activeWindow: TimeInterval = 120, fileOnlyWindow: TimeInterval = 30 * 60) { + self.activeWindow = activeWindow + self.fileOnlyWindow = fileOnlyWindow + } + + public func state(lastActivityAt: Date?, now: Date, hasLiveProcess: Bool) -> AgentSession.State { + guard let lastActivityAt else { return hasLiveProcess ? .active : .idle } + return now.timeIntervalSince(lastActivityAt) <= self.activeWindow ? .active : .idle + } +} + +public struct AgentProcessRecord: Equatable, Sendable { + public let pid: Int32 + public let ppid: Int32 + public let startedAt: Date? + public let command: String + + public init(pid: Int32, ppid: Int32, startedAt: Date?, command: String) { + self.pid = pid + self.ppid = ppid + self.startedAt = startedAt + self.command = command + } + + public var executableBasename: String { + let firstToken = self.command.split(whereSeparator: \ .isWhitespace).first.map(String.init) ?? "" + let firstBasename = URL(fileURLWithPath: firstToken).lastPathComponent + if firstBasename == "disclaimer" { + return firstBasename + } + if self.command.contains("Application Support/Claude/claude-code/claude") { + return "claude" + } + return firstBasename + } +} + +public enum AgentPSOutputParser { + public static func parse(_ output: String) -> [AgentProcessRecord] { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "EEE MMM d HH:mm:ss yyyy" + return output.split(whereSeparator: \ .isNewline).compactMap { rawLine -> AgentProcessRecord? in + let fields = rawLine.split(maxSplits: 7, omittingEmptySubsequences: true, whereSeparator: \ .isWhitespace) + guard fields.count == 8, + let pid = Int32(fields[0]), + let ppid = Int32(fields[1]) + else { return nil } + + let dateText = fields[2...6].joined(separator: " ") + return AgentProcessRecord( + pid: pid, + ppid: ppid, + startedAt: formatter.date(from: dateText), + command: String(fields[7])) + } + } + + public static func agentProcesses(from records: [AgentProcessRecord]) -> [AgentProcessRecord] { + let candidates = records.filter { record in + let basename = record.executableBasename.lowercased() + if basename == "codex" { + let arguments = self.arguments(record.command) + return self.isCodexAgentExecutable(record.command) && + !arguments.contains("app-server") && + !arguments.contains("--help") && + !arguments.contains("--version") + } + if basename == "claude" { + return self.isClaudeAgentExecutable(record.command) && !self.isObviousClaudeHelper(record.command) + } + return basename == "disclaimer" && record.command.contains("claude") + } + + let recordsByPID = Dictionary(uniqueKeysWithValues: candidates.map { ($0.pid, $0) }) + return candidates.filter { record in + guard record.executableBasename.lowercased() == "disclaimer" else { return true } + return !candidates.contains { child in + child.ppid == record.pid && + child.executableBasename.lowercased() == "claude" && + self.normalizedClaudeArguments(child.command) == self.normalizedClaudeArguments(record.command) + } && recordsByPID[record.ppid] == nil + } + } + + public static func provider(for record: AgentProcessRecord) -> AgentSession.Provider? { + let basename = record.executableBasename.lowercased() + if basename == "codex" { + return .codex + } + if basename == "claude" || basename == "disclaimer" { + return .claude + } + return nil + } + + public static func source(for record: AgentProcessRecord) -> AgentSession.Source { + guard self.provider(for: record) == .claude else { return .cli } + return record.command.contains("Application Support/Claude/claude-code") ? .desktopApp : .cli + } + + public static func hasCodexAppServer(in records: [AgentProcessRecord]) -> Bool { + records.contains { record in + record.executableBasename.lowercased() == "codex" && + self.isCodexAgentExecutable(record.command) && + self.arguments(record.command).contains("app-server") + } + } + + private static func arguments(_ command: String) -> [String] { + command.split(whereSeparator: \ .isWhitespace).dropFirst().map(String.init) + } + + private static func normalizedClaudeArguments(_ command: String) -> [String] { + let arguments = self.arguments(command) + if let index = arguments.firstIndex(where: { URL(fileURLWithPath: $0).lastPathComponent == "claude" }) { + return Array(arguments.suffix(from: arguments.index(after: index))) + } + return arguments + } + + private static func isObviousClaudeHelper(_ command: String) -> Bool { + let lowercased = command.lowercased() + return lowercased.contains("--version") || + lowercased.contains("--help") || + lowercased.contains("claude-code-acp") + } + + private static func isCodexAgentExecutable(_ command: String) -> Bool { + let lowercased = command.lowercased() + guard lowercased.contains(".app/") else { return true } + return lowercased.hasPrefix("/applications/codex.app/contents/resources/codex ") || + lowercased.hasPrefix("/applications/codex.app/contents/resources/codex\t") + } + + private static func isClaudeAgentExecutable(_ command: String) -> Bool { + let lowercased = command.lowercased() + return !lowercased.contains(".app/") || lowercased.contains("application support/claude/claude-code/claude") + } +} + +public enum LSOFCWDOutputParser { + public static func parse(_ output: String) -> [Int32: String] { + var result: [Int32: String] = [:] + var currentPID: Int32? + for line in output.split(whereSeparator: \ .isNewline).map(String.init) { + guard let prefix = line.first else { continue } + switch prefix { + case "p": + currentPID = Int32(line.dropFirst()) + case "n": + if let currentPID { + result[currentPID] = String(line.dropFirst()) + } + default: + continue + } + } + return result + } +} + +public enum ClaudeSessionProjectMapper { + public struct Transcript: Equatable, Sendable { + public let url: URL + public let modifiedAt: Date + + public init(url: URL, modifiedAt: Date) { + self.url = url + self.modifiedAt = modifiedAt + } + } + + public static func escapedCWD(_ cwd: String) -> String { + String(cwd.unicodeScalars.map { scalar in + let value = scalar.value + let isASCIIAlphanumeric = (48...57).contains(value) || + (65...90).contains(value) || + (97...122).contains(value) + return isASCIIAlphanumeric ? Character(scalar) : "-" + }) + } + + public static func projectDirectories( + cwd: String, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default) -> [URL] + { + let standardRoot = homeDirectory + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + return ([standardRoot] + ClaudeDesktopProjectsLocator.roots( + homeDirectory: homeDirectory, + fileManager: fileManager)) + .map { $0.appendingPathComponent(self.escapedCWD(cwd), isDirectory: true) } + } + + public static func newestTranscript( + cwd: String, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default) -> (url: URL, modifiedAt: Date)? + { + self.transcripts(cwd: cwd, homeDirectory: homeDirectory, fileManager: fileManager).first + .map { (url: $0.url, modifiedAt: $0.modifiedAt) } + } + + public static func transcripts( + cwd: String, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default) -> [Transcript] + { + self.projectDirectories(cwd: cwd, homeDirectory: homeDirectory, fileManager: fileManager) + .flatMap { directory -> [(URL, Date)] in + guard let files = try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { return [] } + return files.compactMap { file in + guard file.pathExtension == "jsonl", + let modifiedAt = try? file.resourceValues( + forKeys: [.contentModificationDateKey]).contentModificationDate + else { return nil } + return (file, modifiedAt) + } + } + .sorted { $0.1 > $1.1 } + .map { Transcript(url: $0.0, modifiedAt: $0.1) } + } +} + +public enum AgentSessionCorrelation { + public static func newestProcessesFirst(_ processes: [AgentProcessRecord]) -> [AgentProcessRecord] { + processes.sorted { lhs, rhs in + let lhsDate = lhs.startedAt ?? .distantPast + let rhsDate = rhs.startedAt ?? .distantPast + if lhsDate != rhsDate { return lhsDate > rhsDate } + return lhs.pid > rhs.pid + } + } + + public static func assignClaudeTranscripts( + processes: [AgentProcessRecord], + cwdByPID: [Int32: String], + transcriptsByCWD: [String: [ClaudeSessionProjectMapper.Transcript]]) + -> [Int32: ClaudeSessionProjectMapper.Transcript] + { + var assigned: [Int32: ClaudeSessionProjectMapper.Transcript] = [:] + var usedPaths = Set() + let unambiguousPIDs = self.unambiguousProcessIDs(processes: processes, cwdByPID: cwdByPID) + for process in self.newestProcessesFirst(processes) { + guard unambiguousPIDs.contains(process.pid), + let cwd = cwdByPID[process.pid], + let candidates = transcriptsByCWD[cwd] + else { continue } + let candidate = candidates.first { transcript in + guard !usedPaths.contains(transcript.url.path) else { return false } + guard let startedAt = process.startedAt else { return true } + return transcript.modifiedAt >= startedAt + } + if let candidate { + assigned[process.pid] = candidate + usedPaths.insert(candidate.url.path) + } + } + return assigned + } + + public static func unambiguousProcessIDs( + processes: [AgentProcessRecord], + cwdByPID: [Int32: String]) -> Set + { + let grouped = Dictionary(grouping: processes.compactMap { process -> (Int32, String)? in + guard let cwd = cwdByPID[process.pid] else { return nil } + return (process.pid, URL(fileURLWithPath: cwd).standardizedFileURL.path) + }, by: { $0.1 }) + return Set(grouped.values.compactMap { records in + records.count == 1 ? records[0].0 : nil + }) + } + + public static func codexWorkingDirectoriesMatch(_ lhs: String?, _ rhs: String?) -> Bool { + guard let lhs, let rhs else { return false } + return URL(fileURLWithPath: lhs).standardizedFileURL.path == + URL(fileURLWithPath: rhs).standardizedFileURL.path + } + + public static func fileOnlyCodexSource( + metadataSource: AgentSession.Source, + appServerPresent: Bool) -> AgentSession.Source + { + metadataSource == .unknown && appServerPresent ? .desktopApp : metadataSource + } +} + +public struct CodexRolloutMetadata: Equatable, Sendable { + public let sessionID: String + public let cwd: String? + public let originator: String? + public let source: String? + + public init(sessionID: String, cwd: String?, originator: String?, source: String?) { + self.sessionID = sessionID + self.cwd = cwd + self.originator = originator + self.source = source + } + + public var sessionSource: AgentSession.Source { + let value = [self.originator, self.source] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + if value.contains("desktop") || value.contains("app-server") { + return .desktopApp + } + if value.contains("ide") || value.contains("vscode") || value.contains("cursor") || value.contains("zed") { + return .ide + } + if value.contains("codex_exec") || value.contains("exec") || value.contains("cli") { + return .cli + } + return .unknown + } +} + +public enum CodexRolloutFirstLineParser { + public static func parse(_ line: String) -> CodexRolloutMetadata? { + guard let data = line.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["type"] as? String == "session_meta", + let payload = object["payload"] as? [String: Any], + let sessionID = (payload["session_id"] as? String) ?? (payload["id"] as? String) + else { return nil } + return CodexRolloutMetadata( + sessionID: sessionID, + cwd: payload["cwd"] as? String, + originator: payload["originator"] as? String, + source: payload["source"] as? String) + } + + public static func read(from url: URL) -> CodexRolloutMetadata? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + var data = Data() + while data.count < 256 * 1024 { + guard let chunk = try? handle.read(upToCount: 4096), !chunk.isEmpty else { break } + if let newline = chunk.firstIndex(of: 0x0A) { + data.append(chunk.prefix(upTo: newline)) + break + } + data.append(chunk) + } + guard let line = String(data: data, encoding: .utf8) else { return nil } + return self.parse(line) + } + + public static func makeSession( + metadata: CodexRolloutMetadata, + transcriptURL: URL, + modifiedAt: Date, + pid: Int32? = nil, + startedAt: Date? = nil, + host: String, + config: SessionScanConfig = SessionScanConfig(), + now: Date = Date()) -> AgentSession? + { + guard pid != nil || now.timeIntervalSince(modifiedAt) <= config.fileOnlyWindow else { return nil } + let cwd = metadata.cwd + return AgentSession( + id: metadata.sessionID, + provider: .codex, + source: metadata.sessionSource, + state: config.state(lastActivityAt: modifiedAt, now: now, hasLiveProcess: pid != nil), + pid: pid, + cwd: cwd, + projectName: cwd.map { URL(fileURLWithPath: $0).lastPathComponent }, + startedAt: startedAt, + lastActivityAt: modifiedAt, + transcriptPath: transcriptURL.path, + host: host) + } +} diff --git a/Sources/CodexBarCore/LocalAgentSessionScanner.swift b/Sources/CodexBarCore/LocalAgentSessionScanner.swift new file mode 100644 index 0000000000..6b1bd2273a --- /dev/null +++ b/Sources/CodexBarCore/LocalAgentSessionScanner.swift @@ -0,0 +1,229 @@ +import Foundation + +public struct LocalAgentSessionScanner: Sendable { + private struct Rollout: Sendable { + let url: URL + let modifiedAt: Date + let metadata: CodexRolloutMetadata + } + + private struct ScanContext: Sendable { + let homeDirectory: URL + let host: String + let now: Date + } + + public let config: SessionScanConfig + + public init(config: SessionScanConfig = SessionScanConfig()) { + self.config = config + } + + public func scan( + now: Date = Date(), + environment: [String: String] = ProcessInfo.processInfo.environment) async -> [AgentSession] + { + let processOutput = await self.processOutput(environment: environment) + let allProcesses = AgentPSOutputParser.parse(processOutput) + let processes = AgentSessionCorrelation.newestProcessesFirst( + AgentPSOutputParser.agentProcesses(from: allProcesses)) + let codexAppServerPresent = AgentPSOutputParser.hasCodexAppServer(in: allProcesses) + let cwdByPID = await self.cwdByPID(processes.map(\ .pid), environment: environment) + let homeDirectory = URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true) + let host = ProcessInfo.processInfo.hostName + let rollouts = self.codexRollouts( + now: now, + environment: environment, + homeDirectory: homeDirectory) + return self.sessions( + processes: processes, + cwdByPID: cwdByPID, + rollouts: rollouts, + codexAppServerPresent: codexAppServerPresent, + context: ScanContext(homeDirectory: homeDirectory, host: host, now: now)) + } + + private func sessions( + processes: [AgentProcessRecord], + cwdByPID: [Int32: String], + rollouts: [Rollout], + codexAppServerPresent: Bool, + context: ScanContext) -> [AgentSession] + { + var sessions: [AgentSession] = [] + var matchedRolloutPaths = Set() + let claudeProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .claude } + let claudeCWDs = Set(claudeProcesses.compactMap { cwdByPID[$0.pid] }) + let claudeTranscriptsByCWD = Dictionary(uniqueKeysWithValues: claudeCWDs.map { cwd in + (cwd, ClaudeSessionProjectMapper.transcripts(cwd: cwd, homeDirectory: context.homeDirectory)) + }) + let claudeTranscripts = AgentSessionCorrelation.assignClaudeTranscripts( + processes: claudeProcesses, + cwdByPID: cwdByPID, + transcriptsByCWD: claudeTranscriptsByCWD) + + for process in processes { + guard let provider = AgentPSOutputParser.provider(for: process) else { continue } + let cwd = cwdByPID[process.pid] + switch provider { + case .claude: + let transcript = claudeTranscripts[process.pid] + sessions.append(AgentSession( + id: transcript?.url.deletingPathExtension().lastPathComponent ?? "pid:\(process.pid)", + provider: .claude, + source: AgentPSOutputParser.source(for: process), + state: self.config.state( + lastActivityAt: transcript?.modifiedAt, + now: context.now, + hasLiveProcess: true), + pid: process.pid, + cwd: cwd, + projectName: Self.projectName(cwd), + startedAt: process.startedAt, + lastActivityAt: transcript?.modifiedAt, + transcriptPath: transcript?.url.path, + host: context.host)) + case .codex: + let rollout = rollouts.first { candidate in + !matchedRolloutPaths.contains(candidate.url.path) && + AgentSessionCorrelation.codexWorkingDirectoriesMatch(candidate.metadata.cwd, cwd) + } + if let rollout { + matchedRolloutPaths.insert(rollout.url.path) + } + let rolloutSource = rollout?.metadata.sessionSource + sessions.append(AgentSession( + id: rollout?.metadata.sessionID ?? "pid:\(process.pid)", + provider: .codex, + source: rolloutSource == nil || rolloutSource == .unknown ? .cli : rolloutSource ?? .cli, + state: self.config.state( + lastActivityAt: rollout?.modifiedAt, + now: context.now, + hasLiveProcess: true), + pid: process.pid, + cwd: cwd ?? rollout?.metadata.cwd, + projectName: Self.projectName(cwd ?? rollout?.metadata.cwd), + startedAt: process.startedAt, + lastActivityAt: rollout?.modifiedAt, + transcriptPath: rollout?.url.path, + host: context.host)) + } + } + + for rollout in rollouts where !matchedRolloutPaths.contains(rollout.url.path) { + guard var session = CodexRolloutFirstLineParser.makeSession( + metadata: rollout.metadata, + transcriptURL: rollout.url, + modifiedAt: rollout.modifiedAt, + host: context.host, + config: self.config, + now: context.now) + else { continue } + session.source = AgentSessionCorrelation.fileOnlyCodexSource( + metadataSource: session.source, + appServerPresent: codexAppServerPresent) + sessions.append(session) + } + + var seen = Set() + return sessions + .sorted { lhs, rhs in + if lhs.state != rhs.state { + return lhs.state == .active + } + return (lhs.lastActivityAt ?? lhs.startedAt ?? .distantPast) > + (rhs.lastActivityAt ?? rhs.startedAt ?? .distantPast) + } + .filter { seen.insert("\($0.host):\($0.id)").inserted } + } + + private func processOutput(environment: [String: String]) async -> String { + let binary = ["/bin/ps", "/usr/bin/ps"].first { FileManager.default.isExecutableFile(atPath: $0) } + guard let binary, + let result = try? await SubprocessRunner.run( + binary: binary, + arguments: ["-axo", "pid=,ppid=,lstart=,command="], + environment: environment, + timeout: 5, + label: "agent session process scan") + else { return "" } + return result.stdout + } + + private func cwdByPID(_ pids: [Int32], environment: [String: String]) async -> [Int32: String] { + guard !pids.isEmpty else { return [:] } + if let lsof = self.findExecutable("lsof", environment: environment) { + let joinedPIDs = pids.map(String.init).joined(separator: ",") + if let result = try? await SubprocessRunner.run( + binary: lsof, + arguments: ["-a", "-d", "cwd", "-Fn", "-p", joinedPIDs], + environment: environment, + timeout: 5, + acceptsNonZeroExit: true, + label: "agent session cwd scan") + { + return LSOFCWDOutputParser.parse(result.stdout) + } + } + + #if os(Linux) + return Dictionary(uniqueKeysWithValues: pids.compactMap { pid in + let path = "/proc/\(pid)/cwd" + guard let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: path) else { return nil } + return (pid, destination) + }) + #else + return [:] + #endif + } + + private func codexRollouts( + now: Date, + environment: [String: String], + homeDirectory: URL) -> [Rollout] + { + let root = URL( + fileURLWithPath: environment["CODEX_HOME"] ?? homeDirectory.appendingPathComponent(".codex").path, + isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + let calendar = Calendar(identifier: .gregorian) + let days = [now, calendar.date(byAdding: .day, value: -1, to: now)].compactMap(\.self) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy/MM/dd" + let fileManager = FileManager.default + + return days.flatMap { day -> [Rollout] in + let directory = root.appendingPathComponent(formatter.string(from: day), isDirectory: true) + guard let files = try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { return [] } + return files.compactMap { file in + guard file.lastPathComponent.hasPrefix("rollout-"), file.pathExtension == "jsonl", + let modifiedAt = try? file.resourceValues( + forKeys: [.contentModificationDateKey]).contentModificationDate, + let metadata = CodexRolloutFirstLineParser.read(from: file) + else { return nil } + return Rollout(url: file, modifiedAt: modifiedAt, metadata: metadata) + } + }.sorted { $0.modifiedAt > $1.modifiedAt } + } + + private func findExecutable(_ name: String, environment: [String: String]) -> String? { + let path = environment["PATH"] ?? "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin" + return path.split(separator: ":") + .map { String($0) + "/" + name } + .first { FileManager.default.isExecutableFile(atPath: $0) } + } + + private static func standardized(_ path: String?) -> String? { + path.map { URL(fileURLWithPath: $0).standardizedFileURL.path } + } + + private static func projectName(_ cwd: String?) -> String? { + guard let cwd, !cwd.isEmpty else { return nil } + return URL(fileURLWithPath: cwd).lastPathComponent + } +} diff --git a/Sources/CodexBarCore/RemoteSessionFetcher.swift b/Sources/CodexBarCore/RemoteSessionFetcher.swift new file mode 100644 index 0000000000..b14e8cbf80 --- /dev/null +++ b/Sources/CodexBarCore/RemoteSessionFetcher.swift @@ -0,0 +1,191 @@ +import Foundation + +public struct RemoteSessionHostResult: Equatable, Sendable, Identifiable { + public let host: String + public let sessions: [AgentSession] + public let error: String? + + public var id: String { + self.host + } + + public var isReachable: Bool { + self.error == nil + } + + public init(host: String, sessions: [AgentSession], error: String?) { + self.host = host + self.sessions = sessions + self.error = error + } +} + +public enum TailscaleStatusParser { + public static func hosts(from data: Data, excludingLocalHost localHost: String? = nil) -> [String] { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] } + let peers: [[String: Any]] = if let dictionary = root["Peer"] as? [String: [String: Any]] { + Array(dictionary.values) + } else if let array = root["Peer"] as? [[String: Any]] { + array + } else { + [] + } + let selfStatus = root["Self"] as? [String: Any] + let localLabels = Set([ + localHost, + selfStatus?["DNSName"] as? String, + selfStatus?["HostName"] as? String, + ].compactMap(self.firstDNSLabel).map { $0.lowercased() }) + + var seen = Set() + return peers.compactMap { peer in + guard peer["Online"] as? Bool == true, + let operatingSystem = peer["OS"] as? String, + operatingSystem == "macOS" || operatingSystem == "linux", + let label = self.firstDNSLabel(peer["DNSName"] as? String) + else { return nil } + let normalized = label.lowercased() + guard !localLabels.contains(normalized), seen.insert(normalized).inserted else { return nil } + return label + }.sorted() + } + + private static func firstDNSLabel(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + guard let label = trimmed.split(separator: ".").first, !label.isEmpty else { return nil } + return String(label) + } +} + +public struct RemoteSessionFetcher: Sendable { + public static let bundledCLIFallback = "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI" + + public init() {} + + public func discoveredHosts( + environment: [String: String] = ProcessInfo.processInfo.environment, + localHost: String = ProcessInfo.processInfo.hostName) async -> [String] + { + guard let tailscale = self.tailscaleBinary(environment: environment), + let result = try? await SubprocessRunner.run( + binary: tailscale, + arguments: ["status", "--json"], + environment: environment, + timeout: 5, + label: "Tailscale session host discovery"), + let data = result.stdout.data(using: .utf8) + else { return [] } + return TailscaleStatusParser.hosts(from: data, excludingLocalHost: localHost) + } + + public func fetch( + hosts: [String], + environment: [String: String] = ProcessInfo.processInfo.environment) async -> [RemoteSessionHostResult] + { + let normalizedHosts = Self.sanitizedHosts(hosts) + return await withTaskGroup( + of: RemoteSessionHostResult.self, + returning: [RemoteSessionHostResult].self) + { group in + for host in normalizedHosts { + group.addTask { + await self.fetch(host: host, environment: environment) + } + } + var results: [RemoteSessionHostResult] = [] + for await result in group { + results.append(result) + } + return results + .sorted { lhs, rhs in lhs.host.localizedCaseInsensitiveCompare(rhs.host) == .orderedAscending } + } + } + + public func focus( + sessionID: String, + host: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async + { + guard let host = Self.sanitizedHosts([host]).first else { return } + guard let ssh = self.findExecutable("ssh", environment: environment) ?? + (["/usr/bin/ssh", "/bin/ssh"].first { FileManager.default.isExecutableFile(atPath: $0) }) + else { return } + let command = "codexbar sessions focus \(Self.shellQuote(sessionID)) || " + + "\(Self.shellQuote(Self.bundledCLIFallback)) sessions focus \(Self.shellQuote(sessionID))" + _ = try? await SubprocessRunner.run( + binary: ssh, + arguments: ["-o", "BatchMode=yes", "-o", "ConnectTimeout=3", host, "sh", "-lc", Self.shellQuote(command)], + environment: environment, + timeout: 5, + acceptsNonZeroExit: true, + label: "focus remote agent session") + } + + private func fetch(host: String, environment: [String: String]) async -> RemoteSessionHostResult { + guard let ssh = self.findExecutable("ssh", environment: environment) ?? + (["/usr/bin/ssh", "/bin/ssh"].first { FileManager.default.isExecutableFile(atPath: $0) }) + else { + return RemoteSessionHostResult(host: host, sessions: [], error: "ssh not found") + } + let command = "codexbar sessions --json || " + + "\(Self.shellQuote(Self.bundledCLIFallback)) sessions --json" + do { + let result = try await SubprocessRunner.run( + binary: ssh, + arguments: [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=3", + host, + "sh", "-lc", Self.shellQuote(command), + ], + environment: environment, + timeout: 5, + label: "fetch remote agent sessions") + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + var sessions = try decoder.decode([AgentSession].self, from: Data(result.stdout.utf8)) + for index in sessions.indices { + sessions[index].host = host + } + return RemoteSessionHostResult(host: host, sessions: sessions, error: nil) + } catch { + return RemoteSessionHostResult(host: host, sessions: [], error: error.localizedDescription) + } + } + + private func tailscaleBinary(environment: [String: String]) -> String? { + self.findExecutable("tailscale", environment: environment) ?? { + let bundled = "/Applications/Tailscale.app/Contents/MacOS/Tailscale" + return FileManager.default.isExecutableFile(atPath: bundled) ? bundled : nil + }() + } + + private func findExecutable(_ name: String, environment: [String: String]) -> String? { + let path = environment["PATH"] ?? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + return path.split(separator: ":") + .map { String($0) + "/" + name } + .first { FileManager.default.isExecutableFile(atPath: $0) } + } + + public static func sanitizedHosts(_ hosts: [String]) -> [String] { + var seen = Set() + return hosts.compactMap { rawHost in + let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines) + let hasUnsafeScalar = host.unicodeScalars.contains { scalar in + CharacterSet.controlCharacters.contains(scalar) || + CharacterSet.whitespacesAndNewlines.contains(scalar) + } + guard !host.isEmpty, + !host.hasPrefix("-"), + !hasUnsafeScalar, + seen.insert(host.lowercased()).inserted + else { return nil } + return host + } + } + + private static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/Sources/CodexBarCore/SessionWindowFocuser.swift b/Sources/CodexBarCore/SessionWindowFocuser.swift new file mode 100644 index 0000000000..c832e7341d --- /dev/null +++ b/Sources/CodexBarCore/SessionWindowFocuser.swift @@ -0,0 +1,107 @@ +#if os(macOS) +import AppKit +import ApplicationServices +import Foundation + +public enum SessionFocusResult: Equatable, Sendable { + case focused + case activatedApplicationOnly + case failed +} + +@MainActor +public enum SessionWindowFocuser { + private static let knownBundleIdentifiers: Set = [ + "com.mitchellh.ghostty", + "com.googlecode.iterm2", + "com.apple.Terminal", + "dev.warp.Warp-Stable", + "com.github.wez.wezterm", + "net.kovidgoyal.kitty", + "org.alacritty", + "com.microsoft.VSCode", + "com.todesktop.230313mzl4w4u92", + "dev.zed.Zed", + "com.anthropic.claudefordesktop", + ] + + @discardableResult + public static func focus(_ session: AgentSession, promptForAccessibility: Bool = true) -> SessionFocusResult { + guard let application = self.application(for: session) else { return .failed } + guard application.activate() else { return .failed } + + let trusted = AXIsProcessTrustedWithOptions( + ["AXTrustedCheckOptionPrompt": promptForAccessibility] as CFDictionary) + guard trusted else { return .activatedApplicationOnly } + + let appElement = AXUIElementCreateApplication(application.processIdentifier) + var windowsValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsValue) == .success, + let windows = windowsValue as? [AXUIElement], + let window = self.preferredWindow(windows, session: session) ?? windows.first + else { return .activatedApplicationOnly } + AXUIElementPerformAction(window, kAXRaiseAction as CFString) + return .focused + } + + private static func application(for session: AgentSession) -> NSRunningApplication? { + if let pid = session.pid { + var currentPID = pid + var fallback: NSRunningApplication? + var visited = Set() + while currentPID > 0, visited.insert(currentPID).inserted { + if let application = NSRunningApplication(processIdentifier: currentPID) { + fallback = fallback ?? application + if let bundleIdentifier = application.bundleIdentifier, + self.knownBundleIdentifiers.contains(bundleIdentifier) + { + return application + } + } + guard let parent = self.parentPID(of: currentPID), parent != currentPID else { break } + currentPID = parent + } + if let fallback { + return fallback + } + } + + let bundleIdentifier: String? = switch (session.provider, session.source) { + case (.claude, .desktopApp): "com.anthropic.claudefordesktop" + case (.codex, .desktopApp): "com.openai.codex" + default: nil + } + guard let bundleIdentifier else { return nil } + return NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).first + } + + private static func preferredWindow(_ windows: [AXUIElement], session: AgentSession) -> AXUIElement? { + let candidates = [session.projectName, session.cwd.map { URL(fileURLWithPath: $0).lastPathComponent }] + .compactMap { $0?.lowercased() } + .filter { !$0.isEmpty } + guard !candidates.isEmpty else { return nil } + return windows.first { window in + var titleValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &titleValue) == .success, + let title = titleValue as? String + else { return false } + let lowercasedTitle = title.lowercased() + return candidates.contains { lowercasedTitle.contains($0) } + } + } + + private static func parentPID(of pid: Int32) -> Int32? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-o", "ppid=", "-p", String(pid)] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { return nil } + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return nil } + return Int32(output.trimmingCharacters(in: .whitespacesAndNewlines)) + } +} +#endif diff --git a/Tests/CodexBarTests/AgentSessionJSONTests.swift b/Tests/CodexBarTests/AgentSessionJSONTests.swift new file mode 100644 index 0000000000..4afdf1b605 --- /dev/null +++ b/Tests/CodexBarTests/AgentSessionJSONTests.swift @@ -0,0 +1,33 @@ +import CodexBarCore +import Foundation +import Testing + +struct AgentSessionJSONTests { + @Test + func `sessions json round trip preserves stable schema`() throws { + let session = AgentSession( + id: "fixture-session", + provider: .codex, + source: .ide, + state: .active, + pid: 42, + cwd: "/tmp/project", + projectName: "project", + startedAt: Date(timeIntervalSince1970: 100), + lastActivityAt: Date(timeIntervalSince1970: 200), + transcriptPath: "/tmp/rollout.jsonl", + host: "local-mac") + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode([session]) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + let keys = try #require(object.first).keys + #expect(Set(keys) == [ + "id", "provider", "source", "state", "pid", "cwd", "projectName", "startedAt", + "lastActivityAt", "transcriptPath", "host", + ]) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + #expect(try decoder.decode([AgentSession].self, from: data) == [session]) + } +} diff --git a/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift new file mode 100644 index 0000000000..24a7dc20a0 --- /dev/null +++ b/Tests/CodexBarTests/AgentSessionMenuDescriptorTests.swift @@ -0,0 +1,90 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct AgentSessionMenuDescriptorTests { + @Test + func `session section counts groups and renders unreachable hosts`() { + let now = Date(timeIntervalSince1970: 1000) + let local = Self.session(id: "local", host: "local-mac", activity: now.addingTimeInterval(-60)) + let remote = Self.session(id: "remote", host: "clawmac", activity: now.addingTimeInterval(-720)) + let section = MenuDescriptor.agentSessionsSection( + localSessions: [local], + remoteHosts: [ + RemoteSessionHostResult(host: "clawmac", sessions: [remote], error: nil), + RemoteSessionHostResult(host: "offline", sessions: [], error: "Connection timed out"), + ], + now: now) + + guard case let .text(header, .headline) = section.entries[0] else { + Issue.record("Expected session headline") + return + } + #expect(header == "Agent Sessions (2)") + guard case let .action(localTitle, .focusAgentSession(_, remoteHost)) = section.entries[1] else { + Issue.record("Expected local session action") + return + } + #expect(localTitle.contains("alpha — codex · cli · 1m")) + #expect(remoteHost == nil) + guard case let .text(remoteGroup, .secondary) = section.entries[2] else { + Issue.record("Expected remote group") + return + } + #expect(remoteGroup == "clawmac — 1") + guard case let .unavailable(title, tooltip) = section.entries[4] else { + Issue.record("Expected unreachable host") + return + } + #expect(title == "offline — unreachable") + #expect(tooltip == "Connection timed out") + } + + @Test + func `reachable empty remote host keeps zero count section actionable`() { + let section = MenuDescriptor.agentSessionsSection( + localSessions: [], + remoteHosts: [RemoteSessionHostResult(host: "clawmac", sessions: [], error: nil)]) + + #expect(section.entries.contains { entry in + guard case let .unavailable(title, _) = entry else { return false } + return title == "No agent sessions found" + }) + } + + @Test + func `remote refresh gate retries changed settings and rejects stale result`() throws { + var gate = AgentSessionRemoteRefreshGate() + let initialGenerationCandidate = gate.begin() + let initialGeneration = try #require(initialGenerationCandidate) + gate.settingsDidChange() + #expect(gate.begin() == nil) + + let staleOutcome = gate.finish(generation: initialGeneration) + #expect(!staleOutcome.shouldPublish) + #expect(staleOutcome.shouldRetry) + + let currentGenerationCandidate = gate.begin() + let currentGeneration = try #require(currentGenerationCandidate) + let currentOutcome = gate.finish(generation: currentGeneration) + #expect(currentOutcome.shouldPublish) + #expect(!currentOutcome.shouldRetry) + } + + private static func session(id: String, host: String, activity: Date) -> AgentSession { + AgentSession( + id: id, + provider: .codex, + source: .cli, + state: .active, + pid: 42, + cwd: "/Users/test/alpha", + projectName: "alpha", + startedAt: nil, + lastActivityAt: activity, + transcriptPath: nil, + host: host) + } +} diff --git a/Tests/CodexBarTests/AgentSessionParserTests.swift b/Tests/CodexBarTests/AgentSessionParserTests.swift new file mode 100644 index 0000000000..9f14f648e2 --- /dev/null +++ b/Tests/CodexBarTests/AgentSessionParserTests.swift @@ -0,0 +1,74 @@ +import CodexBarCore +import Foundation +import Testing + +struct AgentSessionParserTests { + @Test + func `ps parser deduplicates desktop wrapper and excludes app server and helpers`() throws { + let output = try Self.fixtureString("agent-sessions-ps", extension: "txt") + let records = AgentPSOutputParser.parse(output) + let agents = AgentPSOutputParser.agentProcesses(from: records) + + #expect(records.count == 9) + #expect(agents.map(\ .pid) == [102, 201]) + #expect(AgentPSOutputParser.provider(for: agents[0]) == .claude) + #expect(AgentPSOutputParser.source(for: agents[0]) == .desktopApp) + #expect(AgentPSOutputParser.provider(for: agents[1]) == .codex) + #expect(agents[1].command.hasSuffix("strange argv here")) + #expect(AgentPSOutputParser.hasCodexAppServer(in: records)) + } + + @Test + func `lsof parser maps batched cwd records`() throws { + let output = try Self.fixtureString("agent-sessions-lsof", extension: "txt") + let paths = LSOFCWDOutputParser.parse(output) + + #expect(paths[102] == "/Users/test/Projects/alpha") + #expect(paths[201] == "/Users/test/Projects/project with spaces") + } + + @Test + func `same cwd processes remain uncorrelated when ownership is ambiguous`() { + let olderStart = Date(timeIntervalSince1970: 100) + let newerStart = Date(timeIntervalSince1970: 200) + let older = AgentProcessRecord(pid: 10, ppid: 1, startedAt: olderStart, command: "claude") + let newer = AgentProcessRecord(pid: 20, ppid: 1, startedAt: newerStart, command: "claude") + let olderTranscript = ClaudeSessionProjectMapper.Transcript( + url: URL(fileURLWithPath: "/tmp/older.jsonl"), + modifiedAt: Date(timeIntervalSince1970: 150)) + let newerTranscript = ClaudeSessionProjectMapper.Transcript( + url: URL(fileURLWithPath: "/tmp/newer.jsonl"), + modifiedAt: Date(timeIntervalSince1970: 250)) + + let assignments = AgentSessionCorrelation.assignClaudeTranscripts( + processes: [older, newer], + cwdByPID: [10: "/project", 20: "/project"], + transcriptsByCWD: ["/project": [newerTranscript, olderTranscript]]) + + #expect(assignments.isEmpty) + } + + @Test + func `newest process sorts first for rollout correlation`() { + let older = AgentProcessRecord( + pid: 10, + ppid: 1, + startedAt: Date(timeIntervalSince1970: 100), + command: "codex exec") + let newer = AgentProcessRecord( + pid: 20, + ppid: 1, + startedAt: Date(timeIntervalSince1970: 200), + command: "codex exec") + + #expect(AgentSessionCorrelation.newestProcessesFirst([older, newer]).map(\ .pid) == [20, 10]) + } + + static func fixtureURL(_ name: String, extension fileExtension: String) throws -> URL { + try #require(Bundle.module.url(forResource: name, withExtension: fileExtension, subdirectory: "Fixtures")) + } + + static func fixtureString(_ name: String, extension fileExtension: String) throws -> String { + try String(contentsOf: self.fixtureURL(name, extension: fileExtension), encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/ClaudeSessionMappingTests.swift b/Tests/CodexBarTests/ClaudeSessionMappingTests.swift new file mode 100644 index 0000000000..779691c4bb --- /dev/null +++ b/Tests/CodexBarTests/ClaudeSessionMappingTests.swift @@ -0,0 +1,36 @@ +import CodexBarCore +import Foundation +import Testing + +struct ClaudeSessionMappingTests { + @Test + func `cwd escaping replaces every non alphanumeric ASCII byte`() { + #expect(ClaudeSessionProjectMapper.escapedCWD("/Users/test/My Project_v2") == "-Users-test-My-Project-v2") + } + + @Test + func `newest transcript is selected from mapped project directory`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("ClaudeSessionMappingTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let cwd = "/Users/test/Projects/alpha" + let projectDirectory = home + .appendingPathComponent(".claude/projects", isDirectory: true) + .appendingPathComponent(ClaudeSessionProjectMapper.escapedCWD(cwd), isDirectory: true) + try FileManager.default.createDirectory(at: projectDirectory, withIntermediateDirectories: true) + let older = projectDirectory.appendingPathComponent("older.jsonl") + let newer = projectDirectory.appendingPathComponent("newer.jsonl") + try Data("fixture\n".utf8).write(to: older) + try Data("fixture\n".utf8).write(to: newer) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 100)], + ofItemAtPath: older.path) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 200)], + ofItemAtPath: newer.path) + + let match = try #require(ClaudeSessionProjectMapper.newestTranscript(cwd: cwd, homeDirectory: home)) + #expect(match.url.lastPathComponent == "newer.jsonl") + #expect(match.modifiedAt == Date(timeIntervalSince1970: 200)) + } +} diff --git a/Tests/CodexBarTests/CodexSessionRolloutTests.swift b/Tests/CodexBarTests/CodexSessionRolloutTests.swift new file mode 100644 index 0000000000..6e474a0a99 --- /dev/null +++ b/Tests/CodexBarTests/CodexSessionRolloutTests.swift @@ -0,0 +1,65 @@ +import CodexBarCore +import Foundation +import Testing + +struct CodexSessionRolloutTests { + @Test + func `first rollout line maps to file only agent session`() throws { + let url = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") + let metadata = try #require(CodexRolloutFirstLineParser.read(from: url)) + let now = Date(timeIntervalSince1970: 10000) + let modifiedAt = now.addingTimeInterval(-60) + let session = try #require(CodexRolloutFirstLineParser.makeSession( + metadata: metadata, + transcriptURL: url, + modifiedAt: modifiedAt, + host: "local-mac", + now: now)) + + #expect(session.id == "019f-session-fixture") + #expect(session.cwd == "/Users/test/Projects/alpha") + #expect(session.projectName == "alpha") + #expect(session.source == .cli) + #expect(session.state == .active) + #expect(session.pid == nil) + } + + @Test + func `file only rollout outside window is excluded while live process remains`() throws { + let url = try AgentSessionParserTests.fixtureURL("agent-session-rollout", extension: "jsonl") + let metadata = try #require(CodexRolloutFirstLineParser.read(from: url)) + let now = Date(timeIntervalSince1970: 10000) + let modifiedAt = now.addingTimeInterval(-1801) + + #expect(CodexRolloutFirstLineParser.makeSession( + metadata: metadata, + transcriptURL: url, + modifiedAt: modifiedAt, + host: "local-mac", + now: now) == nil) + #expect(CodexRolloutFirstLineParser.makeSession( + metadata: metadata, + transcriptURL: url, + modifiedAt: modifiedAt, + pid: 42, + host: "local-mac", + now: now)?.state == .idle) + } + + @Test + func `app server presence classifies unknown file only rollout as desktop`() { + #expect(AgentSessionCorrelation.fileOnlyCodexSource( + metadataSource: .unknown, + appServerPresent: true) == .desktopApp) + #expect(AgentSessionCorrelation.fileOnlyCodexSource( + metadataSource: .unknown, + appServerPresent: false) == .unknown) + } + + @Test + func `codex cwd matching rejects missing paths`() { + #expect(AgentSessionCorrelation.codexWorkingDirectoriesMatch("/repo/alpha", "/repo/./alpha")) + #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch(nil, nil)) + #expect(!AgentSessionCorrelation.codexWorkingDirectoriesMatch("/repo/alpha", nil)) + } +} diff --git a/Tests/CodexBarTests/Fixtures/agent-session-rollout.jsonl b/Tests/CodexBarTests/Fixtures/agent-session-rollout.jsonl new file mode 100644 index 0000000000..9f71dd8588 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/agent-session-rollout.jsonl @@ -0,0 +1,2 @@ +{"timestamp":"2026-07-06T16:00:00Z","type":"session_meta","payload":{"session_id":"019f-session-fixture","cwd":"/Users/test/Projects/alpha","originator":"codex_exec","source":"exec"}} +{"this":"second line must never be read by the session parser"} diff --git a/Tests/CodexBarTests/Fixtures/agent-sessions-lsof.txt b/Tests/CodexBarTests/Fixtures/agent-sessions-lsof.txt new file mode 100644 index 0000000000..7b9e2b42a9 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/agent-sessions-lsof.txt @@ -0,0 +1,4 @@ +p102 +n/Users/test/Projects/alpha +p201 +n/Users/test/Projects/project with spaces diff --git a/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt b/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt new file mode 100644 index 0000000000..53c2f31ee2 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/agent-sessions-ps.txt @@ -0,0 +1,9 @@ + 101 1 Mon Jul 6 09:00:00 2026 /Applications/Claude.app/Contents/Resources/disclaimer /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions + 102 101 Mon Jul 6 09:00:01 2026 /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions + 201 1 Mon Jul 6 09:01:00 2026 /opt/homebrew/bin/codex exec --full-auto strange argv here + 202 1 Mon Jul 6 09:02:00 2026 /Applications/Codex.app/Contents/Resources/codex app-server --listen stdio + 203 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/codex --help + 301 1 Mon Jul 6 09:04:00 2026 /Users/test/.local/bin/claude-code-acp --stdio + 401 1 Mon Jul 6 09:05:00 2026 /Applications/Codex.app/Contents/Frameworks/Codex Framework.framework/Helpers/Codex (Renderer) --type=renderer + 402 1 Mon Jul 6 09:06:00 2026 /Applications/Claude.app/Contents/MacOS/Claude + 403 1 Mon Jul 6 09:07:00 2026 ./Codex Computer Use.app/Contents/MacOS/helper mcp diff --git a/Tests/CodexBarTests/Fixtures/agent-sessions-tailscale.json b/Tests/CodexBarTests/Fixtures/agent-sessions-tailscale.json new file mode 100644 index 0000000000..63d612a7c8 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/agent-sessions-tailscale.json @@ -0,0 +1,10 @@ +{ + "Self": {"DNSName": "local-mac.example.ts.net.", "HostName": "local-mac"}, + "Peer": { + "node-1": {"DNSName": "clawmac.example.ts.net.", "OS": "macOS", "Online": true}, + "node-2": {"DNSName": "linuxbox.example.ts.net.", "OS": "linux", "Online": true}, + "node-3": {"DNSName": "phone.example.ts.net.", "OS": "iOS", "Online": true}, + "node-4": {"DNSName": "offline.example.ts.net.", "OS": "macOS", "Online": false}, + "node-5": {"DNSName": "local-mac.example.ts.net.", "OS": "macOS", "Online": true} + } +} diff --git a/Tests/CodexBarTests/TailscaleSessionTests.swift b/Tests/CodexBarTests/TailscaleSessionTests.swift new file mode 100644 index 0000000000..be91d66c4d --- /dev/null +++ b/Tests/CodexBarTests/TailscaleSessionTests.swift @@ -0,0 +1,29 @@ +import CodexBarCore +import Foundation +import Testing + +struct TailscaleSessionTests { + @Test + func `online mac and linux peers become hosts`() throws { + let url = try AgentSessionParserTests.fixtureURL("agent-sessions-tailscale", extension: "json") + let hosts = try TailscaleStatusParser.hosts( + from: Data(contentsOf: url), + excludingLocalHost: "local-mac") + + #expect(hosts == ["clawmac", "linuxbox"]) + } + + @Test + func `ssh destinations reject options whitespace and controls`() { + let hosts = RemoteSessionFetcher.sanitizedHosts([ + "user@clawmac", + "USER@CLAWMAC", + "-oProxyCommand=touch /tmp/unsafe", + "host with-space", + "host\nother", + "linuxbox", + ]) + + #expect(hosts == ["user@clawmac", "linuxbox"]) + } +} diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..37ef8e0663 --- /dev/null +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,77 @@ +{ + "originHash" : "2c34a752ff5b5315e5bae001ccb84dbfa3f7ee7793a23b8862dbc8d096d771ba", + "pins" : [ + { + "identity" : "commander", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/Commander", + "state" : { + "revision" : "b9fb00564aa3229deeb48090801fec2c185951f4", + "version" : "0.2.3" + } + }, + { + "identity" : "keyboardshortcuts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sindresorhus/KeyboardShortcuts", + "state" : { + "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27", + "version" : "2.4.0" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", + "version" : "2.9.4" + } + }, + { + "identity" : "sweetcookiekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/SweetCookieKit", + "state" : { + "revision" : "21bedea672a3e63ccad24d744051e76cdf0462dd", + "version" : "0.4.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" + } + }, + { + "identity" : "vortex", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zats/Vortex", + "state" : { + "revision" : "ef5392088d4aeb255c4eee83157dbdafcd31bf07" + } + } + ], + "version" : 3 +} diff --git a/docs/agent-sessions-design.md b/docs/agent-sessions-design.md new file mode 100644 index 0000000000..c74d770023 --- /dev/null +++ b/docs/agent-sessions-design.md @@ -0,0 +1,98 @@ +# Agent Sessions (prototype) + +Track live Codex + Claude Code agent sessions — local Mac first, other Macs on the tailnet second — and surface them in the CodexBar menu with click-to-focus of the owning terminal window. + +## Why in CodexBar + +CodexBar already parses `~/.claude/projects` JSONL (cost scanner) and ships a bundled CLI on macOS + Linux. Sessions reuse both: the local scanner feeds the menu UI, and the same scanner exposed as `codexbar sessions --json` is what remote Macs run over SSH. No daemon, no new app. + +## Data model (CodexBarCore) + +```swift +public struct AgentSession: Codable, Sendable, Identifiable { + public enum Provider: String, Codable, Sendable { case codex, claude } + public enum Source: String, Codable, Sendable { case cli, desktopApp, ide, unknown } + public enum State: String, Codable, Sendable { case active, idle } + + public var id: String // session UUID when resolvable, else "pid:" + public var provider: Provider + public var source: Source + public var state: State + public var pid: Int32? // nil for file-only (e.g. Codex desktop) sessions + public var cwd: String? + public var projectName: String? // last path component of cwd + public var startedAt: Date? + public var lastActivityAt: Date? // transcript mtime + public var transcriptPath: String? + public var host: String // local hostname, or remote host label +} +``` + +`active` = last activity ≤ 120 s ago. `idle` = live process (or recent file) with older activity. Constants live in one `SessionScanConfig` struct (activeWindow 120 s, fileOnlyWindow 30 min) so thresholds are tunable/testable. + +## Local scanner (CodexBarCore, no new deps) + +`LocalAgentSessionScanner` combines two signals: + +1. **Process scan** — parse `ps -axo pid=,ppid=,lstart=,command=`. + - Claude: command basename `claude` (skip obvious non-agent helpers). Source: path contains `Application Support/Claude/claude-code` → `.desktopApp`, else `.cli`. Deduplicate the wrapper/child pair (desktop spawns `disclaimer` parent + `claude` child with same argv; keep the child). + - Codex: basename `codex` with no `app-server` argument → `.cli` (TUI or `exec`). `codex app-server` marks the desktop app as present but is not itself a session. + - cwd per pid via one batched `lsof -a -d cwd -Fn -p ` call (parse `p`/`n` records). Failure → cwd nil, session still listed. +2. **Transcript correlation** + - Claude: cwd → `~/.claude/projects//` (escape: every non-alphanumeric ASCII → `-`), newest `*.jsonl` by mtime → session id (filename UUID), lastActivityAt (mtime). Also reuse `ClaudeDesktopProjectsLocator` roots so desktop local-agent-mode sessions resolve. + - Codex: enumerate `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` for today + yesterday (`$CODEX_HOME` respected). Read only the first line (`session_meta`: `session_id`, `cwd`, `originator`, `source`). File with mtime ≤ fileOnlyWindow and no matching live pid → file-only session, source from `originator` (`codex_exec`/`exec` → `.cli`; ide-ish originators → `.ide`; desktop → `.desktopApp`). Live `codex` pids match to rollouts by cwd (newest wins); unmatched live pid still listed with nil transcript. + - Never read more than the first line of any JSONL; never load whole transcripts. + +Scanner is `Sendable`, pure functions where possible; ps/lsof output parsing lives in dedicated parser types fed by strings so tests use fixtures. + +## CLI (CodexBarCLI) + +- `codexbar sessions` — table; `--json` — `[AgentSession]` (stable field names above; ISO-8601 dates). +- `codexbar sessions focus ` — macOS only: focus the session's terminal window (see Focus). Exit 1 if id unknown, 2 if focus failed. +- Follows existing `CLI*Command.swift` conventions. Works on Linux for listing (ps/proc paths guarded), focus is Darwin-only. + +## Remote hosts (CodexBarCore + app) + +`RemoteSessionFetcher`: + +- Host list = manual entries (settings, ssh destinations like `steipete@clawmac`) ∪ automatic Tailscale discovery (no-op when tailscale is absent): run `tailscale status --json` (PATH, then `/Applications/Tailscale.app/Contents/MacOS/Tailscale`), take online peers with `"OS": "macOS"|"linux"`, use first `DNSName` label as host. Local host excluded. +- Fetch per host (parallel, 5 s budget): `ssh -o BatchMode=yes -o ConnectTimeout=3 sh -lc 'codexbar sessions --json'` with fallback to the bundled app CLI path (resolve the canonical bundled location from `Scripts/package_app.sh` and hardcode it as fallback: `… || sessions --json`). Host errors are non-fatal: host shown as unreachable, others still render. +- Remote focus: fire-and-forget `ssh sh -lc 'codexbar sessions focus '`. +- Refresh: local scan every 30 s while the status item exists (cheap), remote every 60 s and immediately on menu open; both skipped when the feature is off. Reuse existing refresh loop plumbing rather than new timers if it fits. + +## Menu UI (CodexBar app) + +- New menu section **Agent Sessions (N)** (N = total, all hosts) above the settings/footer area, built through the existing `MenuDescriptor`-style seam so it's testable headless. +- Local sessions first, then one group per remote host (`clawmac — 2`, unreachable hosts greyed with a tooltip). Row: state dot (● active / ○ idle), provider glyph, `projectName — provider · source · 12m`. +- Click local row → `SessionWindowFocuser`. Click remote row → remote focus ssh call. +- Settings: "Sessions" group — a single enable toggle (default on) plus a manual hosts text field (comma-separated); Tailscale discovery is always on while the feature is enabled. Persist in `SettingsStore` like neighboring prefs. + +## Focus (macOS, app + CLI shared in Core or app-adjacent target) + +`SessionWindowFocuser`: + +1. pid → walk ppid chain to the nearest ancestor whose `NSRunningApplication.bundleIdentifier` is a known terminal/editor host: Ghostty, iTerm2, Apple Terminal, Warp, WezTerm, kitty, Alacritty, VS Code, Cursor, Zed, Claude desktop (`com.anthropic.claudefordesktop`). Fallback: the app owning the pid. +2. Activate the app, then AX (`AXUIElementCreateApplication` → `AXWindows`): raise the window whose title contains projectName or the cwd tail; fallback to frontmost window of that app. Requires Accessibility permission — call `AXIsProcessTrustedWithOptions` with prompt on first use; degrade gracefully (activate app only) when untrusted. +3. File-only sessions (no pid): Claude desktop → activate Claude.app; Codex desktop → activate Codex.app; otherwise no-op with log. + +tmux pane / terminal-tab precision is out of scope for the prototype. + +## Tests (Tests/CodexBarTests) + +Fixture-driven, no live processes, no Keychain/AX: + +- ps output parser: desktop `disclaimer`+`claude` dedupe, codex vs `codex app-server`, weird argv. +- lsof `-Fn` parser. +- Claude cwd escaping → project dir mapping; newest-jsonl selection (temp dirs). +- Codex rollout first-line parse → AgentSession (fixture JSONL), file-only window cutoff. +- Tailscale status JSON → host list (fixture; offline/iOS peers excluded). +- Sessions JSON round-trip (CLI output schema stability). +- Menu section descriptor: counts, grouping, unreachable-host rendering. + +## Non-goals (prototype) + +Claude.ai chat sessions; Codex cloud tasks; historical session browsing/analytics; "waiting on permission" state; tmux pane/tab focus; Bonjour/mDNS; persistent remote daemon or push transport; widget changes. No new SPM dependencies. + +## Proof + +`make check` clean; `make test` (or focused `swift test --filter` covering the new tests) green; `swift run CodexBarCLI sessions --json` produces plausible output on this Mac. diff --git a/docs/sessions.md b/docs/sessions.md new file mode 100644 index 0000000000..0e70787013 --- /dev/null +++ b/docs/sessions.md @@ -0,0 +1,17 @@ +# Agent Sessions + +CodexBar can list live Codex and Claude Code sessions on this Mac and other Macs or Linux hosts reachable over SSH. + +Enable **Settings → General → Sessions**. Local sessions refresh every 30 seconds. Remote sessions refresh every 60 seconds and whenever the menu opens. Tailscale discovery includes online macOS and Linux peers; add extra SSH destinations as a comma-separated list, such as `user@host`. + +The menu groups local sessions first, followed by each remote host. A filled dot is active; an empty dot is idle. Select a local row to activate its terminal, editor, or desktop app. The first focus attempt can request macOS Accessibility permission so CodexBar can raise the matching window. Remote rows run the same focus command over SSH. + +The CLI exposes the same scanner: + +```console +codexbar sessions +codexbar sessions --json +codexbar sessions focus +``` + +Remote hosts need key-based, non-interactive SSH and either `codexbar` on `PATH` or CodexBar installed in `/Applications`. From 30ae39a7ee83247cb6eb0d1daeb7d7ac084475d5 Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Tue, 7 Jul 2026 11:16:40 +0100 Subject: [PATCH 103/105] Recognize Ollama WorkOS session cookies (#1942) * Recognize Ollama WorkOS session cookies * Test Ollama WorkOS cookie selection * Document Ollama WorkOS cookie support * Add screenshot proof * chore: drop placeholder proof image The committed proof image was a 1x1 placeholder, not real evidence. Executable test output in the PR description replaces it. * docs: note Ollama WorkOS sessions --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 3 +++ .../Providers/Ollama/OllamaUsageFetcher.swift | 1 + .../OllamaUsageFetcherTests.swift | 18 ++++++++++++++++++ docs/ollama.md | 2 ++ 4 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd84cd320..5d7a69c98f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### 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! + ## 0.41.0 — 2026-07-06 ### Added diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index dc527ed479..7cfc2464ec 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -12,6 +12,7 @@ private let ollamaSessionCookieNames: Set = [ "__Secure-session", "ollama_session", "__Host-ollama_session", + "wos-session", "__Secure-next-auth.session-token", "next-auth.session-token", ] diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index 16333938af..d53be31995 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -83,6 +83,14 @@ struct OllamaUsageFetcherTests { #expect(resolved?.contains("__Secure-session=abc") == true) } + @Test + func `manual mode accepts workos session cookie header`() throws { + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: "wos-session=abc; theme=dark", + manualCookieMode: true) + #expect(resolved?.contains("wos-session=abc") == true) + } + @Test func `retry policy retries only for auth errors`() { #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.invalidCredentials)) @@ -157,6 +165,16 @@ struct OllamaUsageFetcherTests { #expect(selected.sourceLabel == "Profile D") } + @Test + func `cookie selector accepts workos session cookie`() throws { + let candidate = OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "wos-session", value: "auth")], + sourceLabel: "WorkOS Profile") + + let selected = try OllamaCookieImporter.selectSessionInfo(from: [candidate]) + #expect(selected.sourceLabel == "WorkOS Profile") + } + @Test func `cookie selector keeps recognized candidates in order`() throws { let first = OllamaCookieImporter.SessionInfo( diff --git a/docs/ollama.md b/docs/ollama.md index 42880756bf..49b9df697f 100644 --- a/docs/ollama.md +++ b/docs/ollama.md @@ -39,6 +39,8 @@ Ollama API keys currently do not expire, but they can be revoked from the key se - API-key mode fetches `https://ollama.com/api/tags` with `Authorization: Bearer ` to verify Cloud API access. - Cookie mode fetches `https://ollama.com/settings` using browser cookies. +- Cookie discovery recognizes the current WorkOS AuthKit `wos-session` cookie alongside legacy Ollama and NextAuth + session names. - Parses: - Plan badge under **Cloud Usage**. - **Session usage** and **Weekly usage** percentages. From aa401f1d8b7412b6a71abb6d13f57f5efc64d2ad Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Tue, 7 Jul 2026 11:35:26 +0100 Subject: [PATCH 104/105] Detect Ollama WorkOS sign-in redirects (#1944) * Detect Ollama WorkOS sign-in redirects * Test Ollama WorkOS redirect detection * Document Ollama WorkOS redirect handling * Add screenshot proof * chore: drop placeholder proof image The committed proof image was a 1x1 placeholder, not real evidence. Executable test output in the PR description replaces it. * fix: detect WorkOS sign-in redirects across all WorkOS hosts WorkOS AuthKit serves the hosted authorize flow from auth.workos.com (and historically api.workos.com). Matching only api.workos.com misses the canonical auth.workos.com redirect, so an expired session would fall through to a parse failure instead of sign-in recovery. Match any *.workos.com host carrying the /user_management/authorize path, and cover auth.workos.com plus a non-WorkOS negative case in the test. * fix(ollama): treat the WorkOS-hosted signin.ollama.com landing as expired The real unauthenticated Ollama/WorkOS redirect chain (verified live) terminates on a hosted sign-in page at signin.ollama.com with HTTP 200, so the final-URL sign-in check previously fell through to a parseFailed error. Classify any landing on signin.ollama.com as a sign-in redirect so the fetcher surfaces invalidCredentials and CodexBar can advance to the next cookie candidate / show sign-in guidance. Add a test case for the signin.ollama.com terminal landing. * fix: harden Ollama sign-in classification --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../Providers/Ollama/OllamaUsageFetcher.swift | 23 ++++++ .../OllamaUsageFetcherRetryMappingTests.swift | 72 +++++++++++++++++-- .../OllamaUsageFetcherTests.swift | 21 ++++++ docs/ollama.md | 2 + 5 files changed, 112 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d7a69c98f..6d957bd5d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### 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 diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index 7cfc2464ec..d55b4b6325 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -532,6 +532,10 @@ public struct OllamaUsageFetcher: Sendable { statusCode: httpResponse.statusCode, url: httpResponse.response.url?.absoluteString ?? "unknown") + if httpResponse.statusCode == 200, Self.isSignInRedirect(httpResponse.response.url) { + throw OllamaUsageError.invalidCredentials + } + guard httpResponse.statusCode == 200 else { if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { throw OllamaUsageError.invalidCredentials @@ -628,6 +632,25 @@ public struct OllamaUsageFetcher: Sendable { if host == "ollama.com" || host == "www.ollama.com" { return true } return host.hasSuffix(".ollama.com") } + + static func isSignInRedirect(_ url: URL?) -> Bool { + guard url?.scheme?.lowercased() == "https" else { return false } + guard let url, let host = url.host?.lowercased() else { return false } + let path = url.path.lowercased() + if host == "ollama.com" || host == "www.ollama.com" { + return path == "/signin" + } + // WorkOS AuthKit ultimately bounces unauthenticated requests to a hosted + // Ollama sign-in page on the `signin.ollama.com` subdomain; any landing + // there means the session is expired and the user must sign in again. + if host == "signin.ollama.com" { + return true + } + // WorkOS AuthKit serves the hosted authorization flow from auth.workos.com + // (and historically api.workos.com); match any WorkOS host carrying the + // authorize path so the detection survives host changes or CNAMEs. + return host.hasSuffix(".workos.com") && path.hasPrefix("/user_management/authorize") + } } public struct OllamaAPISettingsReader: Sendable { diff --git a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift index 80207015bc..749fd4662e 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift @@ -122,13 +122,7 @@ struct OllamaUsageFetcherRetryMappingTests { return Self.makeResponse(url: url, body: body, statusCode: 200) } - let fetcher = OllamaUsageFetcher( - browserDetection: BrowserDetection(cacheTTL: 0), - makeURLSession: { delegate in - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [OllamaRetryMappingStubURLProtocol.self] - return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) - }) + let fetcher = self.makeCookieFetcher() do { _ = try await fetcher.fetch( cookieHeaderOverride: "session=test-cookie", @@ -145,6 +139,70 @@ struct OllamaUsageFetcherRetryMappingTests { } } + @Test + func `workos sign in landing surfaces invalid credentials before parsing`() async throws { + defer { OllamaRetryMappingStubURLProtocol.handler = nil } + + let landingURL = try #require(URL( + string: "https://signin.ollama.com/?client_id=test&authorization_session_id=expired")) + OllamaRetryMappingStubURLProtocol.handler = { request in + #expect(request.url == URL(string: "https://ollama.com/settings")) + let body = "Sign in to Ollama" + return Self.makeResponse(url: landingURL, body: body, statusCode: 200) + } + + let fetcher = self.makeCookieFetcher() + do { + _ = try await fetcher.fetch( + cookieHeaderOverride: "session=expired-cookie", + manualCookieMode: true) + Issue.record("Expected OllamaUsageError.invalidCredentials") + } catch let error as OllamaUsageError { + guard case .invalidCredentials = error else { + Issue.record("Expected invalidCredentials, got \(error)") + return + } + } catch { + Issue.record("Expected OllamaUsageError.invalidCredentials, got \(error)") + } + } + + @Test + func `workos sign in service failure remains a network error`() async throws { + defer { OllamaRetryMappingStubURLProtocol.handler = nil } + + let landingURL = try #require(URL(string: "https://signin.ollama.com/")) + OllamaRetryMappingStubURLProtocol.handler = { _ in + Self.makeResponse(url: landingURL, body: "Service unavailable", statusCode: 503) + } + + let fetcher = self.makeCookieFetcher() + do { + _ = try await fetcher.fetch( + cookieHeaderOverride: "session=expired-cookie", + manualCookieMode: true) + Issue.record("Expected OllamaUsageError.networkError") + } catch let error as OllamaUsageError { + guard case let .networkError(message) = error else { + Issue.record("Expected networkError, got \(error)") + return + } + #expect(message == "HTTP 503") + } catch { + Issue.record("Expected OllamaUsageError.networkError, got \(error)") + } + } + + private func makeCookieFetcher() -> OllamaUsageFetcher { + OllamaUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + makeURLSession: { delegate in + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [OllamaRetryMappingStubURLProtocol.self] + return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + }) + } + private static func makeResponse( url: URL, body: String, diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index d53be31995..c2f141b39e 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -31,6 +31,27 @@ struct OllamaUsageFetcherTests { #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://app.ollama.com/path"))) } + @Test + func `recognizes current ollama sign in redirects`() { + #expect(OllamaUsageFetcher.isSignInRedirect(URL(string: "https://ollama.com/signin"))) + #expect(OllamaUsageFetcher.isSignInRedirect(URL( + string: "https://api.workos.com/user_management/authorize?client_id=test"))) + #expect(OllamaUsageFetcher.isSignInRedirect(URL( + string: "https://auth.workos.com/user_management/authorize?client_id=test"))) + // The real unauthenticated chain lands on the WorkOS-hosted Ollama sign-in + // page on the `signin.ollama.com` subdomain (verified live); that terminal + // landing must also classify as a sign-in redirect. + #expect(OllamaUsageFetcher.isSignInRedirect(URL( + string: "https://signin.ollama.com/?client_id=test&authorization_session_id=x"))) + #expect(!OllamaUsageFetcher.isSignInRedirect(URL(string: "https://ollama.com/settings"))) + #expect(!OllamaUsageFetcher.isSignInRedirect(URL(string: "https://api.workos.com/other"))) + #expect(!OllamaUsageFetcher.isSignInRedirect(URL(string: "http://ollama.com/signin"))) + #expect(!OllamaUsageFetcher.isSignInRedirect(URL( + string: "http://auth.workos.com/user_management/authorize?client_id=test"))) + #expect(!OllamaUsageFetcher.isSignInRedirect(URL( + string: "https://example.com/user_management/authorize?client_id=test"))) + } + @Test func `manual mode without valid header throws no session cookie`() { do { diff --git a/docs/ollama.md b/docs/ollama.md index 49b9df697f..e6c981e978 100644 --- a/docs/ollama.md +++ b/docs/ollama.md @@ -41,6 +41,8 @@ Ollama API keys currently do not expire, but they can be revoked from the key se - Cookie mode fetches `https://ollama.com/settings` using browser cookies. - Cookie discovery recognizes the current WorkOS AuthKit `wos-session` cookie alongside legacy Ollama and NextAuth session names. +- Redirects from settings to `/signin` or the WorkOS AuthKit authorization page are treated as expired sessions, so + CodexBar can try the next cookie candidate and show sign-in guidance instead of a parser error. - Parses: - Plan badge under **Cloud Usage**. - **Session usage** and **Weekly usage** percentages. From b8a40a1f9368132b773b368d2faa555318658a86 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Tue, 7 Jul 2026 18:53:47 +0000 Subject: [PATCH 105/105] Fix Gemini Plus plan display from loadCodeAssist paidTier Consumer Gemini accounts (Google AI Pro/Ultra) still report currentTier free-tier. Mirror Gemini CLI setup and prefer paidTier.name for the displayed plan so Plus subscribers are not labeled Free. Fixes steipete/CodexBar#1972 Co-authored-by: Cursor --- .../Providers/Gemini/GeminiStatusProbe.swift | 85 +++++++++++++------ .../CodexBarTests/GeminiAPITestHelpers.swift | 19 ++++- .../GeminiStatusProbePlanTests.swift | 45 ++++++++++ docs/gemini.md | 1 + 4 files changed, 125 insertions(+), 25 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 5863ff98ce..2002dbe21b 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -337,25 +337,10 @@ public struct GeminiStatusProbe: Sendable { let snapshot = try Self.parseAPIResponse(data, email: claims.email) - // Plan display strings with tier mapping: - // - standard-tier: Paid subscription (AI Pro, AI Ultra, Code Assist - // Standard/Enterprise, Developer Program Premium) - // - free-tier + hd claim: Workspace account (Gemini included free since Jan 2025) - // - free-tier: Personal free account (1000 req/day limit) - // - legacy-tier: Unknown legacy/grandfathered tier - // - nil (API failed): Leave blank (no display) - let plan: String? = switch (caStatus.tier, claims.hostedDomain) { - case (.standard, _): - "Paid" - case let (.free, .some(domain)): - { Self.log.info("Workspace account detected", metadata: ["domain": domain]); return "Workspace" }() - case (.free, .none): - { Self.log.info("Personal free account"); return "Free" }() - case (.legacy, _): - "Legacy" - case (.none, _): - { Self.log.info("Tier detection failed, leaving plan blank"); return nil }() - } + let plan = Self.resolveAccountPlan( + tier: caStatus.tier, + hostedDomain: claims.hostedDomain, + paidTierName: caStatus.paidTierName) return GeminiStatusSnapshot( modelQuotas: snapshot.modelQuotas, @@ -411,8 +396,9 @@ public struct GeminiStatusProbe: Sendable { private struct CodeAssistStatus { let tier: GeminiUserTierId? let projectId: String? + let paidTierName: String? - static let empty = CodeAssistStatus(tier: nil, projectId: nil) + static let empty = CodeAssistStatus(tier: nil, projectId: nil, paidTierName: nil) } private static func loadCodeAssistStatus( @@ -484,20 +470,26 @@ public struct GeminiStatusProbe: Sendable { } let tierId = (json["currentTier"] as? [String: Any])?["id"] as? String + let paidTierName = Self.parsePaidTierName(from: json) + guard let tierId else { Self.log.warning("loadCodeAssist: no currentTier.id in response", metadata: [ "json": "\(json)", ]) - return CodeAssistStatus(tier: nil, projectId: projectId) + return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName) } guard let tier = GeminiUserTierId(rawValue: tierId) else { Self.log.warning("loadCodeAssist: unknown tier ID", metadata: ["tierId": tierId]) - return CodeAssistStatus(tier: nil, projectId: projectId) + return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName) } - Self.log.info("loadCodeAssist: success", metadata: ["tier": tierId, "projectId": projectId ?? "nil"]) - return CodeAssistStatus(tier: tier, projectId: projectId) + Self.log.info("loadCodeAssist: success", metadata: [ + "tier": tierId, + "projectId": projectId ?? "nil", + "paidTierName": paidTierName ?? "nil", + ]) + return CodeAssistStatus(tier: tier, projectId: projectId, paidTierName: paidTierName) } private struct OAuthCredentials { @@ -1142,6 +1134,51 @@ public struct GeminiStatusProbe: Sendable { } } +extension GeminiStatusProbe { + /// Plan display strings with tier mapping: + /// - standard-tier: Paid subscription (Code Assist Standard/Enterprise, Developer Program Premium) + /// - free-tier + hd claim: Workspace account (Gemini included free since Jan 2025) + /// - free-tier + paidTier.name: Consumer subscription (Google AI Pro/Ultra, shown as Plus/Ultra) + /// - free-tier: Personal free account + /// - legacy-tier: Unknown legacy/grandfathered tier + /// - nil (API failed): Leave blank (no display) + fileprivate static func resolveAccountPlan( + tier: GeminiUserTierId?, + hostedDomain: String?, + paidTierName: String?) -> String? + { + switch (tier, hostedDomain) { + case (.standard, _): + return "Paid" + case let (.free, .some(domain)): + Self.log.info("Workspace account detected", metadata: ["domain": domain]) + return "Workspace" + case (.free, .none): + if let paidTierName { + self.log.info("Consumer paid tier detected", metadata: ["plan": paidTierName]) + return paidTierName + } + Self.log.info("Personal free account") + return "Free" + case (.legacy, _): + return "Legacy" + case (.none, _): + self.log.info("Tier detection failed, leaving plan blank") + return nil + } + } + + fileprivate static func parsePaidTierName(from json: [String: Any]) -> String? { + guard let paidTier = json["paidTier"] as? [String: Any], + let rawName = paidTier["name"] as? String + else { + return nil + } + let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + extension GeminiStatusProbe { private static let processTimeoutQueue = DispatchQueue( label: "com.steipete.codexbar.gemini-process-timeout", diff --git a/Tests/CodexBarTests/GeminiAPITestHelpers.swift b/Tests/CodexBarTests/GeminiAPITestHelpers.swift index 22fba5651f..20d0cab6a6 100644 --- a/Tests/CodexBarTests/GeminiAPITestHelpers.swift +++ b/Tests/CodexBarTests/GeminiAPITestHelpers.swift @@ -76,7 +76,11 @@ enum GeminiAPITestHelpers { return "header.\(encoded).sig" } - static func loadCodeAssistResponse(tierId: String, projectId: String? = nil) -> Data { + static func loadCodeAssistResponse( + tierId: String, + projectId: String? = nil, + paidTierName: String? = nil) -> Data + { var payload: [String: Any] = [ "currentTier": [ "id": tierId, @@ -86,9 +90,22 @@ enum GeminiAPITestHelpers { if let projectId { payload["cloudaicompanionProject"] = projectId } + if let paidTierName { + payload["paidTier"] = [ + "id": tierId, + "name": paidTierName, + ] + } return self.jsonData(payload) } + static func loadCodeAssistConsumerPlusResponse(projectId: String? = "cloudaicompanion-123") -> Data { + self.loadCodeAssistResponse( + tierId: "free-tier", + projectId: projectId, + paidTierName: "Plus") + } + static func loadCodeAssistFreeTierResponse() -> Data { self.loadCodeAssistResponse(tierId: "free-tier") } diff --git a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift index 9e38fb8534..8b7002277e 100644 --- a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift @@ -252,6 +252,51 @@ struct GeminiStatusProbePlanTests { #expect(snapshot.accountPlan == "Workspace") } + @Test + func `detects consumer plus from free tier with paid tier name`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let idToken = GeminiAPITestHelpers.makeIDToken(email: "user@gmail.com") + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: idToken) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistConsumerPlusResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountPlan == "Plus") + } + @Test func `detects free from free tier without hosted domain`() async throws { let env = try GeminiTestEnvironment() diff --git a/docs/gemini.md b/docs/gemini.md index 1b007a5f76..82c53e7fd5 100644 --- a/docs/gemini.md +++ b/docs/gemini.md @@ -66,6 +66,7 @@ Gemini uses the Gemini CLI OAuth credentials and private quota APIs. No browser - Tier from `loadCodeAssist`: - `standard-tier` → "Paid" - `free-tier` + `hd` claim → "Workspace" + - `free-tier` + `paidTier.name` → consumer subscription label (e.g. "Plus", "Ultra") - `free-tier` → "Free" - `legacy-tier` → "Legacy" - Email from `id_token` JWT claims.

DeepSeekAPI key

  • T3 ChatCookies

  • CodebuffAPI key

  • -
  • PoeAPI key

  • +
  • PoeAPI key

  • ChutesAPI key

  • ZedLocal

  • Your providerAuthoring guide

  • diff --git a/docs/logos/poe.svg b/docs/logos/poe.svg index 757be15421..51f30a339e 100644 --- a/docs/logos/poe.svg +++ b/docs/logos/poe.svg @@ -1 +1 @@ -Poe +Poe \ No newline at end of file From 24c7c11c0e41066ad908955d5e6c3cebac77331b Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Fri, 3 Jul 2026 22:05:15 +0100 Subject: [PATCH 003/105] Reject nonfinite OpenCode usage values --- .../OpenCode/OpenCodeUsageFetcher.swift | 4 +++- .../OpenCodeGo/OpenCodeGoUsageFetcher.swift | 4 +++- .../OpenCodeGoUsageParserTests.swift | 17 +++++++++++++++++ .../OpenCodeUsageParserTests.swift | 17 +++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift index 71aa427195..9efb4537f3 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift @@ -423,7 +423,7 @@ public struct OpenCodeUsageFetcher: Sendable { } private static func doubleValue(from value: Any?) -> Double? { - switch value { + let number: Double? = switch value { case let number as Double: number case let number as NSNumber: @@ -433,6 +433,8 @@ public struct OpenCodeUsageFetcher: Sendable { default: nil } + guard let number, number.isFinite else { return nil } + return number } private static func intValue(from value: Any?) -> Int? { diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index 81f9a2312f..5740c771c3 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -956,7 +956,7 @@ extension OpenCodeGoUsageFetcher { } private static func doubleValue(from value: Any?) -> Double? { - switch value { + let number: Double? = switch value { case let number as Double: number case let number as NSNumber: @@ -966,6 +966,8 @@ extension OpenCodeGoUsageFetcher { default: nil } + guard let number, number.isFinite else { return nil } + return number } private static func intValue(from value: Any?) -> Int? { diff --git a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift index 5f4447b5e9..9ff012d9eb 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift @@ -232,6 +232,23 @@ struct OpenCodeGoUsageParserTests { #expect(snapshot.monthlyResetInSec == 86400) } + @Test + func `ignores nonfinite reset timestamps`() throws { + let text = """ + { + "rollingUsage": { "usagePercent": 17, "resetAt": "1e309" }, + "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 } + } + """ + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription( + text: text, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.rollingResetInSec == 0) + #expect(snapshot.weeklyResetInSec == 7200) + } + @Test func `computes usage percent from totals and treats monthly as optional`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) diff --git a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift index ab7359cb19..80fccf82fa 100644 --- a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift @@ -53,6 +53,23 @@ struct OpenCodeUsageParserTests { #expect(snapshot.weeklyResetInSec == 7200) } + @Test + func `ignores nonfinite reset timestamps`() throws { + let text = """ + { + "rollingUsage": { "usagePercent": 17, "resetAt": "1e309" }, + "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 } + } + """ + + let snapshot = try OpenCodeUsageFetcher.parseSubscription( + text: text, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.rollingResetInSec == 0) + #expect(snapshot.weeklyResetInSec == 7200) + } + @Test func `parses subscription from candidate windows`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) From 76d3c66129412f2bf5e84676827debfc5486a0c5 Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Fri, 3 Jul 2026 22:17:35 +0100 Subject: [PATCH 004/105] Bound OpenCode reset intervals --- .../Providers/OpenCode/OpenCodeUsageFetcher.swift | 14 ++++++++++++-- .../OpenCodeGo/OpenCodeGoUsageFetcher.swift | 14 ++++++++++++-- .../CodexBarTests/OpenCodeGoUsageParserTests.swift | 6 +++--- Tests/CodexBarTests/OpenCodeUsageParserTests.swift | 6 +++--- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift index 9efb4537f3..bc78592b15 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift @@ -748,8 +748,10 @@ public struct OpenCodeUsageFetcher: Sendable { var resetInSec = self.intValue(from: dict, keys: self.resetInKeys) if resetInSec == nil { let resetAtValue = self.value(from: dict, keys: self.resetAtKeys) - if let resetAt = self.dateValue(from: resetAtValue) { - resetInSec = max(0, Int(resetAt.timeIntervalSince(now))) + if let resetAt = self.dateValue(from: resetAtValue), + let interval = self.resetInterval(from: resetAt, now: now) + { + resetInSec = interval } } @@ -805,6 +807,14 @@ public struct OpenCodeUsageFetcher: Sendable { return nil } + private static func resetInterval(from resetAt: Date, now: Date) -> Int? { + let interval = resetAt.timeIntervalSince(now) + guard interval.isFinite else { return nil } + if interval <= 0 { return 0 } + guard interval < Double(Int.max) else { return nil } + return Int(interval) + } + private static func logParseSummary(text: String) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard let data = text.data(using: .utf8), diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index 5740c771c3..5cfde3c706 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -788,8 +788,10 @@ extension OpenCodeGoUsageFetcher { if resetInSec == nil { for key in self.resetAtKeys { - if let resetAt = self.dateValue(from: dict[key]) { - resetInSec = max(0, Int(resetAt.timeIntervalSince(now))) + if let resetAt = self.dateValue(from: dict[key]), + let interval = self.resetInterval(from: resetAt, now: now) + { + resetInSec = interval break } } @@ -1014,4 +1016,12 @@ extension OpenCodeGoUsageFetcher { } return nil } + + private static func resetInterval(from resetAt: Date, now: Date) -> Int? { + let interval = resetAt.timeIntervalSince(now) + guard interval.isFinite else { return nil } + if interval <= 0 { return 0 } + guard interval < Double(Int.max) else { return nil } + return Int(interval) + } } diff --git a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift index 9ff012d9eb..c56c2a3124 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift @@ -232,11 +232,11 @@ struct OpenCodeGoUsageParserTests { #expect(snapshot.monthlyResetInSec == 86400) } - @Test - func `ignores nonfinite reset timestamps`() throws { + @Test(arguments: ["1e309", "1e308"]) + func `ignores reset timestamps outside integer range`(resetAt: String) throws { let text = """ { - "rollingUsage": { "usagePercent": 17, "resetAt": "1e309" }, + "rollingUsage": { "usagePercent": 17, "resetAt": "\(resetAt)" }, "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 } } """ diff --git a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift index 80fccf82fa..b7297bc71b 100644 --- a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift @@ -53,11 +53,11 @@ struct OpenCodeUsageParserTests { #expect(snapshot.weeklyResetInSec == 7200) } - @Test - func `ignores nonfinite reset timestamps`() throws { + @Test(arguments: ["1e309", "1e308"]) + func `ignores reset timestamps outside integer range`(resetAt: String) throws { let text = """ { - "rollingUsage": { "usagePercent": 17, "resetAt": "1e309" }, + "rollingUsage": { "usagePercent": 17, "resetAt": "\(resetAt)" }, "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 } } """ From cfb0ac53d15f088011e50cc5e11cfb46dadbf358 Mon Sep 17 00:00:00 2001 From: JoeVenner Date: Fri, 3 Jul 2026 22:24:58 +0100 Subject: [PATCH 005/105] Reject nonfinite OpenAI cost values --- .../OpenAI/OpenAIAPIUsageResponses.swift | 4 +-- .../OpenAIAPIUsageFetcherTests.swift | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift index a540a0610f..f7ddfd288e 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift @@ -114,7 +114,7 @@ extension KeyedDecodingContainer { return nil } - if let value = try? self.decode(Double.self, forKey: key) { + if let value = try? self.decode(Double.self, forKey: key), value.isFinite { return value } @@ -123,7 +123,7 @@ extension KeyedDecodingContainer { guard !trimmed.isEmpty else { return nil } - if let value = Double(trimmed) { + if let value = Double(trimmed), value.isFinite { return value } } diff --git a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift index fbf298bdf4..c68f8d17b5 100644 --- a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift @@ -111,6 +111,38 @@ struct OpenAIAPIUsageFetcherTests { #expect(snapshot.topModels.first?.totalTokens == 1800) } + @Test(arguments: ["NaN", "Infinity", "1e309"]) + func `rejects nonfinite cost strings`(value: String) { + let costs = """ + { + "data": [{ + "start_time": 1700000000, + "end_time": 1700086400, + "results": [{ "amount": { "value": "\(value)", "currency": "usd" } }] + }], + "has_more": false, + "next_page": null + } + """ + let completions = #"{"data":[],"has_more":false,"next_page":null}"# + + do { + _ = try OpenAIAPIUsageFetcher._parseSnapshotForTesting( + costs: Data(costs.utf8), + completions: Data(completions.utf8), + now: Date(timeIntervalSince1970: 1_700_179_200)) + Issue.record("Expected a costs parse failure.") + } catch let error as OpenAIAPIUsageError { + guard case let .parseFailed(endpoint, _) = error else { + Issue.record("Expected a costs parse failure, got \(error).") + return + } + #expect(endpoint == "costs") + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error).") + } + } + @Test func `admin usage fetch pages long history within endpoint bucket limit`() async throws { let now = Date(timeIntervalSince1970: 1_700_179_200) From a2ca93511b778fccc9b9a270fd353559b8e2f74b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 03:21:11 +0100 Subject: [PATCH 006/105] fix: finalize nonfinite OpenAI cost guard --- CHANGELOG.md | 2 ++ Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c66b3cfee..65d3335d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ - 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! + ## 0.38.0 — 2026-07-03 ### Added diff --git a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift index c68f8d17b5..eb3527efb6 100644 --- a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift @@ -111,7 +111,7 @@ struct OpenAIAPIUsageFetcherTests { #expect(snapshot.topModels.first?.totalTokens == 1800) } - @Test(arguments: ["NaN", "Infinity", "1e309"]) + @Test(arguments: ["NaN", "Infinity", "-Infinity", "1e309", "-1e309"]) func `rejects nonfinite cost strings`(value: String) { let costs = """ { From 94c340a9ab08dfb8ac2365c33e4c81b71b8778e5 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 3 Jul 2026 19:25:22 -0700 Subject: [PATCH 007/105] fix: sync app-side Poe brand color to Poe purple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR feedback: the descriptor color drives progress bars, the dashboard, charts, and switcher tabs, and the widget has its own Poe color case — both still used the old green (#26AD61 normalized). - PoeProviderDescriptor.branding.color -> 93/92/222 (#5D5CDE) - CodexBarWidgetViews poe case -> same, matching sibling style Co-Authored-By: Claude Opus 4.8 --- Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift | 2 +- Sources/CodexBarWidget/CodexBarWidgetViews.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift index 176e2ee18c..d3b4feda14 100644 --- a/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift @@ -27,7 +27,7 @@ public enum PoeProviderDescriptor { branding: ProviderBranding( iconStyle: .poe, iconResourceName: "ProviderIcon-poe", - color: ProviderColor(red: 0.15, green: 0.68, blue: 0.38)), + color: ProviderColor(red: 93 / 255, green: 92 / 255, blue: 222 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Poe usage history is unavailable." }), diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index dcae1d7bbb..c45a1582cf 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -906,7 +906,7 @@ enum WidgetColors { case .deepgram: Color(red: 10 / 255, green: 18 / 255, blue: 27 / 255) case .poe: - Color(red: 0.15, green: 0.68, blue: 0.38) + Color(red: 93 / 255, green: 92 / 255, blue: 222 / 255) // Poe purple case .chutes: Color(red: 24 / 255, green: 160 / 255, blue: 88 / 255) case .zed: From 1f204c8add4f81460ebef5053cd9fa887dd4a539 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 03:52:20 +0100 Subject: [PATCH 008/105] chore: harden repository size guard --- Scripts/check_repository_size.sh | 9 ++++---- Scripts/test_repository_size.sh | 39 ++++++++++++++++++++++++++------ docs/repository-size.md | 2 +- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/Scripts/check_repository_size.sh b/Scripts/check_repository_size.sh index 4a24e5a1d1..449055e5df 100755 --- a/Scripts/check_repository_size.sh +++ b/Scripts/check_repository_size.sh @@ -9,18 +9,19 @@ tracked_files=0 cd "$ROOT_DIR" while IFS= read -r -d '' path; do - [[ -f "$path" && ! -L "$path" ]] || continue tracked_files=$((tracked_files + 1)) case "$path" in - *.app | *.app/* | *.dSYM | *.dSYM/* | *.xcarchive/* | *.xcresult/* | *.zip | *.delta | *.dmg | *.pkg | \ - *.tar.gz | *.tgz) + *.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 - size=$(wc -c < "$path" | tr -d '[:space:]') + [[ -f "$path" && ! -L "$path" ]] || continue + + size=$(wc -c < "$path") if ((size > MAX_BYTES)); then printf 'ERROR: tracked file exceeds %d bytes: %s (%d bytes)\n' "$MAX_BYTES" "$path" "$size" >&2 failures=$((failures + 1)) diff --git a/Scripts/test_repository_size.sh b/Scripts/test_repository_size.sh index d30595d322..293a6a2275 100755 --- a/Scripts/test_repository_size.sh +++ b/Scripts/test_repository_size.sh @@ -15,20 +15,45 @@ git -C "$TEMP_DIR" add source.txt Scripts/check_repository_size.sh 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 -git -C "$TEMP_DIR" add untracked.bin + +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" if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/large.log" 2>&1; then printf 'ERROR: oversized tracked file was accepted.\n' >&2 exit 1 fi -grep -Fq 'tracked file exceeds 2097152 bytes: untracked.bin' "$TEMP_DIR/large.log" +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 +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 -git -C "$TEMP_DIR" rm --cached --quiet untracked.bin -printf 'release artifact\n' > "$TEMP_DIR/CodexBar-0.0.0.zip" -git -C "$TEMP_DIR" add CodexBar-0.0.0.zip if "$TEMP_DIR/Scripts/check_repository_size.sh" >"$TEMP_DIR/artifact.log" 2>&1; then - printf 'ERROR: tracked release artifact was accepted.\n' >&2 + printf 'ERROR: tracked release artifacts were accepted.\n' >&2 exit 1 fi -grep -Fq 'generated artifact is tracked: CodexBar-0.0.0.zip' "$TEMP_DIR/artifact.log" +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/docs/repository-size.md b/docs/repository-size.md index 3c2bc877d0..e969adcc1d 100644 --- a/docs/repository-size.md +++ b/docs/repository-size.md @@ -30,7 +30,7 @@ did not remove their blobs from existing history. `Scripts/check_repository_size.sh` runs under `make check`. It rejects: - tracked files larger than 2 MiB; -- app bundles, dSYMs, archives, deltas, disk images, and installer packages. +- app bundles, dSYMs, Xcode results, IPAs, archives, deltas, disk images, and installer packages. Publish release artifacts through GitHub Releases. Optimize required source images before committing them. If a source asset genuinely must exceed the limit, discuss and review the limit change explicitly rather than bypassing From 0da6a1ea37d47786ee3e769eccf909fa195de983 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 04:12:46 +0100 Subject: [PATCH 009/105] feat: add Galician localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alberto Xosé Méndez Taboada --- CHANGELOG.md | 1 + Scripts/check-site-locales.mjs | 2 +- Sources/CodexBar/PreferencesGeneralPane.swift | 2 + .../Resources/ar.lproj/Localizable.strings | 1 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/fa.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/gl.lproj/Localizable.strings | 1146 +++++++++++++++++ .../Resources/id.lproj/Localizable.strings | 1 + .../Resources/it.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../Resources/nl.lproj/Localizable.strings | 1 + .../Resources/pl.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/sv.lproj/Localizable.strings | 1 + .../Resources/th.lproj/Localizable.strings | 1 + .../Resources/tr.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1 + .../Resources/vi.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../LocalizationLanguageCatalogTests.swift | 30 + docs/index.html | 1 + docs/site-locales.mjs | 145 +++ 28 files changed, 1347 insertions(+), 1 deletion(-) create mode 100644 Sources/CodexBar/Resources/gl.lproj/Localizable.strings diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c66b3cfee..41a8d4c087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.38.1 — Unreleased ### Added +- Localization: add Galician app translations and language selection. Thanks @B1NAR10! - 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! diff --git a/Scripts/check-site-locales.mjs b/Scripts/check-site-locales.mjs index 429f7283e0..60a13be14a 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/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index f4aaafdcc0..9d141a1af1 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -23,6 +23,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case polish = "pl" case persian = "fa" case thai = "th" + case galician = "gl" case catalan = "ca" case swedish = "sv" @@ -52,6 +53,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .polish: L("language_polish") case .persian: L("language_persian") case .thai: L("language_thai") + case .galician: L("language_galician") case .catalan: L("language_catalan") case .swedish: L("language_swedish") } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index c432223f13..93a6cf1ce3 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1124,6 +1124,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "أرصدة إعادة تعيين الحد"; "1 available" = "1 متاح"; "%d available" = "%d متاح"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index be79824538..e75ce7539d 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -968,6 +968,7 @@ "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"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index db947e66c3..b09487e8bd 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1114,6 +1114,7 @@ "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"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 9f775253ba..373271362f 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1125,6 +1125,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Limit Reset Credits"; "1 available" = "1 available"; "%d available" = "%d available"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 68810e6c06..b020990893 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -974,6 +974,7 @@ "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"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 0a21f04ce9..a1472d129f 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1124,6 +1124,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "اعتبارهای بازنشانی محدودیت"; "1 available" = "۱ مورد موجود"; "%d available" = "%d مورد موجود"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 72b62eddcf..492c4d9a14 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1114,6 +1114,7 @@ "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"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings new file mode 100644 index 0000000000..a17b8481a5 --- /dev/null +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -0,0 +1,1146 @@ +/* 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_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"; +"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 %@"; +"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..eec57948f7 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1123,6 +1123,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Kredit pengaturan ulang batas"; "1 available" = "1 tersedia"; "%d available" = "%d tersedia"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 6466b8dc65..3a99ad8407 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1123,6 +1123,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Crediti di reimpostazione limite"; "1 available" = "1 disponibile"; "%d available" = "%d disponibili"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 9dbd00a893..6870724dc2 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1115,6 +1115,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "上限リセットクレジット"; "1 available" = "1件利用可能"; "%d available" = "%d件利用可能"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 03dc9fea39..d235752315 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1083,6 +1083,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "한도 재설정 크레딧"; "1 available" = "1회 사용 가능"; "%d available" = "%d회 사용 가능"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index edad9f3c79..94fa2b2a8d 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1114,6 +1114,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Limietresetcredits"; "1 available" = "1 beschikbaar"; "%d available" = "%d beschikbaar"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index f9ec8acdf7..659cf82956 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1123,6 +1123,7 @@ "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"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index c528e5b38c..aca57672dc 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1115,6 +1115,7 @@ "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"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index bdabd2d439..92f6e66315 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1113,6 +1113,7 @@ "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"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 98a85a44d9..06567c9bd8 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1124,6 +1124,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "เครดิตรีเซ็ตขีดจำกัด"; "1 available" = "1 รายการ"; "%d available" = "%d รายการ"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 26b945b6d6..1216820af4 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1121,6 +1121,7 @@ "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"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index fa88822a41..40ddfbcfd1 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1114,6 +1114,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "Кредити скидання ліміту"; "1 available" = "1 доступне"; "%d available" = "%d доступно"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 8562b25832..0fc767bba6 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1115,6 +1115,7 @@ "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"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 4fbdab092d..89a864706b 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1092,6 +1092,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "限额重置额度"; "1 available" = "1 次可用"; "%d available" = "%d 次可用"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 7a619c7413..f721329c0f 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -987,6 +987,7 @@ "language_arabic" = "العربية"; "language_persian" = "فارسی"; "language_thai" = "ไทย"; +"language_galician" = "Galego"; "Limit Reset Credits" = "限額重設額度"; "1 available" = "1 次可用"; "%d available" = "%d 次可用"; diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 7d6964aa7b..897639204f 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -26,6 +26,7 @@ struct LocalizationLanguageCatalogTests { "language_arabic", "language_persian", "language_thai", + "language_galician", ] @Test @@ -71,6 +72,12 @@ struct LocalizationLanguageCatalogTests { #expect(AppLanguage.thai.rawValue == "th") } + @Test + func `app language catalog includes Galician`() { + #expect(AppLanguage.allCases.contains(.galician)) + #expect(AppLanguage.galician.rawValue == "gl") + } + @Test func `new language bundles include representative native labels`() throws { let root = URL(fileURLWithPath: #filePath) @@ -97,6 +104,13 @@ struct LocalizationLanguageCatalogTests { "quit_app": "ออกจาก CodexBar", "usage_percent_suffix_left": "คงเหลือ", ], + "gl": [ + "language_galician": "Galego", + "tab_general": "Xeral", + "quit_app": "Saír de CodexBar", + "terminal_app_title": "Terminal predeterminado", + "terminal_app_subtitle": "Terminal usado pola acción Abrir terminal", + ], ] for (locale, expectedValues) in expectations { @@ -108,6 +122,21 @@ struct LocalizationLanguageCatalogTests { } } + @Test + func `galician localization matches the English catalog`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let englishURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings") + let galicianURL = resourcesURL.appendingPathComponent("gl.lproj/Localizable.strings") + let english = try #require(NSDictionary(contentsOf: englishURL) as? [String: String]) + let galician = try #require(NSDictionary(contentsOf: galicianURL) as? [String: String]) + + #expect(Set(galician.keys) == Set(english.keys)) + } + @Test func `localized catalogs include every app language label`() throws { #expect(self.languageKeys.count == AppLanguage.allCases.count) @@ -303,6 +332,7 @@ struct LocalizationLanguageCatalogTests { "byte_unit_kilobyte", "byte_unit_megabyte", "language_arabic", + "language_galician", "language_italian", "language_persian", "language_thai", diff --git a/docs/index.html b/docs/index.html index 4b748e2634..5cb002524c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -30,6 +30,7 @@ + diff --git a/docs/site-locales.mjs b/docs/site-locales.mjs index 8822da8e21..3040b2f004 100644 --- a/docs/site-locales.mjs +++ b/docs/site-locales.mjs @@ -77,6 +77,10 @@ export const localeCatalog = [ "code": "th", "name": "ไทย" }, + { + "code": "gl", + "name": "Galego" + }, { "code": "ca", "name": "Català" @@ -2767,6 +2771,147 @@ export const localeMessages = { "widgets.dragToDesktop": "ลากวิดเจ็ตไปยังเดสก์ท็อป…", "detail.more": "เพิ่มเติม" }, + "gl": { + "meta.title": "CodexBar — todos os límites de programación con IA na túa barra de menús", + "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 56 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", + "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 56 provedores de programación con IA desde a barra de menús de macOS.", + "nav.primary": "Principal", + "nav.language": "Idioma", + "nav.docs": "Documentación", + "nav.source": "Código fonte", + "nav.download": "Descargar", + "theme.toLight": "Cambiar ao tema claro", + "theme.toDark": "Cambiar ao tema escuro", + "hero.prefix": "Todos os límites de programación con IA,", + "hero.accent": "na túa barra de menús.", + "hero.description": "CodexBar controla as xanelas de uso, os saldos de crédito e as contas atrás ata o restablecemento dos provedores polos que realmente pagas — un elemento de estado para cada un ou todos combinados nun só.", + "hero.download": "Descargar para macOS", + "hero.fineprint": "Gratuíto e de código aberto · macOS 14+ · Universal mediante GitHub Releases e Homebrew", + "providers.title": "56 provedores,{mobileBreak}unha barra de menús", + "providers.description": "Os provedores populares convértense en elementos de estado coas súas propias xanelas de uso, contas atrás de restablecemento, gráficas e menús.", + "providers.yourProvider": "O teu provedor", + "providers.authoringGuide": "Guía de creación", + "auth.oauth": "OAuth", + "auth.cli": "CLI", + "auth.gcloud": "gcloud", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Fluxo de dispositivo", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "Chave de API", + "auth.token": "Token", + "auth.legacyApi": "API antiga", + "auth.local": "Local", + "auth.apiKeyCookies": "Chave de API + cookies", + "auth.virtualKey": "Chave virtual", + "showcase.alt": "Panel emerxente de CodexBar na barra de menús con tarxetas de provedores, barras de uso, contas atrás de restablecemento e detalles de crédito.", + "features.resetsTitle": "Restablecementos que podes planificar", + "features.resetsBody": "Xanelas de sesión, semanais e mensuais por provedor con contas atrás ata o seguinte restablecemento — sen ter que adiviñar se podes comezar unha tarefa longa.", + "features.spendTitle": "Créditos, gasto e análises de custos", + "features.spendBody": "Saldos de crédito e gasto mensual cando o provedor os ofrece, ademais de análises locais de custos configurables para Codex e Claude.", + "features.statusTitle": "Estado e incidencias", + "features.statusBody": "A consulta do estado dos provedores mostra distintivos de incidencias no menú e un indicador sobre a icona da barra.", + "features.cliTitle": "CLI e widgets", + "features.cliBody": "Widgets de WidgetKit para os provedores compatibles e unha CLI {codexbar} incluída para scripts e CI — compilacións para macOS e Linux en cada versión.", + "detail.installTitle": "Instalación e actualizacións", + "detail.installBody": "Aplicación universal mediante {releases} con actualizacións automáticas de Sparkle ou mediante {cask} (actualizacións con {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Un binario {codexbar} incluído, ademais de tarballs independentes para macOS e Linux en cada versión. En Linux: {linuxCommand}.", + "detail.privacyTitle": "Privacidade", + "detail.privacyBody": "Reutiliza as sesións existentes dos provedores — OAuth, fluxo de dispositivo, chaves de API, cookies do navegador e ficheiros locais — polo que non se gardan contrasinais. Notas da auditoría en {issue}.", + "detail.permissionsTitle": "Permisos de macOS", + "detail.permissionsBody": "Acceso completo ao disco para as cookies de Safari, acceso ao Chaveiro para descifrar cookies e fluxos OAuth, e solicitudes de Ficheiros e cartafoles cando os auxiliares dos provedores len os directorios dos proxectos.", + "footer.builtBy": "Creado por", + "footer.changelog": "Rexistro de cambios", + "clipboard.copy": "Copiar ao portapapeis", + "clipboard.copied": "Copiado", + "nav.home": "Inicio de CodexBar", + "nav.projectLinks": "Ligazóns do proxecto", + "nav.sourceGitHub": "Código fonte de CodexBar en GitHub", + "nav.sourceStars": "código fonte", + "nav.displayDownload": "Visualización e descarga", + "clipboard.copyBrew": "Copiar a orde de instalación de Homebrew", + "hero.badgeStart": "Límites de uso da IA", + "hero.badgeEnd": "para macOS", + "hero.descriptionShort": "Controla as xanelas de uso, os saldos de crédito e as contas atrás de restablecemento das ferramentas de IA polas que realmente pagas.", + "hero.fineprintShort": "Gratuíto e de código aberto · macOS 14+", + "features.title": "Límites,\u00a0gasto e estado", + "mockup.openPanelAria": "CodexBar aberto na barra de menús de macOS", + "mockup.macbookAlt": "Barra de menús do MacBook", + "mockup.menuBarAria": "Elementos de estado da barra de menús de macOS", + "mockup.menuIconAria": "Icona de CodexBar na barra de menús", + "mockup.usagePanelAria": "Abrir o panel de uso de CodexBar", + "mockup.updatedJustNow": "Actualizado agora mesmo", + "mockup.plus": "Plus", + "mockup.session": "Sesión", + "mockup.weekly": "Semanal", + "mockup.left60": "60% restante", + "mockup.left73": "73% restante", + "mockup.left89": "89% restante", + "mockup.left98": "98% restante", + "mockup.reserve2": "2% en reserva", + "mockup.reserve5": "5% en reserva", + "mockup.onPace": "Ao ritmo previsto", + "mockup.resets2h53m": "Restablécese en 2 h 53 min", + "mockup.resets4h12m": "Restablécese en 4 h 12 min", + "mockup.resets5d4h": "Restablécese en 5 d 4 h", + "mockup.resets6d23h": "Restablécese en 6 d 23 h", + "mockup.runsOut4d21h": "Esgótase en 4 d 21 h", + "mockup.lastsUntilReset": "Dura ata o restablecemento", + "mockup.limitResetCredits": "Créditos para restablecer límites", + "mockup.nextExpires28d2h": "O seguinte caduca en 28 d 2 h", + "mockup.nextExpires27d21h": "O seguinte caduca en 27 d 21 h", + "mockup.manualResetAvailable": "1 restablecemento dispoñible", + "mockup.recentUsageAria": "Uso recente de Codex", + "mockup.today": "Hoxe", + "mockup.last31DaysCost": "Custo dos últimos 31 días", + "mockup.last31DaysTokens": "Tokens dos últimos 31 días", + "mockup.latestTokens": "Tokens máis recentes", + "mockup.usageChartAria": "Gráfica de uso de 31 días", + "mockup.topModel": "Modelo principal: Example-1", + "mockup.estimatedLogs": "Estimado a partir dos rexistros locais de Codex para a conta seleccionada...", + "mockup.credits": "Créditos", + "mockup.zeroLeft": "0 restante", + "mockup.buyCredits": "Comprar créditos...", + "mockup.partialDegradation": "Degradación parcial", + "mockup.twoIncidents": "2 incidencias", + "mockup.majorOutage": "Interrupción grave", + "mockup.degraded": "Degradado", + "mockup.operational": "Operativo", + "cli.outputPreviewAria": "Vista previa da saída da CLI de CodexBar", + "cli.left99": "99% restante", + "cli.left100": "100% restante", + "cli.left79": "79% restante", + "cli.left74": "74% restante", + "cli.resets5h": "Restablécese en 5 h", + "cli.resets7d": "Restablécese en 7 d", + "cli.resets23d18h": "Restablécese en 23 d 18 h", + "cli.available1": "1 dispoñible", + "cli.nextResetCredit": "Seguinte crédito de restablecemento", + "cli.expires27d22h": "caduca en 27 d 22 h", + "cli.account": "Conta", + "cli.plan": "Plan", + "cli.total": "Total", + "cli.auto": "Auto", + "cli.titleLine2": "na CLI", + "cli.lead": "Un binario {codexbar} incluído coa mesma saída de uso que a barra de menús — scripts, CI e comprobacións rápidas no terminal.", + "cli.note": "Tarballs independentes para macOS e Linux en cada versión.", + "cli.privacy": "CodexBar reutiliza as sesións existentes dos provedores — non se gardan contrasinais no disco.", + "widgets.titleLine2": "widgets", + "widgets.lead": "Widgets de WidgetKit para os provedores compatibles — xanelas de uso, contas atrás de restablecemento e gasto cando estea dispoñible. Colócaos na pantalla de inicio e no escritorio.", + "widgets.note": "Tamaños pequeno, mediano e grande. Cambia de provedor sen saír do widget.", + "widgets.galleryAria": "Vista previa da galería de widgets de macOS", + "widgets.categoriesAria": "Categorías de widgets", + "widgets.allWidgets": "Todos os widgets", + "widgets.metricCaption": "Widget compacto para créditos ou custos.", + "widgets.historyCaption": "Gráfica do historial de uso cos totais recentes.", + "widgets.usageCaption": "Uso da sesión e semanal con créditos e custos.", + "widgets.switcherCaption": "Widget de uso cun selector de provedor.", + "widgets.burndownCaption": "Orzamento restante comparado cun ritmo ideal de consumo constante.", + "widgets.burndownCombinedCaption": "Gráficas de consumo da sesión e da semana nunha soa tarxeta.", + "widgets.dragToDesktop": "Arrastra un widget ao escritorio…", + "detail.more": "Máis" + }, "ca": { "meta.title": "CodexBar: tots els límits de la programació amb IA a la barra de menús", "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 56 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", From 5292b459870410a11f117ebe7590636fa70c916b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 04:38:53 +0100 Subject: [PATCH 010/105] fix: finish Poe branding update --- CHANGELOG.md | 1 + Tests/CodexBarTests/PoeProviderDescriptorTests.swift | 12 ++++++++++++ 2 files changed, 13 insertions(+) create mode 100644 Tests/CodexBarTests/PoeProviderDescriptorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d3335d9b..82790b3827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Xiaomi MiMo: require authoritative cadence evidence before showing reserve or deficit projections, avoiding guesses from plan dates or names. ### Fixed +- 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! diff --git a/Tests/CodexBarTests/PoeProviderDescriptorTests.swift b/Tests/CodexBarTests/PoeProviderDescriptorTests.swift new file mode 100644 index 0000000000..7d7e629a6a --- /dev/null +++ b/Tests/CodexBarTests/PoeProviderDescriptorTests.swift @@ -0,0 +1,12 @@ +import CodexBarCore +import Testing + +struct PoeProviderDescriptorTests { + @Test + func `Poe uses the official brand color and icon`() { + let branding = PoeProviderDescriptor.descriptor.branding + + #expect(branding.iconResourceName == "ProviderIcon-poe") + #expect(branding.color == ProviderColor(red: 93 / 255, green: 92 / 255, blue: 222 / 255)) + } +} From 6a7197884e83cfff90c64a441f741a4a8fa2047e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 05:24:51 +0100 Subject: [PATCH 011/105] test: strengthen OpenCode overflow proof --- CHANGELOG.md | 1 + Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift | 2 ++ Tests/CodexBarTests/OpenCodeUsageParserTests.swift | 2 ++ 3 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d3335d9b..bf1ee3d7f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - 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 diff --git a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift index c56c2a3124..6b5b6269fa 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift @@ -245,6 +245,8 @@ struct OpenCodeGoUsageParserTests { text: text, now: Date(timeIntervalSince1970: 1_700_000_000)) + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.weeklyUsagePercent == 75) #expect(snapshot.rollingResetInSec == 0) #expect(snapshot.weeklyResetInSec == 7200) } diff --git a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift index b7297bc71b..291fc8734b 100644 --- a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift @@ -66,6 +66,8 @@ struct OpenCodeUsageParserTests { text: text, now: Date(timeIntervalSince1970: 1_700_000_000)) + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.weeklyUsagePercent == 75) #expect(snapshot.rollingResetInSec == 0) #expect(snapshot.weeklyResetInSec == 7200) } From efb7839ddf99333fbd770007ae80ef963525fee8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 06:40:55 +0100 Subject: [PATCH 012/105] feat: add ClawRouter usage provider --- CHANGELOG.md | 1 + README.md | 3 +- .../InlineUsageDashboardContent.swift | 19 ++ Sources/CodexBar/MenuCardView+Costs.swift | 15 +- Sources/CodexBar/MenuDescriptor.swift | 12 + .../ClawRouterProviderImplementation.swift | 48 ++++ .../ClawRouter/ClawRouterSettingsStore.swift | 23 ++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-clawrouter.svg | 7 + Sources/CodexBar/UsageStore+Accessors.swift | 2 + Sources/CodexBar/UsageStore.swift | 4 +- Sources/CodexBarCLI/CLIDiagnoseCommand.swift | 2 + .../Config/CodexBarConfigValidation.swift | 2 +- .../Config/ProviderConfigEnvironment.swift | 35 ++- .../Generated/CodexParserHash.generated.swift | 2 +- .../ClawRouterProviderDescriptor.swift | 62 +++++ .../ClawRouter/ClawRouterSettingsReader.swift | 55 ++++ .../ClawRouter/ClawRouterUsageFetcher.swift | 255 ++++++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderTokenResolver.swift | 12 + .../CodexBarCore/Providers/Providers.swift | 2 + Sources/CodexBarCore/UsageFetcher.swift | 7 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../ClawRouterUsageFetcherTests.swift | 227 ++++++++++++++++ .../ProviderIconResourcesTests.swift | 1 + docs/clawrouter.md | 51 ++++ docs/configuration.md | 17 +- docs/providers.md | 10 +- docs/screenshots/clawrouter-settings.png | Bin 0 -> 107227 bytes docs/screenshots/clawrouter-usage.png | Bin 0 -> 17470 bytes 32 files changed, 865 insertions(+), 17 deletions(-) create mode 100644 Sources/CodexBar/Providers/ClawRouter/ClawRouterProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/ClawRouter/ClawRouterSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-clawrouter.svg create mode 100644 Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/ClawRouter/ClawRouterSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/ClawRouter/ClawRouterUsageFetcher.swift create mode 100644 Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift create mode 100644 docs/clawrouter.md create mode 100644 docs/screenshots/clawrouter-settings.png create mode 100644 docs/screenshots/clawrouter-usage.png diff --git a/CHANGELOG.md b/CHANGELOG.md index cafe025e8d..a855d75810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - 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! diff --git a/README.md b/README.md index 59005e5ffc..6539600bd0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![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. @@ -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/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index cde0dfed26..136a0c8f65 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -46,6 +46,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 diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 239cddd37a..5fc962519b 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -296,13 +296,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/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 2441f11915..97f856adc6 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -282,6 +282,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) } 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/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/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 0b00f83249..63dd738395 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -58,6 +58,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.swift b/Sources/CodexBar/UsageStore.swift index 7839db907e..0d0211a806 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1127,6 +1127,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 +1208,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/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/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index da7bd46dd6..705fefd2be 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -234,7 +234,7 @@ public enum CodexBarConfigValidator { private static func providerSupportsEnterpriseHost(_ provider: UsageProvider) -> Bool { switch provider { - case .azureopenai, .copilot, .kimi, .llmproxy, .litellm: + case .azureopenai, .copilot, .kimi, .llmproxy, .litellm, .clawrouter: true default: false diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 75326b56fb..37a0f9829c 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -68,6 +68,8 @@ public enum ProviderConfigEnvironment { LLMProxySettingsReader.baseURLEnvironmentKey case .litellm: LiteLLMSettingsReader.baseURLEnvironmentKey + case .clawrouter: + ClawRouterSettingsReader.baseURLEnvironmentKey default: nil } @@ -82,15 +84,15 @@ public enum ProviderConfigEnvironment { provider: UsageProvider, config: ProviderConfig?) -> [String: String]? { - switch provider { - case .openai: - self.applyOpenAIOverrides(base: base, config: config) - case .bedrock: - self.applyBedrockOverrides(base: base, config: config) + if self.supportsAPIKeyAndBaseURLOverride(provider) { + return self.applyAPIKeyAndBaseURLOverrides(base: base, provider: provider, config: config) + } + if let env = self.applyMultiFieldCredentialOverrides(base: base, provider: provider, config: config) { + return env + } + return switch provider { case .deepgram: self.applyDeepgramOverrides(base: base, config: config) - case .llmproxy, .litellm: - self.applyAPIKeyAndBaseURLOverrides(base: base, provider: provider, config: config) case .azureopenai: self.applyAzureOpenAIOverrides(base: base, config: config) case .kimi: @@ -104,6 +106,21 @@ public enum ProviderConfigEnvironment { } } + private static func applyMultiFieldCredentialOverrides( + base: [String: String], + provider: UsageProvider, + config: ProviderConfig?) -> [String: String]? + { + switch provider { + case .openai: + self.applyOpenAIOverrides(base: base, config: config) + case .bedrock: + self.applyBedrockOverrides(base: base, config: config) + default: + nil + } + } + private static func directAPIKeyEnvironmentKey(for provider: UsageProvider) -> String? { switch provider { case .amp: @@ -142,7 +159,7 @@ public enum ProviderConfigEnvironment { GroqSettingsReader.apiKeyEnvironmentKey case .llmproxy: LLMProxySettingsReader.apiKeyEnvironmentKey - case .chutes, .poe, .litellm, .crossmodel: + case .chutes, .poe, .litellm, .crossmodel, .clawrouter: self.additionalAPIKeyEnvironmentKey(for: provider) default: nil @@ -159,6 +176,8 @@ public enum ProviderConfigEnvironment { LiteLLMSettingsReader.apiKeyEnvironmentKey case .crossmodel: CrossModelSettingsReader.envKey + case .clawrouter: + ClawRouterSettingsReader.apiKeyEnvironmentKey default: nil } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 1ce765f1c6..781603f740 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "5f6d277d7c7b3f56" + static let value = "c54070a94f6419ea" } diff --git a/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift new file mode 100644 index 0000000000..2b52905250 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterProviderDescriptor.swift @@ -0,0 +1,62 @@ +import Foundation + +public enum ClawRouterProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .clawrouter, + metadata: ProviderMetadata( + id: .clawrouter, + displayName: "ClawRouter", + sessionLabel: "Monthly budget", + weeklyLabel: "Requests", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show ClawRouter usage", + cliName: "clawrouter", + defaultEnabled: false, + dashboardURL: "https://clawrouter.openclaw.ai/dashboard/access", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .clawrouter, + iconResourceName: "ProviderIcon-clawrouter", + color: ProviderColor(red: 89 / 255, green: 110 / 255, blue: 246 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "ClawRouter spend is reported by its usage API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ClawRouterAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "clawrouter", + aliases: ["claw-router"], + versionDetector: nil)) + } +} + +struct ClawRouterAPIFetchStrategy: ProviderFetchStrategy { + let id = "clawrouter.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + ProviderTokenResolver.clawRouterToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = ProviderTokenResolver.clawRouterToken(environment: context.env) else { + throw ClawRouterUsageError.missingCredentials + } + try ClawRouterSettingsReader.validateEndpointOverride(environment: context.env) + let usage = try await ClawRouterUsageFetcher.fetchUsage( + apiKey: apiKey, + baseURL: ClawRouterSettingsReader.baseURL(environment: context.env)) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterSettingsReader.swift b/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterSettingsReader.swift new file mode 100644 index 0000000000..4504b075d0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterSettingsReader.swift @@ -0,0 +1,55 @@ +import Foundation + +public enum ClawRouterSettingsError: LocalizedError, Equatable, Sendable { + case invalidEndpointOverride(String) + + public var errorDescription: String? { + switch self { + case let .invalidEndpointOverride(key): + "ClawRouter endpoint override \(key) is invalid. Use an HTTPS URL without embedded credentials." + } + } +} + +public enum ClawRouterSettingsReader { + public static let apiKeyEnvironmentKey = "CLAWROUTER_API_KEY" + public static let baseURLEnvironmentKey = "CLAWROUTER_BASE_URL" + public static let defaultBaseURL = URL(string: "https://clawrouter.openclaw.ai")! + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.apiKeyEnvironmentKey]) + } + + public static func baseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else { + return self.defaultBaseURL + } + return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) ?? self.defaultBaseURL + } + + public static func validateEndpointOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else { return } + guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) != nil else { + throw ClawRouterSettingsError.invalidEndpointOverride(self.baseURLEnvironmentKey) + } + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterUsageFetcher.swift b/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterUsageFetcher.swift new file mode 100644 index 0000000000..609a790deb --- /dev/null +++ b/Sources/CodexBarCore/Providers/ClawRouter/ClawRouterUsageFetcher.swift @@ -0,0 +1,255 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum ClawRouterUsageError: LocalizedError, Equatable, Sendable { + case missingCredentials + case invalidCredentials + case apiError(Int) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing ClawRouter API key. Add one in Settings or set CLAWROUTER_API_KEY." + case .invalidCredentials: + "ClawRouter rejected the API key. Check the key and its policy status." + case let .apiError(statusCode): + "ClawRouter API returned HTTP \(statusCode)." + case let .parseFailed(message): + "Could not parse ClawRouter usage: \(message)" + } + } +} + +public struct ClawRouterUsageSnapshot: Codable, Sendable, Equatable { + public struct ProviderSummary: Codable, Sendable, Equatable { + public let provider: String + public let requestCount: Int + public let successCount: Int + public let errorCount: Int + public let totalTokens: Int + public let actualCostUSD: Double + } + + public let budgetConfigured: Bool + public let budgetLedger: String + public let budgetLimitUSD: Double? + public let budgetSpentUSD: Double? + public let budgetRemainingUSD: Double? + public let budgetResetsAt: Date? + public let requestCount: Int + public let successCount: Int + public let errorCount: Int + public let inputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + public let actualCostUSD: Double + public let providers: [ProviderSummary] + public let updatedAt: Date + + public func toUsageSnapshot() -> UsageSnapshot { + let usedPercent: Double? = if let spent = self.budgetSpentUSD, + let limit = self.budgetLimitUSD, + limit > 0 + { + min(100, max(0, spent / limit * 100)) + } else { + nil + } + let providerCost: ProviderCostSnapshot? = if let spent = self.budgetSpentUSD, + let limit = self.budgetLimitUSD + { + ProviderCostSnapshot( + used: spent, + limit: limit, + currencyCode: "USD", + period: "This month", + resetsAt: self.budgetResetsAt, + updatedAt: self.updatedAt) + } else if self.actualCostUSD > 0 { + ProviderCostSnapshot( + used: self.actualCostUSD, + limit: 0, + currencyCode: "USD", + period: "This month", + resetsAt: self.budgetResetsAt, + updatedAt: self.updatedAt) + } else { + nil + } + + return UsageSnapshot( + primary: usedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: nil, + resetsAt: self.budgetResetsAt, + resetDescription: nil) + }, + secondary: nil, + providerCost: providerCost, + clawRouterUsage: self, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .clawrouter, + accountEmail: nil, + accountOrganization: "\(self.providers.count) routed providers", + loginMethod: self.budgetConfigured ? "Managed monthly budget" : "Unmetered"), + dataConfidence: .exact) + } +} + +private struct ClawRouterUsageResponse: Decodable { + struct Budget: Decodable { + let configured: Bool + let ledger: String + let windowKey: String? + let limitMicros: Int64? + let spentMicros: Int64? + let remainingMicros: Int64? + } + + struct Usage: Decodable { + struct Summary: Decodable { + let requestCount: Int + let successCount: Int + let errorCount: Int + let inputTokens: Int + let outputTokens: Int + let totalTokens: Int + let actualCostMicros: Int64 + } + + struct Provider: Decodable { + let provider: String + let requestCount: Int + let successCount: Int + let errorCount: Int + let totalTokens: Int + let actualCostMicros: Int64 + } + + let summary: Summary + let providers: [Provider] + } + + let budget: Budget + let usage: Usage +} + +public enum ClawRouterUsageFetcher { + public static func fetchUsage( + apiKey: String, + baseURL: URL, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + updatedAt: Date = Date()) async throws -> ClawRouterUsageSnapshot + { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ClawRouterUsageError.missingCredentials + } + var request = URLRequest(url: self.usageURL(baseURL: baseURL)) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response = try await transport.response(for: request) + if response.statusCode == 401 || response.statusCode == 403 { + throw ClawRouterUsageError.invalidCredentials + } + guard (200..<300).contains(response.statusCode) else { + throw ClawRouterUsageError.apiError(response.statusCode) + } + return try self.parseSnapshot(data: response.data, updatedAt: updatedAt) + } + + public static func _parseSnapshotForTesting( + _ data: Data, + updatedAt: Date) throws -> ClawRouterUsageSnapshot + { + try self.parseSnapshot(data: data, updatedAt: updatedAt) + } + + public static func _usageURLForTesting(baseURL: URL) -> URL { + self.usageURL(baseURL: baseURL) + } + + private static func usageURL(baseURL: URL) -> URL { + let path = baseURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let versionedBaseURL = path.split(separator: "/").last == "v1" + ? baseURL + : baseURL.appendingPathComponent("v1") + return versionedBaseURL.appendingPathComponent("usage") + } + + private static func parseSnapshot(data: Data, updatedAt: Date) throws -> ClawRouterUsageSnapshot { + do { + let response = try JSONDecoder().decode(ClawRouterUsageResponse.self, from: data) + return ClawRouterUsageSnapshot( + budgetConfigured: response.budget.configured, + budgetLedger: response.budget.ledger, + budgetLimitUSD: self.dollars(response.budget.limitMicros), + budgetSpentUSD: self.dollars(response.budget.spentMicros), + budgetRemainingUSD: self.dollars(response.budget.remainingMicros), + budgetResetsAt: self.nextMonthlyReset(windowKey: response.budget.windowKey), + requestCount: response.usage.summary.requestCount, + successCount: response.usage.summary.successCount, + errorCount: response.usage.summary.errorCount, + inputTokens: response.usage.summary.inputTokens, + outputTokens: response.usage.summary.outputTokens, + totalTokens: response.usage.summary.totalTokens, + actualCostUSD: self.dollars(response.usage.summary.actualCostMicros), + providers: response.usage.providers.map { + ClawRouterUsageSnapshot.ProviderSummary( + provider: $0.provider, + requestCount: $0.requestCount, + successCount: $0.successCount, + errorCount: $0.errorCount, + totalTokens: $0.totalTokens, + actualCostUSD: self.dollars($0.actualCostMicros)) + }.sorted { + if $0.actualCostUSD != $1.actualCostUSD { return $0.actualCostUSD > $1.actualCostUSD } + if $0.requestCount != $1.requestCount { return $0.requestCount > $1.requestCount } + return $0.provider < $1.provider + }, + updatedAt: updatedAt) + } catch let error as ClawRouterUsageError { + throw error + } catch { + throw ClawRouterUsageError.parseFailed(error.localizedDescription) + } + } + + private static func dollars(_ micros: Int64?) -> Double? { + micros.map(self.dollars) + } + + private static func dollars(_ micros: Int64) -> Double { + Double(micros) / 1_000_000 + } + + private static func nextMonthlyReset(windowKey: String?) -> Date? { + guard let suffix = windowKey?.split(separator: "/").last else { return nil } + let components = suffix.split(separator: "-") + guard components.count == 2, + let year = Int(components[0]), + let month = Int(components[1]), + (1...12).contains(month) + else { return nil } + + var nextYear = year + var nextMonth = month + 1 + if nextMonth == 13 { + nextMonth = 1 + nextYear += 1 + } + return DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: nextYear, + month: nextMonth, + day: 1).date + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 43ddfb72f7..4ea3c7118a 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -109,6 +109,7 @@ public enum ProviderDescriptorRegistry { .poe: PoeProviderDescriptor.descriptor, .chutes: ChutesProviderDescriptor.descriptor, .crossmodel: CrossModelProviderDescriptor.descriptor, + .clawrouter: ClawRouterProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index 065ac499c6..b0f7ce6a04 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -121,6 +121,12 @@ public enum ProviderTokenResolver { self.liteLLMResolution(environment: environment)?.token } + public static func clawRouterToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.clawRouterResolution(environment: environment)?.token + } + public static func perplexitySessionToken( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { @@ -374,6 +380,12 @@ public enum ProviderTokenResolver { self.resolveEnv(LiteLLMSettingsReader.apiKey(environment: environment)) } + public static func clawRouterResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(ClawRouterSettingsReader.apiKey(environment: environment)) + } + public enum DeepgramCredentialKind: Sendable { case apiKey case projectID diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 92d00837a4..b999dff47b 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -59,6 +59,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case poe case chutes case crossmodel + case clawrouter } // swiftformat:enable sortDeclarations @@ -118,6 +119,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case poe case chutes case crossmodel + case clawrouter case combined } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 6d071f6360..b2ac31f973 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -185,6 +185,7 @@ public struct UsageSnapshot: Codable, Sendable { public let mimoUsage: MiMoUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? public let crossModelUsage: CrossModelUsageSnapshot? + public let clawRouterUsage: ClawRouterUsageSnapshot? public let openAIAPIUsage: OpenAIAPIUsageSnapshot? public let codexResetCredits: CodexRateLimitResetCreditsSnapshot? public let claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? @@ -215,6 +216,7 @@ public struct UsageSnapshot: Codable, Sendable { case mimoUsage case openRouterUsage case crossModelUsage + case clawRouterUsage case openAIAPIUsage case codexResetCredits case claudeAdminAPIUsage @@ -245,6 +247,7 @@ public struct UsageSnapshot: Codable, Sendable { mimoUsage: MiMoUsageSnapshot? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, crossModelUsage: CrossModelUsageSnapshot? = nil, + clawRouterUsage: ClawRouterUsageSnapshot? = nil, openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil, codexResetCredits: CodexRateLimitResetCreditsSnapshot? = nil, claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? = nil, @@ -274,6 +277,7 @@ public struct UsageSnapshot: Codable, Sendable { self.mimoUsage = mimoUsage self.openRouterUsage = openRouterUsage self.crossModelUsage = crossModelUsage + self.clawRouterUsage = clawRouterUsage self.openAIAPIUsage = openAIAPIUsage self.codexResetCredits = codexResetCredits self.claudeAdminAPIUsage = claudeAdminAPIUsage @@ -320,6 +324,7 @@ public struct UsageSnapshot: Codable, Sendable { self.mimoUsage = try container.decodeIfPresent(MiMoUsageSnapshot.self, forKey: .mimoUsage) self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) self.crossModelUsage = try container.decodeIfPresent(CrossModelUsageSnapshot.self, forKey: .crossModelUsage) + self.clawRouterUsage = try container.decodeIfPresent(ClawRouterUsageSnapshot.self, forKey: .clawRouterUsage) self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) self.codexResetCredits = try container.decodeIfPresent( CodexRateLimitResetCreditsSnapshot.self, @@ -373,6 +378,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encodeIfPresent(self.mimoUsage, forKey: .mimoUsage) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) try container.encodeIfPresent(self.crossModelUsage, forKey: .crossModelUsage) + try container.encodeIfPresent(self.clawRouterUsage, forKey: .clawRouterUsage) try container.encodeIfPresent(self.openAIAPIUsage, forKey: .openAIAPIUsage) try container.encodeIfPresent(self.codexResetCredits, forKey: .codexResetCredits) try container.encodeIfPresent(self.claudeAdminAPIUsage, forKey: .claudeAdminAPIUsage) @@ -552,6 +558,7 @@ public struct UsageSnapshot: Codable, Sendable { mimoUsage: self.mimoUsage, openRouterUsage: self.openRouterUsage, crossModelUsage: self.crossModelUsage, + clawRouterUsage: self.clawRouterUsage, openAIAPIUsage: self.openAIAPIUsage, codexResetCredits: codexResetCredits.resolving(self.codexResetCredits), claudeAdminAPIUsage: self.claudeAdminAPIUsage, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index a4f6c40931..defb8a6d1d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -464,7 +464,7 @@ enum CostUsageScanner { .copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .deepseek, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .qoder, .stepfun, - .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .crossmodel: + .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .crossmodel, .clawrouter: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 98b828b550..cf18920cc8 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -85,6 +85,7 @@ enum ProviderChoice: String, AppEnum { case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets case .crossmodel: return nil // CrossModel not yet supported in widgets + case .clawrouter: return nil // ClawRouter not yet supported in widgets case .elevenlabs: return nil // ElevenLabs not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets case .windsurf: return nil // Windsurf not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index c45a1582cf..79ed8dee99 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -300,6 +300,7 @@ private struct ProviderSwitchChip: View { case .synthetic: "Synthetic" case .openrouter: "OpenRouter" case .crossmodel: "CrossModel" + case .clawrouter: "ClawRouter" case .elevenlabs: "ElevenLabs" case .warp: "Warp" case .windsurf: "Windsurf" @@ -861,6 +862,8 @@ enum WidgetColors { Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple case .crossmodel: Color(red: 124 / 255, green: 58 / 255, blue: 237 / 255) // CrossModel purple + case .clawrouter: + Color(red: 89 / 255, green: 110 / 255, blue: 246 / 255) case .elevenlabs: Color(red: 235 / 255, green: 235 / 255, blue: 230 / 255) case .warp: diff --git a/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift b/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift new file mode 100644 index 0000000000..28668cc373 --- /dev/null +++ b/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift @@ -0,0 +1,227 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ClawRouterUsageFetcherTests { + @Test + func `parses monthly budget and provider agnostic usage`() throws { + let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.budgetedResponse.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + + #expect(parsed.budgetLimitUSD == 25) + #expect(parsed.budgetSpentUSD == 0.006) + #expect(parsed.budgetRemainingUSD == 24.994) + #expect(parsed.requestCount == 6) + #expect(parsed.totalTokens == 54191) + #expect(parsed.providers.map(\.provider) == ["openai", "anthropic"]) + + let snapshot = parsed.toUsageSnapshot() + #expect(snapshot.identity?.providerID == .clawrouter) + #expect(snapshot.primary?.usedPercent == 0.024) + #expect(snapshot.secondary == nil) + #expect(snapshot.providerCost?.used == 0.006) + #expect(snapshot.providerCost?.limit == 25) + #expect(snapshot.clawRouterUsage?.providers.map(\.provider) == ["openai", "anthropic"]) + #expect(snapshot.dataConfidence == .exact) + + let reset = try #require(snapshot.primary?.resetsAt) + let expected = try #require(DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 8, + day: 1).date) + #expect(reset == expected) + } + + @Test + func `supports unmetered policies and arbitrary providers`() throws { + let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.unmeteredResponse.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + let snapshot = parsed.toUsageSnapshot() + + #expect(!parsed.budgetConfigured) + #expect(parsed.providers.map(\.provider) == ["replicate", "tavily"]) + #expect(snapshot.primary == nil) + #expect(snapshot.identity?.loginMethod == "Unmetered") + #expect(snapshot.providerCost?.used == 1.25) + #expect(snapshot.providerCost?.limit == 0) + } + + @Test + func `usage URL accepts root and versioned base URLs`() throws { + #expect( + try ClawRouterUsageFetcher._usageURLForTesting( + baseURL: #require(URL(string: "https://router.example.com"))).absoluteString == + "https://router.example.com/v1/usage") + #expect( + try ClawRouterUsageFetcher._usageURLForTesting( + baseURL: #require(URL(string: "https://router.example.com/v1"))).absoluteString == + "https://router.example.com/v1/usage") + } + + @Test + func `fetch sends bearer key and maps authorization failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://router.example.com/v1/usage") + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer smoke-key") + let response = try #require(HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil)) + return (Data(), response) + } + + await #expect(throws: ClawRouterUsageError.invalidCredentials) { + _ = try await ClawRouterUsageFetcher.fetchUsage( + apiKey: "smoke-key", + baseURL: #require(URL(string: "https://router.example.com")), + transport: transport) + } + } + + @Test + func `config projects API key and optional base URL`() { + let config = ProviderConfig( + id: .clawrouter, + apiKey: "router-token", + enterpriseHost: "https://router.example.com") + let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .clawrouter, + config: config) + + #expect(environment[ClawRouterSettingsReader.apiKeyEnvironmentKey] == "router-token") + #expect(environment[ClawRouterSettingsReader.baseURLEnvironmentKey] == "https://router.example.com") + #expect(ProviderTokenResolver.clawRouterToken(environment: environment) == "router-token") + } + + @Test + func `endpoint override is HTTPS only`() throws { + let key = ClawRouterSettingsReader.baseURLEnvironmentKey + try ClawRouterSettingsReader.validateEndpointOverride(environment: [key: "router.example.com/v1"]) + #expect(ClawRouterSettingsReader.baseURL(environment: [key: "router.example.com/v1"]).absoluteString == + "https://router.example.com/v1") + #expect(throws: ClawRouterSettingsError.invalidEndpointOverride(key)) { + try ClawRouterSettingsReader.validateEndpointOverride(environment: [key: "http://router.example.com"]) + } + } + + @Test + @MainActor + func `descriptor and settings are registered`() throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .clawrouter) + #expect(descriptor.metadata.displayName == "ClawRouter") + #expect(descriptor.cli.aliases.contains("claw-router")) + + let implementation = try #require(ProviderImplementationRegistry.implementation(for: .clawrouter)) + #expect(implementation.id == .clawrouter) + } + + @Test + func `usage snapshot preserves ClawRouter detail when cached`() throws { + let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.budgetedResponse.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + let encoded = try JSONEncoder().encode(parsed.toUsageSnapshot()) + let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: encoded) + + #expect(decoded.clawRouterUsage == parsed) + #expect(decoded.identity?.providerID == .clawrouter) + } + + private static let budgetedResponse = """ + { + "policyId": "openclaw-smoke", + "budget": { + "configured": true, + "ledger": "durable_object", + "windowKey": "openclaw/openclaw-smoke/2026-07", + "limitMicros": 25000000, + "spentMicros": 6000, + "remainingMicros": 24994000 + }, + "usage": { + "ledger": "ready", + "summary": { + "requestCount": 6, + "successCount": 5, + "errorCount": 1, + "inputTokens": 50000, + "outputTokens": 4191, + "totalTokens": 54191, + "actualCostMicros": 6000 + }, + "providers": [ + { + "provider": "anthropic", + "requestCount": 2, + "successCount": 2, + "errorCount": 0, + "totalTokens": 12191, + "actualCostMicros": 2000 + }, + { + "provider": "openai", + "requestCount": 4, + "successCount": 3, + "errorCount": 1, + "totalTokens": 42000, + "actualCostMicros": 4000 + } + ], + "events": [] + } + } + """ + + private static let unmeteredResponse = """ + { + "policyId": "any-provider-policy", + "budget": { + "configured": false, + "ledger": "unmetered", + "windowKey": null, + "limitMicros": null, + "spentMicros": null, + "remainingMicros": null + }, + "usage": { + "ledger": "ready", + "summary": { + "requestCount": 3, + "successCount": 3, + "errorCount": 0, + "inputTokens": 0, + "outputTokens": 0, + "totalTokens": 0, + "actualCostMicros": 1250000 + }, + "providers": [ + { + "provider": "tavily", + "requestCount": 2, + "successCount": 2, + "errorCount": 0, + "totalTokens": 0, + "actualCostMicros": 250000 + }, + { + "provider": "replicate", + "requestCount": 1, + "successCount": 1, + "errorCount": 0, + "totalTokens": 0, + "actualCostMicros": 1000000 + } + ], + "events": [] + } + } + """ +} diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 2d00a9ca44..8b3a4c1543 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -36,6 +36,7 @@ struct ProviderIconResourcesTests { "litellm", "deepgram", "ollama", + "clawrouter", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") diff --git a/docs/clawrouter.md b/docs/clawrouter.md new file mode 100644 index 0000000000..a65a73055c --- /dev/null +++ b/docs/clawrouter.md @@ -0,0 +1,51 @@ +--- +summary: "ClawRouter setup for monthly budget, spend, and routed-provider usage." +read_when: + - Configuring ClawRouter usage tracking + - Debugging ClawRouter budget or provider breakdown display + - Explaining ClawRouter API key and base URL settings +--- + +# ClawRouter + +CodexBar reads the policy attached to a ClawRouter API key. The menu card shows its monthly budget, spend, requests, +tokens, and provider breakdown. Provider rows come directly from ClawRouter, so the integration works with any routed +model provider configured there; CodexBar does not need a separate provider plugin for each route. + +## Setup + +Create a ClawRouter key with access to the routes you want, then store it in CodexBar: + +```bash +printf '%s' "$CLAWROUTER_API_KEY" | codexbar config set-api-key --provider clawrouter --stdin +``` + +You can also paste the key in CodexBar Settings → Providers → ClawRouter. The hosted service is used by default: + +```text +https://clawrouter.openclaw.ai +``` + +For another deployment, set the Base URL in Settings or use `CLAWROUTER_BASE_URL`. The value may point to the service +root or `/v1`; CodexBar normalizes both to `/v1/usage`. Overrides must be HTTPS URLs or bare hosts normalized to HTTPS. + +## Display + +- Monthly budget meter and reset date when the policy has a budget. +- This-month spend against the configured limit. +- Request count and total token usage. +- Up to five routed-provider rows, ordered by spend and request count. +- Unmetered policy status and spend when no monthly limit is configured. + +ClawRouter usage is policy-wide. If one key can route OpenAI, Anthropic, Google, OpenRouter, or non-model services, the +same CodexBar card aggregates them and lists the provider identifiers returned by `/v1/usage`. + +## Environment variables + +| Variable | Description | +| --- | --- | +| `CLAWROUTER_API_KEY` | ClawRouter API key. | +| `CLAWROUTER_BASE_URL` | Optional HTTPS service root or `/v1` URL. | + +CodexBar sends the key only to the validated ClawRouter endpoint. `/v1/usage` returns accounting metadata; CodexBar +never receives routed prompts or model responses. diff --git a/docs/configuration.md b/docs/configuration.md index 69684801e1..a645ad6902 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -51,7 +51,7 @@ All provider fields are optional unless noted. - `auto` uses provider-specific fallback order (see `docs/providers.md`). - `api` uses the provider's API-backed mode; only some providers consume the `apiKey` field. - `apiKey`: raw API token for providers that support config-backed direct API usage. -- `enterpriseHost`: provider-specific API host/base URL override. Today this is used by Azure OpenAI, Copilot, and LLM Proxy. +- `enterpriseHost`: provider-specific API host/base URL override. Used by Azure OpenAI, Copilot, LLM Proxy, LiteLLM, and ClawRouter. - `cookieSource`: cookie selection policy. - `auto` (browser import), `manual` (use `cookieHeader`), `off` (disable cookies) - `cookieHeader`: raw cookie header value (e.g. `key=value; other=...`). @@ -107,6 +107,7 @@ printf '%s' "$OPENAI_ADMIN_KEY" | codexbar config set-api-key --provider openai printf '%s' "$GROQ_API_KEY" | codexbar config set-api-key --provider groq --stdin printf '%s' "$LLM_PROXY_API_KEY" | codexbar config set-api-key --provider llmproxy --stdin printf '%s' "$LITELLM_API_KEY" | codexbar config set-api-key --provider litellm --stdin +printf '%s' "$CLAWROUTER_API_KEY" | codexbar config set-api-key --provider clawrouter --stdin ``` OpenAI API project scoping uses `workspaceID` in config. This maps to `OPENAI_PROJECT_ID` for Admin API usage and is @@ -143,6 +144,18 @@ LiteLLM also needs a base URL. Set `enterpriseHost` in config or `LITELLM_BASE_U } ``` +ClawRouter defaults to the hosted service. To use another deployment, set `enterpriseHost` in config or +`CLAWROUTER_BASE_URL` in the process environment: + +```json +{ + "id": "clawrouter", + "enabled": true, + "apiKey": "", + "enterpriseHost": "https://router.example.com" +} +``` + See [CLI configuration](cli-configuration.md) for scripting examples and output formats. Manual cookies are secrets. Keep the CodexBar config file private, leave its permissions at `0600`, never commit it, @@ -169,7 +182,7 @@ z.ai team accounts also use `usageScope`, `organizationId`, and `workspaceID`; s ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `crossmodel`. +`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `crossmodel`, `clawrouter`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/providers.md b/docs/providers.md index 2e82946cd7..4bbc1b2c1f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 56 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 57 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -71,6 +71,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | Grok | `grok agent stdio` JSON-RPC `x.ai/billing` (`cli`) → grok.com billing gRPC-web via Chrome session cookies (`web`); local `~/.grok/sessions` signals as fallback. | | GroqCloud | API key → Prometheus metrics API for request/token/cache-hit rates (`api`). | | LLM Proxy | API key + base URL → `/v1/quota-stats` aggregate proxy usage (`api`). | +| ClawRouter | API key + optional base URL → `/v1/usage` monthly budget, spend, and routed-provider usage (`api`). | | LiteLLM | API key + base URL → `/key/info`, then `/user/info` or `/team/info` budget usage (`api`). | | Deepgram | API key → project discovery and usage breakdown API (`api`). | | Chutes | API key from config/env → subscription usage and quota API (`api`). | @@ -429,6 +430,13 @@ headers, source selection, provider ordering, and token accounts are stored in ` - Status: none yet. - Details: `docs/llm-proxy.md`. +## ClawRouter +- API key from the resolved CodexBar config (`providers[].apiKey`) or `CLAWROUTER_API_KEY`. +- Defaults to `https://clawrouter.openclaw.ai`; optional config `enterpriseHost` or `CLAWROUTER_BASE_URL` selects another HTTPS deployment. +- Reads `/v1/usage` for the key policy's monthly budget, spend, request/token totals, and per-provider breakdown. +- Provider rows are data-driven, so any routed provider returned by ClawRouter is displayed without provider-specific CodexBar code. +- Details: `docs/clawrouter.md`. + ## AWS Bedrock - AWS credentials from `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optional `AWS_SESSION_TOKEN`. - Region from `AWS_REGION` / `AWS_DEFAULT_REGION`, defaulting to `us-east-1`. diff --git a/docs/screenshots/clawrouter-settings.png b/docs/screenshots/clawrouter-settings.png new file mode 100644 index 0000000000000000000000000000000000000000..46ef0754aa0cfcbefaf3ff802563db2024a6e98b GIT binary patch literal 107227 zcmc$_Wl&sC^!JGdNnjwjYj6n?2sQ+VAOnHmFt`LuaEAm7?jBr&yEABT8{Az6w*dxU ze$W5e+I_LFwzg_t+^Vkbb5C{OzPIa~@8{f5RTViLY;tTQBqSUKdFd}mNT?}DNGSf8 zuU>j?h3B4;kg(gVrKD69q@-w79UUyJZOxI8#8|hOHCUcEUnR8$?D$;N*nj{ZWv1@N- zqdrHr;4Nv?-d}0JFW*$@T zVnH9+{SLFWu*i}YIB6Fc8B2=X9e-EXd+HqboSVurW5yd_Aljb9+T-;@28EjM@Oe4v z*=}ESaD-BJKns_PopcBl(6QXw;RkW7`wJllt3R% zbwY;G$kFqQ%*fD$zSq6flg(&ts@9?*d+b-?ygdb`{{}YxP*Je0^k@|N!QPs`;brKS>yu>Uc+He_;bgL1 zjM`YUfjAqt?{NxykiVm~k*WkIcP)J9YD4D=C|#HOPT)-qdA0WQem(h&1v}eJm5qfe zK`1QvLOLjWQ&oY5?i&sp&DXFJ$q(7OYE;E~#lUIyX=Z+6WeIiYu9amSjqux1Np7i1 z36k7lD8_^`%oWb*EbfW+h$Gc!Wi;|$h4dBEtM~|=sKK5DlW*^t-;?mTy$?tMXmefg9UP4zoIwlk{$lQpX5$9Q|Ozwn7-Qg1nx5 zeZEuveZHF+??TLxN)$u*px%JX_Umot?dTdpo!2_S9D1(hwn4V>N6HIxj%Y%tLM_X0 zn{AuFG@CjKZW|5~Y~$>R?WA7VY_o5JhHi$U*;EP32qg(8iLi-q+4R*&zx1+_vw5=D z5IGT*5^=NOs;Ux-g)s-oEPPr3HOrG}bhDAM@+5po#7@9vcVUfWVP{)nlVhz^txyG) zFBkp#!lPkae5Mhm5mmDM^;T5Q3D*bLa%@)!$H{(-Og*FYpkhdERLf4IJ?~B@vShbB zsjxx0T<5r2Sa(IkC+E)bs{Mdu20Z#T?NBuNgP`9Dv>Cu}grgq$J~EMfhCG8jFHJWM zl7=#BkVe7}w*G6?Y*X+p(JBKd=Kz6N!D#G(on6gqJ}6${fi zaNdtNB;S`k*o6rk0B0Evpzt{OXZRvq7G8f90N2`|I~0FUOFHr9%Ntj6XVM5Vaxz&8 zdtO@sO5QIYOi0ZsW+=pXnJ8MxxcMr0A$Gq$M({Ow)i$>6I=^^TNjpLZZBk7)# zMweChR&PhUUbnqopx#19OM9(>ZV548R&QPHVQOPBT)krBH6CR`V%9iVF?v$k9?(7k z>GGYISflGQZj|Cy4kiA~o^RmV`_c1bg^TD8VsLO(;mCP&GxId+{bs`)`+e%06sOo3_gG%lHpyfotmTCxtfcbDbx`959-5nX{y<;AO1CZ zr>~)RV>5U2+omju2lryN)%#U0AotUWN!~wpVz5pwB(QNC{-wEKq@e%pdn_mx1_9)~ zRLa*8sI=_nNiTj9vkmj%yQK_+ZtC5p?_omY@%nLL@xgHhagEG?qNaj`!nRFM$Khs& zFOOet3Ze>v@@EQ_&vHE?_Yv?wc)YiaFL{TU6uYU6Y;>nm3^Pn`x6Zb|2H)7fN)|1Y zI9#{)v;6rqD^HSb0^G92LetDwpSu2G9y^HWfha?#P$#`M)AHWzx$SLxTz!pQ;#kR5 z>7CY;mR7~jS;h)$z0yVjx8rr;H5ZIog1Msdj!e^3^*@)sQt;qe{n2HYW>g9v!P&jR zU93w_K_Q358`L|D6M#Xwoxthh17}u-UwvmycHO!nUFq3Dw5E8{kFL2M4&{C^;zV{RUB7%j<+ixD`)ntN7P>@ zjQtIp8Y&7O!yDhuO#PVd;|l+?zFc}axQDTr)F9S|=d|H^-+f&Xj!eQ0zG!yN30VDc zT{FC+uJxzlPuu6#MeorkUG*Y^z0gC~Igs;$+lQ6!P0-2X`IW`m?7b1?#`HZ0kChhZ z^dI+&8S5fqZVacZhmohpt^E)|=PbA9(SQ+*Ex?>ux9E@`$8!@rv@gD7P082hsP

    rdR|K4LI=4#@van{BONx0z~2w@wgU*b(jH%%*; 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%0DskE%N zusH}1S8RG3H;M16V^d;s3ipm{M*(kr2@&d!mXob;>10n&d;k7FDfs zAkdlxHv~y2@D;1$&kliE?9WhjaPqOXK6>~i8+`}#4c>cN|K4bNoV!lIBr^acnS?P>JYTfmeGN?#pN2C-8O#`cGjO@VAXUFLgltEBG=%u z!lVai=nBL#QV_M%nt_h((r@G|jx@by1DA1u>JD(&IcA7dWHyEXMj0RH{uQUtAUZ)hUtnD*5s=^N?~eb+0IF_q12U7e_S-6(Rv~LP$luOC+}ob zn!y?A5_ylDBWsInDH#5uAx#qVsFMUNYW+flVKzmkHu`i;+{+2ZO{vVqAK~(mS4dW1 zblB-sQW+|9%e@9X)8_ie0}UvXf+H28Q7UWERbj#KQ@iyv{YeF)s0@3t9U#=3nE0BE zb#1LRmHL$wDFW5Z5H3WHCO~Ho%P4VBB>2WiBLSD9p9?;Si_qA*%Vzz{Ss8uuw0Saq zge6mcn%Fl>Cf~qNtzeHgDj>c}1a)nKz^r6Wvk)S4{Fr*z&dZ5P8tgTvA*i`F5wt&1 zYp8dX1q+$(&Bt&_N@Wjh7E_zppOJUFq&;FV?V`_=227?3U#U^Ro*WfhF=O|J+Kbl* zF+U6_Obdmg7%7msE326>Z1g#UOfg6yRFXZhI)YMX%^8AJ4@tk&*ZkLH6OwS<{M05l zboKY@gDwb{p!`*WD?0<1+hRZz@eKsYoMO*$=t0}vpza8V|{|1;Tat#hV>q0k_K5mj( z29TrsRG;5WYw-#1mjF{V<=2(#c;&Tb9}J(Hsr*!v7;+lOdQL6=Z7F>D8N_xsIOl~p zY1l?vs%Zd4bPG1=N3|pdpyr*wJB36c9LA1{K3jOf=hT9pX(EVGAb&8{q;UC@CsVEs68Vm?wAM10)3vWJ2R;22!|)OLL8U`CAs83C z!$AK8v+k&%7Hwg4@Z|QSK{tLKB?P9hL0X%}$nkDSrp}d=lYO9M!|Mex@ckWC@6Duq6Fylo+0U3bB?=5#C0|IBp`=wlK?kS~Bet z=_>0bBp5v~79>p3WHFhul%wUmyv4yWRxCZV9>KN^K)+*^;VDWPo5DQu6#a24i#gjv zadn(Ng;LXmQG(h*i;_WN*3X2X2&mMJU~Fy`mxY?hn5eaH3WW1z`QBI??}VwkVfnY- z%)UERN`KX{tEe(zs&_c|ww8)>LOfg*u&A59$2cUpiZ3|?rCBK3pkW7z{K+QaVCDmvNzOf0}LF!c_B>tkOg;jTM}u#T~?iM9h(MsIymk z8V!pSC*ao9TZ|dqGIA&nK64(t`!Q}b7@bc2zqJA zoBDxHm%+*Zm98DYD;lB}v>Z^w`U(`*1pj(7kUCxxZi^wAy?zoFV7^dShOt=RD$L)! z`<>w>j4(_@s$C^Zz-~c+t62>b^K-wqhFi*>_1M*G@y}S~%O^q@mV+a`F4w@%H>D;D{71>Jo&4^_OeFS9wX9ka9o}V;h z!IfqO@P%gth~E1{GffLZJyjc8v|~w#L&cTb=r>q~mj~$NmE!*+ul*Gfik9SIFlP2f zs$N-4Ly*atq8S5sx`^Ic_v+sodvAZW_`Q^~mjF(NA1u(~-NGlSTGTUW=Pub5p9ur! z_e=>FFPjai35JDTM(l)BkRO`<=7SdoDS`&d2W8K4g zrV!QRd{tfLnd;#*1I&8p5CpB+;uXy>u3z3QcFg#Nm1VdHp|96s(vLE-*lvD+g%< zY@ffLG(5>>)3R=N`Q80{w2y_Su&Ol==WdI~>MH&K$A);C|7PWnrf^0FU~S)5QYevY zoqYz1^5^IIv-3kGIb_-qhK$`LOGW;5=J9qHd<3Jqe&A9oc;Qn7u4mBzj6!fEpx1;7 zs5Xf5XG<#W`D9QJaPvC&1xg!&q?C7;H-uJmI?R|Wy1>&!Kg~sZRDen z-4C-c*LE5BpW!QA3tW9fjj#xGw(7|x@~un%inUhCaO~f&uYV${XWRgNijK3~oSo0Kar?p8%D3C@>(Rs* zJRUK0fG>zh=;lUm&OW+srX2>Jjzr195+R4;3w5OhJgG3%=O)*ZxvDp4xA3B6%@_It z9!Ja5<=S|hGoV2WgMgZu|8l1P`S7AdU^Af+@tqeGFm3l&qhqR&mYjr9i^7P%YnAA@ zPrP`~IVb3KW|b`HGs{~KQv&d4{8EkpU*@B3Y-ROMvpR&*=P00S6o~APNhY`~(C0c< z`3?Vuexo0m@}wx+Q(mWW-fGM!e~jeMe*Ej36%GO$CMP2dOQZQ(1CGE}C2%80QL|9x zi%7fZ>QR?o-y_ccY-e&I_;_t$7AU?_mme!UFq}U#3}cs6q3as7numCw!(b{imYEy9 z8e>r|_-y6il^#OR2(~N8!+E)o=|^Q{ueeWoQ&PMlO=ewBHy2vG1x}?eE4!P`*5onb zmpm_xE;}X)4zJc?oi)Em+;1DH4XLzV-0aysa_Bnlt{+1ZPOy}Og;*bj_ty-yR*9oA znZxKwpnIy=!i(xuD}|=*PP7Ho6MQMoJD=^9ur+sHsX4W~(Y7OmQ|)V$KI|_q^LL7o z;6*Ubb3ojG8mCc+BnV!?n=! z+A*pNX%%8!Am2_gIArA7!*6iXzsSbdc~7w?FUTufbi|qBpeSo?4QKR;MiH<@BDPSE zPiP%oFfMpNPHiXdQJ%!Y*eA2YRE-}f!Rw2@x3&VQE}5{1s~zo=q9d6=vn#R8yZK6s zusI+p88uwUVoU~qBMe+{WrV6L@hQg>x3;5KcGi>7dMK6akkcy;{~-DQc%a)D$Ths} zWL)OKq{6R}FTF<>TOQ-xcXM(buV29ji?olnxRQpP8RPzgZy`Pm-_{=4_T;!x)=!Rd7n>A^p;Q&C!hIf;XgX z6rK`Bj`7X&OO;ztgQvR_kqm&m)khEwaWUUv0nuT9r-6Z|c(V4U%C{I;iF>`%D8YAJ zNV&Sus9>@|$MG#@&8^i%=Gt`jKW5Cwfi8%LWwnE&XH20SuwIravXFnKhl8?-m1^YC z@r2CBTyJM{I78Vi?oF{`wdJr=7%+2JUS8yfbPC2|g(D^oh$-Qw_>#Sm`iPQ$CZO%) zkRC)LM0Mf8g~p5m4^d*w?B+?F2qoi5KC)!K7KG(@jbBCqbE!Orlq+trhr`Va3956( zDnnZ-BmJ9-gFWA%ah6E)Oyo1{vHY^x{a3dK*o8?dTXG(kQ7^W2H^<=^B47_8x1J2J zh(-y;fHv1NhiI(M^M?bD4jCqJ(#`HXellk%$r^Da$%(NP%yiqVBE#T`8#RjgJ9C+y z*&HjEj9;>WqSv2pdpP{kra6%z#)qH$@s%xB1OnwzjAfwbxjC*@;Hznz8M*V9l8)F6N^9Mx`H=ET=vVazTja9vpD9xwOWwCB#1Z{Jk>b};- z6t0-Z+j)bko>Ed!=mEio2xE{7zu(b&pq?gIu8zI(L0!!Ffih^sbs7oLG~dL5D4KU= zehn@t0YAqIKH#dUW?Sr&Osm!;WdhCAzL&uucY2X=55p(ikj znB{5|#K8hL{_0OBg$N;x6JaLdrChsdGInz@O}zKUPaVzLR%kD&XtO)n@eKr6h(hhq z^K6+pz41rHlIo`!i5a{tI5sX-Wf;{aJ|#WNp%VpT&>#ULQ4J8RTXsvtwgt{kJ~l_1 z(XkfL1$YV*ow{1&Qv=c%*>sm<(?n8k0h!#&k#M+8l?GppF{0m8Bp%s4w@MA^DU?d2 zgX_J`yK#OlT~1T)SnAx!V9KZ zDLC|WO(a7LDFylET^L%-jorzglPT;QY|S#32#1@!`#cFNbtQA?Fdpl>*x6B+9V^%! zr@4N0LGc^fmz3(dP9Pv1=abI;A^lzbautd~Wo+6YgNGUO|9F}I0SYER{#^B&7VAW6 zK+>C_^)(fj`< zu16P>20f{UpDI@cy~78(G(%SjOzh+-1$Gb30rqk-Dbk zAD-OU0(J#lbu%UP>FB=<^08owK>sJHV(XG=8Db~_zf_ItMqUCtmwfpC@F};MAKT8n zK3w)MD|xI%%QINry=UNS4}Y)pf9p4<1*_hS*7pn}ShF3#w`YzpTTjp6cP-8}`?Hp4 zSld%*H>O6P;o!Yf$`P=<(;|KEZyMi~H4O}lY!bmVC#~wuH(p$JCjpzLmOS#+fwz|s z$kANwyOekK_LjtL2k$l29Q|&xAF2&nKry!`w)OZE?^OQxbRKK8KnS1FvESO)E6M|C z!V8di;CSd+xcE@sz@sLyFr3NLXXMsTf7o{4W72RsB2*M_vIH%E__=VJl-*#lH5_$) zUl%8S^WAo)>1WIBqBmFEzaF(0I|6^ujB{M3=VB)I_KIfb1d0d`;zcZ8_saxYV4!qOq+fhX2@6^;D4`KJ99?xraWuj9?-^$^s2jRxnjwSn zu5WiCcNn8hG=d@fupH%1meK=Cw7F<2Ts#gf&&Z7tTrf~pPuT;1y`0k$oTvYVRd3gFTY zd^>l@^T}ECNrY}*GmIsFNeNx^b2-9>-$=1*719oX!$*;VRF8^nQFxwdWPukbo-}ji z|I7T-10}f!-bF@W6xiC@+KIEhfh}sHyrI`yjG-#jiiV3)p6)AU93Rc1ry*lbTOz5Ln3zOD8^nwv4nwek%lM**w66+hE11AI? zCjAm=wlu9=rSq-1#B8MXFIK`nuECF9Sta96@dql?`8nr!`$C-s!VXzbs z7rm;9zQE_&0nl>~>AZ`?YUh6ClLyGSoj+frY!)D7a~fqe+-`+&SdM~nhR3~AP5ZXw z92U%k*vfif)KInMaCHAWytm3niVkc-*g9h`4L(KG050*;$9;l)I&q9Lf7wqjL)SBw zUQ=?3+=P(YEpNb>?BI{$mFEA7nKdHUu(p5n?Z!UzoQ;>kaZB#5hs0O7XoIdREY=;* z*mct2PGKvswdE5wl4lPO`T5KtQ#HttMCnAkzDiqL=OA1C!j1O*7N5cQ1E)@;|%Zrrc=Zkr}Ll&H2@`DeT zmvhZUBWa!^Lf%^v5D}iJVbVwqDGVAO52@*q+SDYDp~1eo&l@Mg?0l7jjM*E?0>Y7? zR^A#QU?2o8M$BXPnp3Vn6rI#3mHE2s-S_4}Q}OTyh|leuT`T&L^Ck@H;dU_F2xDj{ z95xaffA=Dmu++@tjN42Cin(^zx!c$cOUHy#+-Pwd4ZsN83m=OT2v2yOf3aRBZ!mq^ zLI$QGhx`A1NZtV6%9+x%&ZfHu9ZisLqP)(0VM+u5NL_EvHyXcQh3~-aDA(;yhuXE2Sv*{W5 zaEQd;FiI%#;K{*i?a^V?ecch~x1Z8>i}|n&T{T#8VvHu+d5TW5esQ?4=j(U5U~K5r z-W4alyTl?Io1`YxgEn+kCt|}%#Y*u8w!h>?gv58~-Im{dN}NZIJ%h34;+lbd>rB|J z^N`=7Z}aU%_PrjzZ1U3*5lGWu)Np)gtDGc2_U~hYcFd|ho|@7!sjoW4NT^|~M_P62 z^^H`I8-7hBoUG&pWNC?O1O9`0p@8WP9Do{sszY1o%_qeRY~e5~i&^;2(^dS@D>-6E zr<3)5yJSz+ysUw3N49;ht&h&J<-x1Owq~?CIH6HFuc@%Q*RXI?U%fqkHIZE%=V_>; zPfwZ~iDa32F6F)4fpqUlTPl5jL>t%8n|%4n)f!GFh3Kh{lM_`Ssl zGZ&CJoiBJ`r)8gb!4wkFLKi_*<$XeiD>0L;*?r7AafGq*WC(wESY&Zx@r%)>*$`hg z{pstIW*l42fm*h#(JSFL{5DvQn0zvB-g^mb_rX)59JbE0SsMka7(QJ~WgwsrnBwu^ z>JkS>{bNS3?cNorTrnA_-C9&d_1Q*SK<4b?>9ijtfKh5m$z;d48 z67AAEs9{oN7_P|7GFAAjjxBpDRS~b5ky)lZk zBKB()w&fNwVo0P!edG!E6-Fp0pKI!=6@y7%oxEtc2QF+U9PguVLwW{vHvOzKmHgo- zh!`u4N>$R6s=_97d!zw4OoKO}1;HCqiriZa8koauvt}*l=-@e$&u)ytKa!6oUcC69 zjmhS~J=P=}=~UE|`V~6FRAkSRI9fc#W)O8~=-AA!)xOhihbKb!(-IMwflJz!SkEmf zIMp+1U)$Nb^J|oQNue#Gxw`&B$@bY7~}?vTMvT_bCX)88lbL}SI<}X)iUe5Xl@nsrRPQ8%98I0x@Beo8}MK)g#r zRQ7QGH-!J{kv9sGV9vqA*{sM$dLYAOB&#E{qk00BY?+LazXyhyE$=gCdn> z3pyiwNWUO*Y?T~f3*Ol<7_)hj7iEZdhuIeUxif+Sx$Aq80RTgm@*kt?o>r1A?uJ6+ zE#>j`cK$0grhqi&#S`F10(7OQlQy9B`dE}QZfetm^_W)rKgH3g8v`7kt3Py2;H8nQ z7kxYQF5QsNqj~ZJAHcWQr~)69i1*vDc@9K)r4y7yT}t-Cv`|iaho1_8iFpuDS_kZ> ztSr!DD3Ie_o)`I#2}r)dKBD2_d8$Anl+vye^!7;sQ-X$+K#8oL?I-aRdYUh<|9J<> z=5V82r6GP*OAReZdP^eoR~7jfO|z6mQ@MZd)lgM`VKWj$vEsrR|Ll^F&Re{u6d$zD zc0IDMvA>LXe(06I#{aFx-+_;pBWUJ--`px|p;0eK<`k_}`F&pWL0Q(bnf(+Y5{aof zASdtp=4>hsw%nkc4fAAs_x1<+yR3ng#`t#8k72)Lb9`~*+rKVd>J;GqUB+iCgy9SU z_u+KD!N>tCvnun+3jvsjnzC{htr=Iy5 zpJ7~Wa}x9j>Y8H3&Sd-Bp4B;!@o3repUB5M*}1aCG?`_hs4(k*Z_XUSw->4f&epEf z0`E_WmKjCWN%o6!=Uwf*8uqRSRaj;PS}*t>w^LacbyyF_J~Z&Vu(VtDhZnjms;o3> zb~}XaaNug0E$iLn%`%~C6-rNvSj z)g9QaUk##uB_&95gOaFoG2uJfR2Ql)q>kq=G*ntFOmkV>Ica^Uq)Fu1$!*9URd&V%|yQwC=cfT5pD1G8D1rPq)%>geMC25cSpto(!C1u~LKIf?;+3PDj zTsFGBLj3fTbBOQ6lM1^oAGQXsg%OlEL?*&F1t1+vkgv7ksn7Q!7&F&n`2|IvtEx1B z5e5VqKmln61nKS=q#LBWJ4L!h zx+Nr}C6(^(E=dU~>FylzIo$8{C-?un^M%8lIp=fs-fQi(*RKCm_L$HXWdZlFTeI56 z5ugy90Jum`9^7Fle*E!x=-f5&Xs0~5+dL37ZVJDi>{(U6T-`i~9d+(F+~R{Y3FV)? z8;r!ic@!NsQHkd)Iz&D{TU5!hr~xZ#k7N*J+tiBAB_Q^?$hhKE2_O3fZLZ zj?uk0=DqK3F7&-pSigT?Sm9;z53i(PjND#WaNHU1#nGRubF|r;t+iP%()Ma}+@9F; ziy~|6v6{&VFK+me!cm;Q1n;GnQzn&7=k8lzaFL^J*gKm2Bs*rCggPGB`j=lSKlr(- zDp(*JETDILo9f>18#2UN*HNgu(6m>jX=5i8`0R9l;2v)8UEb{9KtBt5-*1{}WU|k>x@x}S;V`&(=RH7pch$VteYbyCtJCOKyA3_1 zI-mIXgx_G9=;^8F!qGWZ{k(PBU4yBVTfCM>>Gsd^+?k8L`aNLY$0?Hkwnk3O``=ia z9*rzqUoGQnt@FnV02QBo0N7vCoVPT5fmiJj-b|orX2|sH)o2LEr*8bvF$-K1>S(Qi zoL0*a--t_8$g%D1R`KjPoglS@)l~&1-h#b0S)p|$HUwYe79(DX*0&~0kH>k>p}67l zyCJb>$H&R^1VKeE&jUH{_mP_ZsE3kxF6=W2df|BJZ<+R%zX6OAP520_8b$bTw($M2 zI?y-{eEHgdopk}+h7p!JZsunUZl0?_j&c#+E&MdF900Bw(V@ zF;kYawa{&yFtDV}=Hevxh9e4Xm(D6D+Y`xhvZ---#E#R&Eox< z_RywcK$Kh5xVo-N@th-xzT#R@_Q>zPC&|y(X6$XaE*~&7=ahsGwVB0$jU)b>&+^FO zZ>~;{?}*df7oUjDjwPx@f<%Q_!b^dTy&h95W@fjcwx0bQD_(0|_%~z;L&??ZR+gqUHV@J_AJ>kO(NJnIC_VYr=UxJ@^d(8JB!BU7k7&P-*#q58Vo zj?oqzmbBJw@oM7b*f}s%<-r%`CZ|wirJzxorKnrT=CgTam$>mXwbw?wo}1`nD1A+f zBg;N~++4hRvFs+aq(rR&Pe}kj5wxQ}{{;2-$yJBYhe;n4$Y;$nU5^owBB^-#>3%eh z{m1AMSe9=&tw+f@EXH3qN8XwMed^%ilc=s2@!D&mrf0@-MJ4|2j|pgKk5{ATd`G5N zqmvq7ui5kMrS%|j^ql*=X?sTQ#9&{*Pe}NTyS?xgX2!`UOrSWVT+W-NMp5S)ad6s->kW)ankf?wmktcZD_CUl|>zybbsCORD6E zMDT=s6V_MiI0*1t?H)Mp;g4t)YWWTIOH!UxDbU&!)tItIa>r#HB|EX&EHj>e!tVlD zXm0gV^6viNSKc#5#C_%a@w{{mf4z$zrP% zq`#IaIa7zLmMOW?$Z#VSyxjnttKQ$U$KGN;I5L7Mnf45YK+g6QsgEs@5=q*YooD3B&8~&hg1= zO%B2>6jp4g6D8~+)O_&gV$i$4cOR(%B0O||NRm^djbeL1`f5OzH`Qc-^%;;LmTi9 zN#fUtcZ>R+pqWes$pYq#+MrJLGqBUn)QCI#ac98&T#+@$5PT}(?4FAr4Zz9;lQZ6s zr9+=!CK%jM6<2?nDE3^X;ip|~E@Y##c*A;=;<~V57<4%AU*a+m(>CAEq)mo_Kv>8` z{p~T2`DrgmBksmF)YG=ao4XOotq9wEd_iiSRKK#RHZLqvAye4un_{~OSi{?s^c4d$3-HN z0N4Dyzp|LUnoah~APi+YaFK(wS4gB2i!AmTIvkOd1~TJWCF>gt8Dt$bW=3&Gw6_xZ zkLg}4emTkhirI%`7fkvABSoe_u-muSw|^t5CL<;+Dcrx0Jmmw;;8yc~;pma(scwyZ3m%tkr7O3S7xtIB3U4@x}3mH_>qOg(E-Li;X40Og7O;u4HZr zw82Bmz~Lcq@N++k?zg$!u5FK8J8>P6@mlWsE=|4Kx~zKGN^}$qnND%~zzsR9sATW+ z$8IRcb(dW4X!5bH7n-l7ij~`%?kAFOl3Y!Na9T?EJSekYoJ~$RZxKloC9e-uhUK;U z%XCKG%DLQuLeT=;zeE`wS}awrBp(d>8-~D4{HTQ~GHIFc$gA3Bk52|WT;yGGr@AW- zotD^E^>wXpI{Q`;VFNiAVuAqW!6tpV%xzUe!Wt*%g1%wj3q_un!t*y%TI^0%Vaq{) zDEa@`&}Dkt%_2hFepmHuU#%en#1z6L140ux32it7d^9EtUPMLdzI;SGuhD&Y#d3ot zj{ioohnvm?S|$ry@o9Aj`8UsAiU#tMs7TOc;q(c))rRrDa@A}+aa7#l4Y!DREX_J->ytqpw+1L50<9f8A~zYkir;5`-~8RPv_(==8-anLkiG zmIkg(wp){f3lTMoLBGU!;^(UE-DR<7rb7bar@QTIIRd@ql!Im-VP=2O8^pB?dcjQg z@VRjg+$$frlZ_^!1=%giPTaftO$xE+KS@F*4J2dpM%2Pm(@PVYQr~9kyL|i{9-x1g zVny0Kj^uT*OpHV5dV)dJN07O~9L*PgimGI}5ZEOF!%Ep63ke- zD`vt81rUTJRG_S8eD+n}G|0%blur0L4vw4UwQBS)X99_BMD{)w_m+TyKLt1XKgBT- z+WlTfFL)Tz&v4EW)1-|mFgi``<$-JFJ7WG#OSbGC3*r%}H81l?E4lO;6Mlnl$igtG z(Dn1DT9J~F#R}Xu+(QX&zY?_4<-PhluV!?({>O5!_u-|gF~dSQ&eht=o;%YBF+E8j zpYHZDnp{21GG(ybm(IY}_{BM&rrc5MujLD)bft7P4PinY{|Xm!jAE{~n+D^S6z8QDfbWEUrNT4zs3z&Sy>=FXoErg$%5Y2WaEmquyyZCO6VH0 zU2!BB~F4q?a*Ya(XYT}oONRV&zF(qjqh)(up)!TV(+%KQ4N*;%*TD?yl@ zIS(BpF01{yi9=$0l7y$9;j|n%Ief7#0A;xQDb4M9=Y{bCknghfap64HwVhww-0=u& zx4p9IPSSzJt)2>vF%N{b3VdJ{SZ6*GZp`C8MXHdeDx5%|V46Zb#b@LFtn}Vfjh09H zHzv_lmFw%Z7o5;%98Z8=3F>3&r2{^9@q{{Z7kU>pb9EA%M9ZCWBsg0W+;=Nx?ltr! zfkyR}DvXlKB`gD$?G2o7^q5<(ebM<=oK@>uVuw+kYwH$}(zv{xj7saii42j|NZlj^4KRB%0K$af&u(csZY)^_XmC>`YIT zK3n7=Qv{C;?(NLw7ClcHe)5fbJzhgEXC8^=dGx!Vl&K!0Y?Tv;4gP)|0dJFVSxs*N z2|^=cV9l06`*s@}3M``9>-l?GPj1AwR5YY;N~!5_;Jj$eKb#NPCqRtE1*pXnLH+M+ zYUT6Ud?b8s@=6hXeE6Jx2|zQg7@rDiAS)04jQ_4hLPZRDNA6Q_$2gXF*h5w!^7k$R zhz*7G1BDk9@mN1i4IIo0D0Tp+I6{~!{iMr@N zEXzM6+jFT$t>UUpIqd1AV_77uzEh}C31Qg%EzqAYhjXW{mDVq~u@Zjv7W|+>^9kB7 zWJRWa@!$>tKgTB;EWml`74?c}u97^Fh9qhri028xH=!*;IOIXh+0o}$y6gr@tK6)J z?c8U8qctG-TM67U;U*1>ng{$vzwWHGIVN-_$IJE+k9li(u738aIZUSf)zuqAL6}TW zwfTX%3g>;e3+BK=CwSdlP2pGAhUcL_PL5q~`>2rxWk5{vbHk52W0Z15u{+dhf9kHd zdph>0Dl6?<-DmpEcsbR)mv9}c{_M`<#_?K9+|D~25zt!~=VdgGi&`U$cLU}#<7@fU zVp4s?g+2pOA`1cJ+cVY0mYR=CReRW$CQY_7vod;@yRqw2W7#t}cnXpPzlDQSi@jj| zMF6$MZ;qhJk;GW95azSKG-j?WId8wp;st$Am}&jzf-K-sqUhL0m}MDiHMF}DZCccM zW4=i_5?S6ungRcZHIU(=Ck{a$YVNM-4E+#Byr@tjcS-vxYKu7X1|+f5@0 zS-KpVN3OE6`Rv*dCL7(Ip@)pAWlg|_wDr>P^+>O$o>tHpJ{IkM7)84KRPBkQ%Seu> zMwLe`!z4og-OIJkuglk1>N5ot&Eq#knHwIV6HK%vX+7W(ua*gZG2!~P zbhCWubwps*KY(@(EK$E)9xC6pwi-2G!}HoH?VgoQ8t1}|F=kb7b#FGveM z8=7~^--j|=Upjb4a#5&Wk`gv0wtVS=Z>ZO|F+^ZF_TW3($h^H|^?28u0JVx5OdIgb zD#-n{GJic^S+YCu?fN?!bvH|omccz+(mX(y1`BhaN}aigRg=ye6rR&^u9SiQ(J?)c z8)h2ATEk)c650j+psIU}Sk1aOo4?Vhr;DCcl4R;@KVQ+hlhb?{(Cp+A557zmb33e1 znRzbKfJDgcaIm_r?kB`#9I+6od`8gwFC#-@4S%GUBcrxCDFh#`K&SZS8_utumWE1D zHwit}0f5>ritSH?69?JqG)iHlzw~Sd$MlQ#>hyeA(_`K6f zQBZO(k@D1oEHPohgQgU%ZfEnNioI06bt4iuCkwTZFxC)}DItZf@4r6oQ<%0jTFBM- zqFoRsol;@4tKpm)%tj&A1^ya2eMe8HRnfztR#C>fWUg%%4rVPtcr(yQS2a+(C&n77f&IV4;3K#l(D{k`sM(Uq zbXyGwu5J<^B-wS6JOVgSBKvsO%ribpbCT=*G$+CuAkgpmTwa>HCcV(i7*#kis^wmadrKrAH8?NQr!U8V%5sIT?W zEk(~n{phso#&!1%XS$uL>||1dwVhC5gj@!GN#@2a<#?5aFI(%Ui~Kn|Uf-1&_s_D= z1jfJIf2F9jDhW--8tvE?gDr+}*cE=p`QbuQk0&Nr+e>_oWlMPu?-O>g1~Y`}*znH# z{|a=9G?!j<{r%FPipVha4e#%P<*-WTk&sr~V5WZK*}HepMNth_QE**|GZtAStCJon zoiLZH*DL~m>Jr*oWq^07v5x7w{>arL$42D8$udh^fN0?PBK4-5YPo^v zsX$zis6Q^c1|#*7+C`U8kREau^GtLVnb|$LJg4#W&8wZ$06FhpY^9Q{0h6fckQdkS zgPAyN9n=u`q318m5JtqY=!19>#7jOE9}rd@hvTrUeBM8&LuH+63S(U2u0?GXQez-` zBgo*MbZ{)lNKwVfk%iGT9gq$(U|;dsO^tDt^IiOAX(foj9~;gLF#<(p?^y2ge4$@| zl|9&0<6?+^R^=au$t{@(t|2Ahnthx=uU0d>4imxBVNk=Ro`<$MVuUEG_ZW(E5Tw!E zlrVS%ibg!9ID^kWKw}77_Zu?6a+8wjG0>M~j6gFlNg-WeYN&KcvVS>=a*kxnE`4aE zW4PGdtyY^!+M1l!@!4~>T`x;QW3g_;!?xdLUPz-gNmhq(%5H8emP<=_;ZHWy1k$r* zXho6Y9ViP!5u7ppT7fIvs_3%_uccDNrlGhlE3Yu>g>O7v??$HBYsfrZ1A~hd`>(33 zT^IWe8nLOhV{E}-oLCe-(u^SZj#sHrUL`xB6%0s7E(QZ$Kd3Y>At((?d%W>D)z8X$%vs)e6{i>iXkh~;-(O0jV`OUf;t z#H=tO?{vA*Nk_GNVK?H~kp50+g^Zo)5&<)(Vc750yZ{1y_plsTj#NK?R<#ezhCrAq zf#3|57`f5sqPi(*{$yJeNved=qE=+uxnBYWaSJvhE57Q86AjP`IR6AzN!4%YVa#Uo z2|#I!_jMU`wDG`S9GI26j99X!ECkG6E?KY3E&EH*KC&kd*$=r3yr$n@SdG`&x>~~4 zT)VjWox%fnJH31PM+P83jqn_jgfej?%WABxH1A)WKI@q*4_6cp*!$2()MBk0r#H0sUpe`Kw8%(Tw+=Gj!E1Zw04b zzp%Wt9YsF|+AyAZv<4J5L9~9PA46}$m2SV47-p0a4e6?xLIoF(`swZO88#eQkXF@| zoaoe5pX+s7Rm9};QV}9>#|qpKX8(vK0zEm#X$XekfCCjOGuEYi8F7>_XpPe99sUv1 zq@W`P8SdArd}J~|DEzai7RTkdSf>WstX4M0GbVv&M5BzuBmHq=h;(&7_s~`$M$d&1 z(u^Ooq}W|CuZm2)6>ZBvuB71oY|Wd$m^$*P z{m{wB3^S1KIN1Z>ldk#5X5&SXd52Hdjlg8QUrJ0#vRyPz9-FjK2-CwndoI)dc|;`C zKIdDG?hNI;=>4TCe_AVR2zY3?%qzp*Vpn3WW~+Kj+6AYM;inl<%It9DaOdnEigb)$WEP ziu#Yv!0S>z)p?;W1nr*rTk}Am&3e1RG5J-_5X4Zp#XCiEeRL=z8N=V^C$FkZf7J52 zImk+|L4qL_T6IXu0Hd93lv-@j`(q`ZKS8Pf#ZIj$KM1BF}cMjQo590z`rxWL3Mm-eRu=2u#`X854 ztxAfJ0!pR^ePERdf5L)%MmEbgI+iz=*!X>(qu+8bgZpkgyb>dFq{CZ;cPq_<78Qmb zD<;7vE2)zNDh^)_Mh|QJ>{)9Q(vvjEkOv)`~Ro5*>XUW59o-0+}8I*A{^7gmcU3f__2hr*N1y3q5JSSlO9 zo-kXsiFQ;-`aq!9LNL9`D7~_E%KcNEzbh;uT?fKK*C(m^7Z~@2e!z)Gwr7o}&fR<} zon(gRlh`kcO1$^0(stk3xHA%Y_(>>sEg+d*<9=0X&EvG^Fe9VJj)0znAYu{uR^%0d zu!-TZ346|=7{woD1R&#)w?+P4+R=-%^}+lFtD{e1?BS0To_e4i=o*=G{@u7CDT&U= zDGGU@0&Pg<`QB)@6d@pABgYB~D(NveI_%t)Anm1QY2o=T7AIna$*#HjlKYQ>^Zrz& zM}yKU!EuW*PV?KsdZUwevk6R1#1y3E`Xy1oG<-b(3kgtj=}J}r$ms}71Mn?vyo~$N z<-EXW#WQ(dMiD#Y1=HEiR1FRSNYEPa*gGvhn9>&ttm{s55zMqA5|yDjO%b>#J@jNQ z%;1C#|?FeBMi^N-v z;J@7PHAeZYO2cVj8;`c97-r28#c6x%;f?ZQ`E4Bgi#2qoeSS!y&tpUv(psx&=IWWV za<9>gLmj0mv-#Odvyr7B`>nBnWORK`>hwMZGWf-A%Er_*w!T!^f9v3Xs^Ppx@Rjt8 z?&n*pZqwaK!F`PHcfM3w&Qar^a4<^+*&DABF-$Ux~(&5>)wZbqB&`7w=SQ%)X0?t2qg2Gx`>hF;gCZU4| zvvkQkPl~ zNn?mSE*zdH`ID!H*_r>QihoW99>hljDv;4O8Ntu+&scE5I^#^4V)c0u*jILX(mOV9n;ZuFsknU}_d>{z-uTqBhruvNAXWhA zNFbi68|X;dW++&~V=js}_XAQOPSH;yvt8Ev^NoixBE{XEuaG^|{8cM9LN^dF^5Ec7 zuL}-BbKBi9=94b?3;zbcdog0-y<(MFTfB%$2p;KDv`;ua15-eaXu2YrD7?Xdy|JJA zPnNoumiizHrrSNH$_=4<5Ox0dkUlX3Nh}tQ#`VAa8ytq&YoMCO?@LkPdYD!2PZ5bR zAX}t*ta5bfBVYnb?TB*uuG{r%$6NhIYfJh+f97@A2Lu!SWQSN#EE8PsICB$M-w|& z+?!68v6l}||Ns8Y1sYFmA$Wg%XmhWb%gc;-4=95iDO2Bl#OKr#h@iEId2kW`-nLg@ zK;Jr@Erl~#YAD7)dv&t6dr#c(=#jcNx}7=fMsQIm+XPJu7A6=zl~L?gCh@6@h*jpa ze8SJ#_ovkdJPTH7cp#!DCHBlTdDhPWP6;@PpjtqQSWn2~e0geMBa$f^4hT#Q=YF^P z+NVxr+nYKALA(f%0Tz|h>E4g?UHBPhB8LH4+8MA%t!KCXRbxgns2B**Z9aic>qz$g|f?`U4b1^xE-m*t=Mg5r%pG=y2Ek#XDe;y zzO0~^FIF|G7V8vw);rk%FP~1w-fZP|iQ)S~5~u3@sYU+l10mG`w)ffDe|XmaJCVa+ z5bL*aLCEkA&Krkn_j8+S!0@1&2E>aMjr)~3NVa1YnM};BbgE5wGj=`oxcm80u5Pmz z;7jW|EfiV$ZU&gUHkv&A!hd=)HhsDQ@A(FLxskb(lPwj`&5wKaQQ0y49l=8EOsI)J zr)cTJzQ#XCJ{2?Xg-`JL`+IHQ{58yrgqSx-N8ZE_hk48!oLp6y;)1{>qL)$P}60KW%r zzm<{-lVYUsq1o)MLMXkW1f><0YS_QmC=Yn>%bN4VcXHu^5MF+Uh7EP)smkeZT4&NZfn;^aVd48=5TC zxS`{UkRMfC>El9bR=HF#K&j$>jJ(st2p*tT@f1J)W!|=~nG9&94Ja3P0 z?SH;dt?}}_GKn-yr#*8l4@j=jb_blh7myu3%Lh2G zME(TO+e8r1{k|k+v9)GI-0m}m-mNltdK=@(gXHdJ3xQg_Kw~E22kQ@|ZKD+O2N7I8 zA3*^p;c!$~$mm8ZVZV7_goG$rdrgLb*X)xaBT>$C{q!`0u)f_dY?jx*xuqG7o_($A zQ2EK$_DIOZ6t^?ZD-P7Fhk83Uu{PncnH@iiopcq0VV`KJNC7W~$;s+=7V4)Hzmdo7GuuWs&%o!fLI=|n#DJ>j>{iv5QJ4VH#DT(U}q z!@<33{`AEJ5_o9Y&D)u@#2j*I)%PtE?J$2G-;j%?&d|o5S02EMf|qaN8-~%#(_Cre z)nD(|?>DY*L`rnJjR#YTmS+IMdjx(PkAuV_8UhyxWs2MP=4*8C6S9Skv9U`f^Z}RO z_N&`B(%uGb?U`d&E8Oh$gY!Pc9*3cO@K~{|s5iO6edSga>&1z`jrg^^(0@%4RHxPN zvO5F4yE=btgZb&IG*+*MZM|m7scFElrW!CbAx|p6epo$NqzPK2YRF7&vMDmQs4RM2n)iP%G4zSO2}5z(6+|1te6Sw zENC&BSKpalPje=oKK&z8`zLEUilt9HF{#K?5?vomBc96w%2FR@l9m>37aFFv`g8R( zGr?vgbz<|iNMo;=)hMbDHkIGR84=eY3K?q4ctTbut8zNdDOWdq=NAk97!=HR_j=Hb z6&DF)hVJ^{#-m@BUK)@~#`7|&;F9~Hckk^#XxH0evj z(R1bjE_@ly{)hEhrBr%R=7B|ZMco&W5{ggVSX}Bb=eBonBdeo@idNa}*I@3j8^25l z@5=;(u#}TrguvM$O_2!F->RBwn8hx*di=(ukp?-jr-c$oBFZvzrEZM&U z(cPpkwj|H?LG|jxP5#deXCQ%wiaKw_dq!1lx0834R^y85H)QhNcsXG{COzZK>FxBU z^ZoPeX}e(mK$H9#E#8K8Cy3j5nd5hXY$e zn%lkp8(q-g{Cg(QoVAq!=0N~{ARfQE*b~-2pC62LI_W%1YWET;%kl38=8*w4tRsR> zJe?}M`khX#Nl6zBBAGEN)m!lexp|2wjGL;)ax`E#+_d^Y&i z^(rf)W9hz!LFb-@5!X{0om0@kCxAPRw|ZBt_2e%t;S+NI`H^b5L5qwPew?}@?WI+XC`iro=5+gJPb_r4p1j^l4{d7>%l zLIixtTO!|RgQ|h0GW7@FLbo}3k_rX?LRtYdJ^ z#)kkQZ<)~8u~B}%-%ebDCt;+(FpjN>AWFh zoGjAb=1chbBJt!UK95P*Op{@t`_*oBTRB&}x@n%xYi+h3t%D1+Avv7y}-y z$A5ClOA8cgEg%vxYex5APOY8%cVi+|=m5ahfLhUk#pkV_nuvrPaVF7YEQX zCZ?}9?#Dh~l45{O{Wh@S_`JgJd3m7-&Id*zcy~?~Da?2rHhu$Z%lVK8+I!5hGceOn zLl}{R`vWc!@@D_m8ipv8Ph<7(OR1DrG%xIO^CGpPSF(5I4D7FJ7}Z=3^c zQ|1N|SsI7iv0k3@V{86uTI&T^s_HTw`Ad(k!sg)NR$rtmxB#c59_=ctM9o;_LO#zU zqqKOyJgnYnLd;N3@E<8wboA)C77^_iWst#B`JU3fwKexqZsTFv(JC}zg z=dioueiukw=d4ofWG}O`hE;mlV6KfYO7YuUdPh6vA*sV^1>aO3%SHHoMwuyCr|>5$ zh5;}f(ZF+_Y(Kj)CVTA3be?2jV{^lJu0FNHk7b;~<0#o@`gw-KW_AM{@DKP2eY9gp z_Opx+muPeZpt$%bXcs(=W%gyzNGk~1VfSS0Otr1^Q*7FUTr^_#0q-&0SUE;vW=}~j zBz&k>gQfm9$OM5>kbUJ@jD1rw2Ef+w)}0Vm-|=9mr23>< zn!I`~mYLf7{JMSB+69qSxd-74U@pvo0Y*CExTU31D}5rUAEEE{fqtxNLjb706`R?8 z#2?3PA3~bbiCd&C`km0+-U@#{6C}mFsW8V1BGul!-#{Z*p`9C&1ofHfV{UXaZxOHpbZo}bM2Jt7jfH)FCMhH_J z1MaTTXowvoJLIKR`SHizFX7Ov4K z=?=EstIP#VB0%?8krm8=y-&5+a&nr?=k9dENEc7zV@4xXyH<(-lO#j^iZHG4pYdHs z0TN&kvhe8upgyS?$rOdXH9DUn;ZEYP{Si=FMe+RSWzcSj44cAhOF!cIw}{K6N-r49 z#;=0%IsvZSY@dtJ!k|K)tRW7+MSy>MqS)CN5w#~ZWTRnaDU?vLkWmk;SgJ>?;bjDo zmqow}ilC=l=?ndvHDc*}ShoTIDjeg(bE6N)tfD%`b(jraK~T}SRd-7a#m~R(R>f27 zPBnBtIXHI0^ukBg=Sc#qC)ppz#>k!7_Z4tp)EV_gzYA&-N#_Mk+3SWe4Gw)+jyT?@ z2xkWjAdkstu=ZpZR_--bX(k(m1LS~t@FhfZPbX-q=veL)P#9-)svr^EfpZLyQUF*sMt>|+;h{I0u(k9eR-pR|? z%CVc1?(GMC7z%0JX)GWj`IgHb5s&JpG_a0PMh56K)3Af_1 z!L;#7w(rG6i%a)Vg@G|KIyXlYbUE34shoy7?>&J(?cP^;chC*J+@4qg#6<5t^7OCG z+`t&T)t9NFdb;mNQG=$c?@n?t6WD5*xc{#omHq>iej~_NtdyANh;G8V3~E5bX6_I4 zaRnzm#i0u!itb}33fppH`p)fan*&Le?D}jLj4Yh35*$;6)GO1KWERd8cELM#$+CP? zzw$NI!w`Jjwxm8_oQ(Aq@%?VG#SAO{Dr{9%=Wq;ugwlNnu$0VleVWUAu5~v4#f1!` z$j%U7XPW$}XK~CKrFF3cf6VB()8Bmg+7w=FqGIcHwbJWNdXOE@#}dV5iG65f(;gYg2q{JRwl3wT?K1sAn1P>9prz zcKo$RwbmWA)OLm#(P2gdK_p3z?m2aTzjExBG4Msthd%$Ysfpgm_^66?d z$mhCuyXZLM`~=N(xI}lsE^yy;7c2PYjgqd*Ev(3NC^h2!upB-%s*z}F?d^RsoKwk} zv8nCE=p#m8!61m&bxLGF8Y6oUfq?NxCEL;7=aONj@=MH5N+ioH;NKe&wSr0m=ps(Z zL{DyZaGKvm@?LIWtdo=k2_{&;Tl*ka`b-R6k|$vY-NpndU-Gb6I51%8gLwFZI+`d{ zVlLIrN{$cUZK~iZ{t@4y0Rxqd*1qQVu&5~W6r=I7kA@IZk_g~N@lNNGOlzJiTrDgA zVBVe6NnPh-t0IkvRjs42PsZfihigoPrbLQiQC~4qQRh!*kB2DNFyLILv*X>Ef;o-f zmEP+p(Kw1}KH33HSd;F>{q{b8*2hU`gv=dJ<%(E3;(Gfvu=*bW(L4uuGmAg^WP1gg+7_%hAtZ3dA1TRFF#YU=|q>O{y~kXw+K* zzXOu`dB9$Hxta<5(pcS~I|dyQBlHPib8A=$ix(RlsZv(ar);ZOv{t5#pD+qB;-@HRC@1FI%u9h1 z?DCyfb2O}dwqA;k*QwfnnOSv@X!*lAGiYR^^pDW*zSSd14j_qOXBk&)pIBrP6lm2q zPtY-WGArr_UlJoD^)`uIu}c%kt!xFC!KUU#G#ZK@Ftiik7jJMfNp6I_zb3WDL_U57 z|MZ*4hoXv2UtC8nkTh?w-xr9D?d^c+EGJgc07L!YD7jovubzG)c!m+)>7eO7>PH)( z@?Cs)N7#@@N{cf@v)ZaL^}3dnusP!cv{=1o7vbrX9OBhE?+f1{%G^PY{nc`8jZh3x zsb5W~6U{Nh>TBbj2^=_bIS=CZ)?)zFT)x9mM#Ol{1c$J?&HiP(0~w3J_kbh_>>Rcc zlHl=ESb3bk%o7*BBJ{Lyg?FuY*2sR)pg*3$nM^F~px@=7?fILN{xEdx74$ObeiCKO zh~*}h#1yUl;Apd5&1q`~S%^5H;p0b9*hE90);8W|UjOEuD!1~h-+a{l z7#ILvPj)|>-m5ep(^ACpiLoLfX~+)xA!t3ZGd=I_w#T)8@y4I%Vl2(0hsIOHS&jb? zF>>hL)wfgKPWRyM=mwVV*Q*fJgr;vNOd=(ih}YlbJlfKvyj2m|Aq8it?)!srxx?3+ zoPR@So(zDBD-@f ztz)9tbJKFk*p~}RpNUVj#MTcAYRh~nsE!=^3Y8rsEou`kXUjb3w*b)lje4H;ds^pt z;lm-Idlpk>vCeNepy{g%KSuQmj()K7r)>kRK(DBi(hkkb3t$gmAfRl$mqR^^l5iVn zzXHbGFPDAwPHB+6!E-LU7-4=B5Il^MNZzxA`W{Zk9t_0l?j5%vsW2a}9-hTYQr@_4 z^56{*JWU-?3Yx2431q_e{3qpC=z}UX;D7QZd12fa4QdqiQ%2jCkjL*joGdF-!DcS+ zX%v=v47g@x(+II}8w(C-vOX$*cH)dEQT&l_L4t<>7DRsI zuO{6O;_4LKusPul>SnZkwZveP+<&&gFX)Wnn4Q`kXGv%b3(gH)Kg+?lIq*fhxfhwM zlkb!2F7mfV8T0Gwo*~fv?JNqQf#iok&wTL6?WevS=@A8F>KBtBJ(GwG82m~g>1~#m zJi3}wch2bo%I`y-Z~L8{zcuw`XXq8JkXsNVf=%=)H-`&K%#Uw-PDPbar=v&Pp>ET! z;x9fK@)o@fMd`^CFy*Qv;uCwo6v=DDo7X%hXUuo!a7YDZN&gmK&>Upg5D9T@GjbIB zz_=(z=NHE~Epzrjcq5>VA)X1R3142cms6xEh5%NLs=_lb6C^)UmPbmE9U`HP-`wkO7YjR;g7b+5Q75f6?wIO`?_w@)gC^JeT z-LiXr!Cxc^94x<2Q}X-yZxo)S{K`ttw1A~kh!L(PD<=T*3yo+RV&hF%M8VI5vkddG zoV|8*D6Is4Y!E1x3{KD(##-haA!pcOVKUvXGGXYn5;p~>@vx%}a5+EdZPv1l>WPO( z;tn;+b|w`E2QxYpaT4w;l;R4&V}Go%-0d`jW(Kp(7FerUWS=YRb@jz@dp7JP%j_{j z!*BhDyW6Wk!Zb^Nl*1kjvVY74M6dL+08&9-?3D|UKmmvRA!qlq9XmyUf8A#|8M=qk zfPsL$oLu4(Fyi}m!<@Tl7FnEdwLxgHdtKm1ZC%wZBA?)ywL*CrBrU76c-eH8U-XvJ4;MQ??suE}9jq5D7Tt$&3IY(6JX zObYH)RJ5G-cI>Ms+6;hRtPZA~1m`x%u@)vZkNre})uvn$Ywuz>fDfy90Gh(P5NNd3 zR9Vl|(X9a*cB@3d(mlv>uC5Z@KgCAB?&NOAbQ7&yeg?qpU&&$pCgM*W1qAF$9Up1O zKYXLi^bi!#qfU7bb-oOfYq4+FRuBUFDCrVwI`FxQe#)6rA= zYrqBE^}S*a8^fgaSQv@L3*_^?zZZqA*exdp23?lXy!kgPEGOxc;{MZm`QINyA(Q7J zl0Mc+WqiNV>f-`RWQ(NmB*aL`uGqx32wjhE^*xBAm<8o*KMMKdq zIw>?F2k2pfHv-yBkpf@Q{=UAz1C*zR2lRaJLncT0{c>bo_qd>F$lKPxD(p;Cz3S3T zvT~jne0ABe#`yT90NMhoX|n4T9eL@sfjk# z;-)QXVo2x)K=S<5r*x`>bLt8wbbptn;+Hpl??E#${Yap!jud=)dPplgxlJLKU1O2; zJgQS6D_I3Ee8+QRuDV#$l#K3*)^t8nYck)$`#PESBd>Ya(8zx;G*>d@G2v#X`=26%JL;{SgaERcu^$d;ObjnR9JGD)+0eD3cddI*#8x~`p#2lpFn zN3#RNH@+MrSU=yw7%TH&By4XO+pf|8C zeWa_RGgS+zS)pgaMLAI2JnTt!Qqx5{V**cI$i&v+opPcaX9+o9(dZw$i*|iM(W$mc z_nP>GH_R#h^D`h@BWk`rHS+lwE4(Y{KB#TcutxL^7_0Y)T{Z!B=7sM#_Px+-p#Lt4 zefz-r>T9`D7H3;DwZNcS{Ib4w?fZDI!_C6GSeA#%+{IBLPyKsBr7G`8I*<^6ut>>2 zwkhyNbBzl!L@u+f5Mad z^Ki-}Iv0zG-32asdu8NvUW4(ZLA>iiYfGoP_fT(w$nO+Vc;j(sR||s$LZsP+(?mG-aPv3LtT>SPL>SFIqBTt&B&ClBf?#&t4rfP`e@dE z0jSOS;n{9N1wDJZkpC^Q{5W8Qn0?p< zD(g0CE&UpUh3IMlqCXIqPOvVs*PdA_YpD46Xy&{F1xE?roY@U(M*u^}ftu&>>rLlh zh*bbfvd|ezx;>%DPEbmctRa3(q;Cxe=Lfr3jzs>`#>PE2wgGy+!On>(M!^N0N*eM9 zkINHTnhToZ&4;1J!`48Ywt@G;!OX%1JD!}j?`9Bnv%UNTEv^=^wW)aJ?QbCT6h1lv z2d|JJ(uEto9zXixS?SFxb^&DO77j#!J9BW+g!TFA3)>QgS=@v-8L|n=yWF$vhlS*4 z7&BOS5!nHlSRMoZ0|d^1IT-K5518C>=oic zKR#{fq?g&dW~h*9K?)Gosq$QAV^8^~g%Ah*rFsp+__E3n^hYF%je*;B-PmVF>Ne?n z=OkXa8|;$vBwjRDP4vN$k6-tTv~TfDl5xGzn*43mH*?2*In~lkC~%LmV4m=D_{s-b z^E_pm5q50Q-vTOMmp414E81A0ng`P*6>i9@gILiPqiegPG~4QOb~S5zqmVnP5U#*G zd*l@t!M5(UcKywp+(XM@&awWSzB`|*M!*x=+eCf%NaY7$-SdImZ0Q%6y6FvN*_B5| z_{W|TyVVQI59_h$+@ei=t9yV!`4RkC6Xab36N!PJ@5136?EQ}jDu-#)t=Sw<`hO&F^7|-^V`_uaDY}Qu;i6?YJtyILb}2K0DeX3-eRC*N3UTF226b z5p;_p9lEeR&*7u2z3Wv0x=p0;>XvNei}z;dOTLHL9JL#dz+t-Ew;(l4v4(561&_`r zt|0I{7TLOnHtG`_%$0S6eZL@N{H636{~pDjFW4&7J#+y|Y?~6O&@ZJj+G%o{W8|?7 z8H6B6#1oL=8;OQl-?xHr4@JN0P2A>8`<(Uo?jdyOnOfSKDu{FZpu*$62;}5tOxhO- zB9>F_N9RM|@9;W4>&GQ3Pt}P4Oo=ssPvgA1Q7WA7wqO$@46k50CS0mhk>?=5a5eW< zTcfRz+hG9+FBuNczF%%GN-9zYP@@nM?PM5Ba>T%Rm5;Pd zpp^10LKC$oRA7dJ3Dk6r2rZ?BENR`I5$A-SIS$y)Bx~v|HEPaU!{UiOo*08o)D0SL zLC&3)&||?Ep_Dx+g6ez6m$pFM(72U`e6adFzCoKu&P${Kkg&iq8JyA`?T!|so`c`W zG*iR5fOcHZ!`wD2_e5Hd*$Bqj`JvN3m0fN z3MAk??)Q=XXtG*SR_5G-r&Bn&2bkJGFVp15{n|2R(S2{Jsu98q&GN?haIax|+?F?2 zvUIx;XkwJ?$JTTckA*XzR>mxs1mY=h(3Na#Whfu(CS(xl%Xsa3iN@ncRTUcQbJ|by z&7f+5o9w_t122>}N!vQN@%Lfhwfz)z=~CbV@3>HRO&u~U6c>#a^7JCuJ;uT^ich>d zI+S7l9XQSV%xC##q>^)z?CX4$Z5}@DMW)gVHMqraGo5h=FC^x@;+nxw_{;o3*lD?H z==ak2gl@%MP1l??k95FS_#~h;@0Rr>kC!UQ`f&D@qWqjz{v_ddqzARC`1yx{?p5&&${`d`_27dHx-Zmx9E!CG!6auiBPc2| z08;ld&q-EHTxWxeIk!!6ejkcPzc5>)$$f=se|dg;6-#Wl8u39e2PDc`!$v|VPljc# zy>ich3H-1v=w%m4g~Vvjh_@GGJw`!@g|OXfG~V{LhFVvLgpf0-F}0n#DE_MY<6?)` zLnXcY{yuHs%5dkL2f<#@Sp`mAbDrUI{PV zgO&4qYJok>Jsk@#b6|9KFgP=3<^vC`HhlFW7>w|R9pxO)jPP|Nh0^_CRD|p{yyt$v z#=|5y&yU6nM~k-50*2sYUnl!S->MAmY|e{fpzOT-G3m3|kZ;d#ww&qO z`(ep;-Q8$#w!t?W;abPHP4fgoye#W`jd4jEP_8eYtI zxa&e)zD(_+zV!PBYJla75947o`O?+dcWRp7iMhvgkJW+>STV5RHfQEu&`Xj*tc4}t zDuc1+pIvkX1kglZul6GnUx811MuGwtN|Ws4Plq+n-�ypQIEX-mPL9*r*#U4`&b zF^ItB_bhr$$`|(DQ#r9+B3^XdhI4m)!}zw8-kTzF#NIg~cT6YFm1F5EaM0A@lXp!O ze7uYpA_-09$?Ch&Lq5d6npwCWL^d<=W^5!ZXsxgevGIQ0&y=F4yCtZ6E!HQhc8wqK z@F@+h`Ga|0Gi`u-;&1o^Fb|{1SEXaeY&1O{CkV(K^`luW7 zPy0W2r0sZtTCA$prXZn131X@}sT;RaBJ;pLD9ARYYJN>R(N!ZSbVv&M^g@Lp} zA1~Tb*RX8WLlEDQ(nhRo4VxxFGuB!SuvtK1Op1JE2j%AWz&Fr1Q(cmnU5O6j$d{!P zbkA7KzkO4*fsZ0b&B#1Lu;Us0-1AEclg{y`7-^?Z6ZV1YE|UQ#vx-M?1Mb2_w}AJ> z>^o)+AEl=UMndY-E1OTMV|pZA@Gs;9t(CrT`x7VIfm8bWu_87C1d#Xl$R(fue8ywo zAsAhK*v|c2B=|8h@TQQLE>3i*kp$_Q7Gl!Q=%G9rvtK_fdAmQj*t`V&CZO`#we*)e zYmQqU-1iuqL)-Ks_}&VRoHv3yM@)8iRR}?DPX#A8;BE@tFA#IC^oEOlqT~>65hBiV zH{icYb55KGWWIi3TDYOGNBNFXTgx(t>>dkwB-et_cAvX_f|i2;@1A^%$PI+oNQPQ* zzE6+#vOvZK6pb(af-?j=?{nKm?kt=vJ6t)<(m=ph^NdZ=GbeFgC5PcKaSJJ|R}yD0 z*$tS~rJtE+?~HNX;L0?|N!K0=a7FK%3jGv}UC_(XW!*Huf8D=Fhr{8KU@09yecRZ> zlV5@{r1)YClF~pd{aU(tyMTcp;1#1o5u_?XA+ISlskM#&D1ouKlR4^MS#`J~x{syw zJUQQCo}Uejgal1Zs*60fe~V~XPA0%Xrpec@+E<-~5JR*LbRez%%X84VTg(F8Z!s54 zD|d`(Vn}@9z2^C-SUO}UJfcW5@MNQ|o$n`)kEEC_RdnvnGOB$`Eyev zhKCh53H1Pf=R4_eM@_^%Q`HzujjC?E0!8P5wiPFWEP-ip0S_%BscEg(q?lXDwtdQB zYN0&}gT9%~%&Zsa4wCQ%seBfDBAMBy#(9xj%?6r_qk;-fr9w^GubF@_=(M+sh&vZVxQf&0i`cu}`jFG_bcPzhb1yDth2X8+-g& zxNVRjIL&X@3!wFwf;z7VbV+^V8wQ@)Xkm_K1T$wnmCY4>=CmIrD}d>Ub0Cv4}})`TpR_ zRAB`boH46uiVt+I8Ht;J6w!vq)V4#y?o!N1Bb37vx`#Kn%plM&6V1({BI6kIHL)v^Y6?8xVQq2Xr*DS9XnSM7LSLT5E?T z`!Nyb9~hTkX4tbu0s#_#$uWcK9y!@_zyLXB*QW7&FyARE zgEa{n9>I`BYh>gHHqWQX_^wya9wj3UG$iKnF2W`~6V63|BnWul7xh*lj{}l>LF4+& zR%%#F4olShTi$0dgJiXc)utlxRf;B2&{xUfqTggg*9T3q$gWhOlq-f1ywDY)M;wpn zm{JSz+>*HVF%fGl^vfA8E2SL`A-+dfziy+vLq53XArkE04MnOy6iRI}I`CQ-AUeR< zV^1z7wh^b0ydVLE-9%uIsuM^Rw6pCGJ~X^+(V&bhlp{OejYXJGd^mc33}Jp6ThLXQ zh74M%YW@6mYZ2#5{DT1HAVsKk2!i#de3iwy9Ff~A8ZE9qZRud#%5gDseW=;eYc3Gs z13f%=gohG4$uXL+Yw{M6h%4%7aIV#Pl;grW^HZ~YA^klxfy7ZU5AIC+*LCCN2w@Ea zJOpC~T(Li;KhGyg|2DbMP~p9)0L2ycu?%83u<$EK1X`raj&JxB@7DC!Vfn~O`JS+$ ztSJIkGfq(9-b~Oa*L`LMT2}s?xf^TzR&3Y@ zc#EIE7I*dO+Jx}IpBvT@|_3*|ld+OuDG{3iCMI!Vs**ic1&raBV0z z!t(}#4sz@^QIYYDl;PwYwi->CM6kT8U8YuIS#Wx5ru-t+d$m_Vl;~h3F~-b`)=b`5 zItR-q2MZzw)NYBBPi;906y*`s**zcDTx_3J<739s8iRkgJ#Bt^v)y;1x6kTl8ttK0 zvRgypNA$_kN*j$i-X9=5drZ9U=b0~i@8vf>lf29~qVOW|@*%5qg9qnGqrj_%Jc5s1 zIUkIwk~=~N*>(;m>X<&4SLE_9ypn&nXECssl(Uw>qy8{i0XVb0&F;Mr0~Ep{=A#rj zVVRG<_^OdUka_qID2J=h^9 zf3{sd8sG87`^#R-%6tq)dq}ca7_9jQ5TE`-N5KgONZqInL(E_PH_l$5B#y#k+fJC` z>*AmLusJufQ~Ci#A_CU$k}4D_v>Pu$Z;5! zF9#@tF%e#c%Gtla+05xUC4_4QmxV^L3r%w?soM2%H*@v=VMP%xF2bbBLR+m!7JH|) zcio+x+L^5C0BENJ_PUL3Wp|QwYa1Aa-_?PyE|=fSOTb4)AK;(V6w*GEiy~Q)oesMu z&H|8)qbQ!M8^Fcy^b@~VCDCuD`H}XUcA$ zS=6afuCvy1dAzyO5_v7Pu>*7+ea?0O{(AFAFyN;@kuQgf1oV{VIr*Hpj81 z@n;ik8FMtI6)iEMziw;%Tr`bIYr|^fi;_qape4KzO)I5v?&q>A2x(;3s{E*fQsW0G zQ-6Jwc%-u4uU+GPaU}#O7QdSyl*3x1bpa>rIe?hy44m@6XvlpjY|ect9I@12T=9#9 zQPy2k|LI`^sxomvJWLqSFe*I*NciS}o}EY{0@~?vs9(2s!e8#v)w&YH#K{ zoLaL#mtFryJ)ng>djm9G*)plfGX(5&MuewIEL8TC<^WM=rm%ZC(mRHGj~Vq*v&nh# zzkcj5726Z#j~eLbMmh;ZKu_}~-n2|6l4!~|?l^k(E@0I1@|=QG1u<_0>Zf6srnf zNDK%ei6B~OtShV0|NnUSDDFHKh{&z8S#WXGs|V39^A_m4o6_{lZl5m4N%$|rh;*{< zB*0SXH>LnZ+jsFJ&>LD8@d02jQN7g7HzYLg10foiYi5mqjz0fkOn($aspIZ}!$=Qc z$4`xz;{aci3*T4|flS?SRw}tuB_0*V{01VcEI1QLanK7erP%AD;5H4{1z>P2+RE*j zTvEO8HA4Pm%xlruns`$ncVriXR;S(}4+xci(D*Nz*nb8#8N9!01*xi%@Vd#Le<_u$ zp4F!%td71>J{XBRTitm-D*1<>|~sKl=JqjYX{2VR50cP*L_mGoo8>cO?^Um?Jh<7i%9)wN`bj@lRv(?%Srd;-T#EotzvscNKWQdyd7I#yvJ~ zdnq4e*4GbUB{9F7f>Lv;+eZ%`*o}nZbT!>NvK{M3OM^N;M^LfYUc5ID`Ik#bfI6BJ z9p@W9al~P!c-OFH{J3n+y1>%exV&Mv^U%LYFIS9lnL=sj1L9?W2mAa@(CN7cbWO0u z)$#wF75?-38&4R`pDpSCc`eQeOe*GqpL+k^#@{dEFy8F_p|JX&zX$XKx+fkUinsq) z-VFgLYHx4YXzmfFi$7S1{!EvzIrFC~`ELH|79XPhV+{Py_5+3i=LS|0o;AFHEIh%t z`qc0&e+`B|kAal_Bfd?y>Q|co{oSHzVB#t{J<;6z^Ox=(H5wD(9iGtF5B~Q^AwG7e ztg5SM@yvyx9WV8-MCO0_^D~`~i}1bFF@WtI`iq~-_d5eLSD&4Qn}Ivcg$MvbmFv`5 z*#X0$-9Z9_9YFXKGI>*DuH=q{iLS>`39sLr{gRS7;kO#?@i{x4nLDKvo&`Ecn|CoS z>@CN`pN^PRa$N4%`89}s$2%GeOe*7ZUuxq7lQQe85~e>7JDnbocnPI2%cF6>T2!xl zL&{dc8!}d`KNG2NsOP>@XJ8n5g)4RlOtDq4_P$cl@!~hIIfv_$UBL9R3(Xv$$a^>P z+fA=NFt5lFx!&sR-Uh@%*E18)b&U$`70HYUdngdS1$tHS&&i{aLhFUgB;-9RA_1oS z?Z;pf$&lmExsqIiPyq&2k3Z!{8T=(U?Ck{VsKp z)4`a#X9p|g5<%#@Kdj4zww&JnHI)AMmh(hgZBvX*d>tX4{M7%Y*HPJb?*;s{h3oO} zdcb^nwRJQo4EMPzZ=6k2%hvlKSQSwC+YYKjE{h+P_y+m7~W z2q4YnrUohdxZD#y=iUfNFzbQT=K4*51bLU0vkTZtSad;>G5Zm>2e|LL$8Be3e-#zyh zi85Etas~z{cpW}AA`^366jIp$npCz^?)hlpYyc|8Up%SMM^xKg>-+>h_CPQc5a)E$ z=S7U@&)y=_o?0o}Hdo60QQ>`3LTc2XHFRhM!da^v%Dck@9r%Wz&gv~kWsxx^=956rPr+u}- zd_=GPvG5Dqv*S5?znPP*nTaY7`0iYz&wTY2xu{Fg^~JwsQR43LdQf?QKSgV+A_ zNO?vX;6#w-L6VUH`;=1lSYB(kyoN+QWz=llV@b^jj}_Qm;3JK!Ny%@`yj-@{p&ko9 zyGMXut**JjE6I0zm$L{xU|NYJ?6L6bF4exXsMrkz>IgBvO@*5)Pu;N-IkD?vmW|1w z7FpAqMXv>xl<$BAM->V{K>NK*aVe~JM*2m>QFcy_ZI!=va}Q{48`PcuJ7sMa?-_Bx z&v8_G+dMq9FVDpm-k_}~6~AeQ<@4%Wh({oT%0Qs84!ophFQnVp_IY3k*4$m|r7wH` zV(X^q26*xdbxS2u@Y-mw2)qS|1e3=upPgHh~siF^*wsQFzvKEH>6o0LaGUo z;Lr>NP$hrS`V5f&I8plBu7-)Bi}X02fXP*d@_A`TCy8*3=gk3j`y1Oq zCPa(T5f7r}vb-fg+Q8;8={c=s6%3Z+(XMZPY5eW|Q2U(Ih*Hb^jtGOjnC40)%y3b zVhuesm!sNvs4@)0-%47Z18KPsf@g4~5lAMaZH;lX-cjnAFqQ0STW877afaKAHI6&u zv~zO7Qo23_xz{;x^?`-K^}`zX^g@CFA)wYq63>Vwl8Y`>$@q$zpt#(*q1k}rTL7A) z@$xXS>8+seuxO%*TrB&c-YyS(-6U3Q*TDOHEv{dTioGemvt4w1?5CvE_Y9_^h{7D^ zS8}Vtu{qDc!@ZI|ewr>CDt{J!gQ4Pe<;|Yi8gIf&f`@g|v~=Iq7s<63b2o3B8N?m! zz%y}l7JM&lCt%V`U*DF@8tPgevUyQT9u61#J4}+>*SE28as9lP#`!|>eBUPc{>6LU z2hO=Tf{5u4$tJeqk+i1ig(YinhqAGu)B6?C6@bb{LUIfHOB$Zj@_s95dcpC>nlg?YMF?rgZzzF>{yoH? zU1I&(P^=kP1YS_pd@|L>hRY|mSI)TGP-)<$CWWM72QARB3QXttvS^g%1C=-bs z5bnt0(&#YFd6owOBqRZ=XlGfUr_qDq`dVuI`lKo zlpm*tnyAG_B8#T0Hu#@|ep&HT^Mucvu5Hx+?*(~yt0jsMVVdX_p%G~m;uHpE->zia z?O3KX<2qf_wLTgnn*R25y8`yAdQ%H8Q{sI>A#_cyNLI5(v5@uipF{LrSr+hBk){yE zxugs%1S6V0$Wf3s%d0%5Qfhp7NBTUf6V$H1fBBjq!)9af8zy4LAdO*)e}+FG#3v1F zdO)MulqGa9`^9-k=qHx`6!ncZ!3(W#25C5M0Bz0x$gXim(Wo1M(M@hOB1 z_{rgtEtG+nN*eLlGar(^)Oe7cW1%EA)tJLi*Q$)!oO$vk52Q_X9}hoH5bh#6qE!-` zc?z|NrSjbqC3=_;A%6b&%y6&g&^E~G-_`Ij=Bjt*%^`+(x{yU^5E5@ zBsw2s-{wDA-FN|}Xim@%tT+*NN)D^6>-vma>e{giS}Dm{hkurWe^o5bLb&mSE`N;3 z|5LQ~$^+sbCj-rsKeLPfS|&Lu1857P=WX8qzUu1AK#81u^y91MpH<%7>o{kWwdx*~ zL9gP6Kt1y6X=`uY)mw{@sdmIqjVv~u=5L*^lmD{>`_KP0xWguO&DZdtYz*Wcno zbYEOdIBwQBD5CISslF`$sXs*-E75dcQfaTE(-*1(ychoS#gEJc;go=`{@%k!s66~e z(hfObNcBeS$`p{=YBT(HiY^X;HhDP~+1JjI2evQI_!o)(70Sy2=l(prPh-{1eEDeR z&pF}}BE@$>4)Et*q4n7B_@_!_82(lh0B3I%_5K%$w>1aUDN6ChGQNjPRwX!ecv6e% zCzCxFX-S{Of+vO}`-UyvKKkSQ20VP#>c!*8{p8_*jf)ft54rl=FQ8Sf`|b32seg#c zfZJYmL;D`OPmLP>vx_}~_q&51 zr;)8S4C_M4i1^`sf`0+e7`E2>Cbr)zqNV~**z!-Kc`S-OH46up9=~FU%yf^bj ziF{)GrW*fJmm=FBLcEJUE*aM%VkGi$TJW z!vftZr_VDjek~O_5>^08qYuQ^&Q1eL27uz~G{ri`fC5qWpJT7VZ;kW)ZarGG6hkfM zD~le3Q4*>-aJN=85^;c&-mNS`4${o$GO+zk-9ItX=w{5Ls z)%09goh85@h!OkSp+wvcGmDDSyvhKb9x$qNa)k@S%XC}j0r$xoEnCn)7hs$umHtBa zM-T??kGCb7se+3Iamo)K%tuxmF0~t*EmQ6c<%i8wI_3l72i+=XhGxUc_IIm? zTo-0eStKmhUBA)Ak#AerxY@JF@pZhCt5lV5V^xv|Lx;aLO38RtOZjYB``rSb54(c!o zW^Yh2D2&>OwdYSxMS9&AYBxvbY5^_$+F@){D#-|YchY$n=jQ?MT!-lQ!anT4$|j7= zzV?V9dIVU+`jC3xVpH+<@bMF^<_&lghc$WUa+d{Iw;@M?2!(epQgKbF7})z0K7a+` z9k3vS{ekSg%1b#vfNi??NGmrGuL-f+=yL%Wl{^_k?gl=v>m4n=xke7fdUq;iaCfmq zc~6YUmFolGGi9Lb<>}7uiNL%A`L>Uw2q1oRXz|-`tMyg*WIO8o-=Qp^njqX=AgQ@& zGVAIWb!`AzPzVh>CXUJe&v$8Wx`c))UU*NQ)PXlioJ&d2ma32)nMxxmYBGv}SXiKhnR5u1T-63d=&|?)8~1&o1vG*{t64xRo`^Xx z#`wC@U2&FGtF|XHSj6k}EK@3xQROt(SsFyUpDpZuN(XZMsrM2Mk00<*ngGyW4!Q-8 zEw+iTW__QWL2+1NxJcMb@=lg^<^#Tk``>3>5~WO`CV^Yc3<};N;SQ{Jj#BC4fj-{=nwHAtO&)u@JH9gp zTd;c*ru0Q&tVG>7;|~7}!~%tE z&m=NJsyyfFCVrSRW=I)$(B|Mn^f;h1TqJQw0J{O?foorAvGIPW<+HvE%SNEzQ`furvtyLAP~XXRWV7-or(My-XNPxqV_p zUTO2Zi3iRFE-r50e=ODbGWR`0a(tTTE+1;Q;V}VVFZS}OgE^iRct zSP0VklNss!Ro|jve!`FU$N2^c_P8d>Sd5xh0Frf^%_*cd99Ce>7gj@`IG7aQml@P_ zv+;1etHdN=@^lP_VVgkLj2aJW^EoeQFUar%<61LA5sy0>HRhn!k9U5y+|K!7EbU0D zp9sKB$hU}3fY&|JV#ryWPc6Z><@_oJl|LC(4{D9VIj2MA9tVW%caqCB;x+z^Y6$S& zizXGB_L}ujo}1-d5$2ROqj`Mr%kd2}iR**iB@zg}`KY`f=X8u<7kQLBt`x=cT1Q%p z1E6)ku#VjCWSqeXG;vd^H*(nFn>gD@bsXgE@wyop(Ns*aCitwH#Fj#r z@R_IuBXKboNlZ@e+WQ}iJ0UPOa;(UDW|XtlF9oEhh)s0yHGco0AL zO2sK69mV7Bq4WkPcqo&!(o$*HZs?O6a^><4k+2R?O^b^TD;P4q9^Ue%#Utbr-agWX z&)0nB1Lw5WFLiiP?LD3s7EKI}JPP~R<{bK~VE+A_Hx#o1cY$GOg4UCkSyYmQA$PL~ zho{0)?2?^Ehla!w&)8!Q&p3FZq6*H0g4ZVb6tx|ngWec7zpM>Zw&oSdOA_nNgwL+4 zV{Nm5tEF)C=&>(FT@f*_j^9fugA2lTeIcrxj%T3$jT7LX)IL8W`)FqaUkD11CqXPy zs-yiB9UagY%Rvda+T(*B;L_7^TFpMx0t{`CRx1bQzHC&3L#NP1GMcLf%y>cD`|(~4 zn5#B2nbI>wh`+19P5hpQ*5iTp8a!!>eeV4i6bcnPGR6DG+yc)%G)bNsQ^*NE4h&)A zRSR$JT9NmdevCzr)t=DsvY*i8UdTPuMafA*RO8)BTnfQoxRM9PPbT%f+K~nV9QU0| zNH}@yEk8i`(jw~?r0vsK)>&e%Og;wVw|9RG4|syYpKQUGpsylBQ-~J$Q9;9UbVe@2 z3V(^{aKFJ+nIcpifND_fpBPAk4Q36QE8%#?f%`v;m=eL>=J<@XVEzL>11{_;G9pku zxDzB06o{V#3Gh)Vt$i8seLw*QmHM_^og7FnA z7oGb}(mGy}k#H|?YPqb8PK7TWgpsAN9&XMc#bm`iq z3B)h;{yr3oakC%x3A*ga=)yOBQ-874@2(w=k|!1n3bQ6UbU2EB!I*)Oyr(S4wLrKM zO4*;D+j|*~ygv~S$>xmojFW;CgzN#f!m*SrkRF2KNc)t(yBV# z%CEz?L2ec7CD+vHY!@PKS{HUZ&Q~6l_L(^0+7xK_`#;gMX5b@s z3fQ>ud*o0+(os8nX}J-aQIPR)Y`bb{DXDbi%V+eGI2@!#rQT4X7(+=L&bWdQw-XC1WNP2#$EjboHQ z8?{2wpN653dDjMzp@^(<)IYOZ6@DzyL-#VgMLqPr#qW+g7_&k|l9G}2^huoqJx*J% zJMBVdTm6m0`en+6*1hm8e>sAss2;g`-^p+7OS22PknR?XH0oX@3GnC5!8u&l-+v={ zG)H%hEPM?aA~Xh!z>llXD{Oy7#6z{H{CLmB&|hWR_y-rpH_l?4k{%o5YzuX_(!V$h zpO3fTOzE9YyVt2zOJ_xE8r^oHLGU?T23@E0l*EGdL%`(P-fOIuF6W@Cc3=%h!9~f! zfr7nJl^BvMe>)o!Le2!bZ#PbZ4(gjVE$O3kz{3GT3r?1Mhz)Pk1b%Y$N`sq?k2tD| z89#ukq)CitH!u-ej%2hwzpKMFL{}t0jL`L?I}?K+2j6}vYfWMt2vs>y(V30lwrgom zxx_&_r}bQuTkeObit5zAMbDK$!wcl}kbE9?5!i}N#|L8QUGFAX5Sm?;{J3;L4^D!F zlSESK+qYxwkzwOSdl01~!s|^5-_p_8)!%p`BX6(YA+Lbgu#z$#&}(e2v%mwKOTPrB zS$`8KUDjF0jZxsEwg`t+;UrOdyo2%Or7k@CEVBBqrq05(<$S3WOgM@w7 zUf{V!h9za9EFtf!@RaU*zd^t5WFd<(33w~`X#5n7*J)PZs#Bg!;+}63Q>)Lm zcaC{p#*2}!#0a5cp;KaGe;_)qy8o!#?S8+HscM^{KOWz1R;oEJk2DR-Fn6o?8P~7n zL9qK0sbW<%tuvUbx3Hog)-X%Aj^&th9}i2t$qLm796f8sRJ$jmTly4R11|#G zbCbZxynn5rf$9MMUoQarrW`|5{-&&mZZ}*HZoE9~4NfrOBo=>>>Bqi63I&_^7*4s- zTvnK0ye{h63CBgbUnPGI!WR{2Lfj${Vo0p-`{`KD$CAEOm~Uc*<+PK$_L#&r)Aqt(4;8$3 zaEWg6MV6dNM5ugXDkaknNU?Jf8|T%cg$7XKgycxj>gD}_wD)RuOB6mv?}h};X>no$ z-W-!as^05^`Wrnw2du*`GrWQOOF8k~3^39#+(?1Rk? z!Y1f~2e`D1X`Z0tZjN{9L(mv<P zI|yX)kJn0YN+zF6&3?A9Bg(-+e?~;1b~W!wl8O9{m~;{dUPqGoL0RSCmcvcw-`|*(S{tc&u|@s1!H$Pz zF*Uhkvy!~Wr|NE^xoH;PEyMo9;4m%uporZ@%L8-}Comwmqe z!6?Izej7z5ZJ^NGg!qS>?lo7&VYqSuz9~koAT5m)v-@-&Ykj@dWA9Bvs$N>ttFR~@ z(c`RLkN*py4{0>R5 zi)r>i*F$H*^fw*9zhe7puhRAVJv`?=dHJ3K73qSZS%Bf8zVUbvCN3~SO!vm8-$m%B znbH^QUTMf3z-s{B-^D8~QN~x& z1TV&eSR22p1j5`L$6FP#{}heAKOTFN=UQtT?`!?B$9+?}I4qR0ZPZ-Lm2S|{#lIXN z>Edt-wUr;3*9U{-+vPDe`H+U&=+m@iQ~)%}EqBizTy;t~+mmu`tp84c`5h@n`$N5RC+t zo5*x&%q9)ZoWJO+B7ToEO0e`;ZXy)H~zUtu@-9tMIq%}QFvegg#HTt`n zaPd=d#6MqAeTd@0CUr~}JH-zGy965Ncff8tv2vZ&7=_>14m{@!Vc>B1qHPeI5GT&@ z=Mh)GqH#lKh(RqGm^?KJ3w{^=38y;) znOMZY?mRZ!!uuOQ`-^4(Am3zd8U-bvzx_Q({Fiw^1@KJ%58vbNO>vpKypH(tl<7ZB zZU5STh~mdtH1nP5I(>jFrQhs%a!3;UudDu_ql{y}Yn21!xD%A4tNG>;+mhk`3;wQh z1&}pZ=>l0fdM8$6Sv4(yYh?qFwcD647kdFy&VJDVxD*HqZz*SiH|PCsecU&G<>~P$ zo&3-K|G}a#^%qwLr(Iv1x&~q3I5iLG-ZAAS0QZjXZaX?0oQT0p#5dcy-#|ZjO&5?8Lw~e~&S`wNeg2iU-HZ-MMYWn4$OAyN)CL*E8B{QI1G#FB`*MvSA``c@TxnQkNCw76z{G|K+hEffG| ziX*@^2dDyoF4=h&pfSK(9f!dwm$($qUJN$%yAg@%5=pg=SGTEK9EC$04JEWcJo^r= zD_xi{@G8^2+-zcGJRpXK<&NiQDnN+&?`@ zVb@(J7Kq+2?w_llF9W#aUr6Z7{VwXP$_Hv%#CpJs4yKnYWYKoqt=@;Ag8{4`}_dEY! zf%z1+ce@o5KU~9HEHcHC2&nRHv0$$}SJ%k}aH+kn9FfqutSGX;gssssYfbk-z%)!$ zIh}u?naAgcO3I+uQyqiZqYbZ(B!)@ziKFBRgQE$4b{~1zbgkuzAHuvpOZ)Y(lMozd z$!%v^=pQ8f8C$^ecTa^(VXV!Ah3pqyDju8I_%lGRMs9HsVIvjRA`cO@;2 zziKvNkm<{WHGU7nee|6>H*+MY`S`GJm&5P6MEAR0cB99YhaW0a6`MEFr|}v&+=)5` zV!!vW!R%dkT+t1{tmcW*^5Y|Em2My3AdKf3?AZwK@$fY9tfuqRnHUEjG}Lw>E5{v6 zppBo&3#o<^4|jjYLcA7{O_v_|%v`|=dFIs}d| zF>h}_<9cy)d!4IW>rq9{W2HWH$x(%C2B8i?{>qOPSw90i&>wp?HsIHF`pUu?MC}1n z+c?Mx0oz+rGoe&g6?tp}zJn`(RY7cRPz|Q|FTE3pUjiPUE0UFBMc5;1q2>Lo{f;y{ zs@|?X|1#~GZo(%|&f3P-*>ta}6gV0>7c9*e0|;HVHvr4q6@yI9Hp;3CP~LAFT8C1b zMxRT!Ty*=bV^SVHV)CaeGK(>MMpAMEsDY{RZI%jHKfCCwN5L^3R-8?RbF8&R(6X3D zYowjn5%nz}UhP+vmoA#(@${ zcIfN3-hlYfM3UKm1XIewxI2o30OVF}(n*k=c2lONur8m?;3cUDy$FM!W#41&bOuPMO^} zcd5Fae<0jtD8azB{PE5H)_3o`{jv+$st<^d?^sPa-!9He4SdGlmN!Fe-1eqc=5u&| zBG<|R9D1y7eh==MB(*ZiEBjm+uvh!osKy2O4^sQli-k z&VV?BXIAr*L-~7@JytnoC904HxxK6 z|8e+gFIw0z{u$2zPn0`J+TBaKB_9QP#31q7trrj^J< zWB;O>Y!P&Ft&4Q-EqPc|=Zg9Iokcn3hrvk%OzzNsq8Wt$39iRGxF1q~A;)VMI&;g2 z@AgPc^mf+f$MLX&&y#yisZ)q6zD7HMfxHiEz-+g01n5?F03XLORUY=AGU+Qx!0)MD z4%BDTB&g_7)NH<2`Jt67G3?*q=owAB1|J}4R~xjwBu`m658Qh@S;hL>NU&ASE1hjx zd#w*?L2$8K@9zufxLB;EL`Hp!J5B8kqDJ@HtlKZ~nuBcaN#2~F2K5?C``E@jccpoO zq2q^NSYUR~acO_)1_mCL&qyZv@ogGiG1}H}5ckUQGH+duTxyX{3&_UELEE1_Fo+Ee zslG>y>NaTP9dkYlz3C?3ecPzrLD2vZls)6C+Ja}SuylQPsc-jldF(1_B+vD@IT6km zt3X0MImZN%XW+AnfQUX~8_p;Yh2^Jc+o-dCye~g#gho3MUrM``7$k3XX8bAXn-cDy ztPolCp_l}#zur4yJgd*^jQ;YR-@%+_z`bvwpWw$vGm-)YPeO0xf^g0*g7dl)k_W1I zMu2_>ns6`OZV@>MCf4j%iq$cuf=1f$2Xse2pKhhE5TdFh9_Vmuhraoudu9eASi52; zNKw(D#^(v%HU4?AtHO8{+2@xXqUvnhkm8MSv~B4Ad67EFAN-49oiNw>|1tKKaZ#;p z`>-M^h)BtRbPnC!NQxjK-Cfe4bcl2#NOvP3%}_&k3y8!JQbXs^`CsgP-&^>3WFYUeH<3JCWEtAvl>@4XS0^>1_cQS=d1AC1HgT)6CE2VfogG~M!npUDOBqm`BA(gTm5JauIGd_nij z4Z_$GWm1h-6xK6PR-*6ClSsTadVW9siJ(9mCT`dpYo_Xbbz!t%DnU)p{JQgYbG~;d z!D-G?`Nu`kM^)CHJ1E_^xda=ao<6 z19ri~F0zB;W6ca_@*N(XF33?(xa$vR?GPb-g-Sq$|h39o*+6D`{R+2sU%J!O7%q+tJ%ZGF4t-D3E@14DePWR1ALl=VS z5A-}dq86v~m^?GeYRdQbJw$s?%|)&)Pe(C)raS@{6$oS5Xy2K%u&0gN;I&GvCy?dBE?{XeJ z_8vG(MV!GNlkD>t2WjDRAKNzv%zar!%zVuxOI98EyLl+0^-cBfZIAcG^poG61wZ3g zpNg8he_3H{-wq|;6;0?yG448J+c9|XJQb@)B`pnxr<)?kMkB5FMjE z_Aml}mx`3f0YBWAUCQF^4-|3OTl~_2Rto%)^MiyC(Ppe)*Zt=4Z5&}gkQvI`Oi+Z@ zG~TxkI6bQQ8g4Hjw)N?A&?}@HRtfbZpo?epbPRVxOX`qGT$mn)QR0 zef&t80ldhdy%MrSB*gm*`4|j5;sy$ZaPG!F(8co${BM*;K|RFr z@lK4dI*y+PYEYo10ef};cPr0)_e>5A=egcK#GL~Dis;QnnQW5<}NKd z-XbBpP1=$`V;LLQ!!&EZb2jV$l_JD+z2>i$NpylD`U3`-H|LW>+)#zg^F)7?#TQRE z+Ku@WJ_+>t3mXn{8Of{f(+9F@_J=*Gx2H)Eyu!zc$Af!S5I-P)Ys%IhQCEC*=kj`P zHpoZmiNc~#9+S)A$B~30nyIz;v0TuCi~^MyI@~UO0JmRW!%m@*qve0lXW*Z(TtkM= zz7>X5X;mY6x$PhC?_OT#qK-A#l43^t}sP=O*zXkn(bCd$kqjwxM&B~=yZ!&TJd`w)Jbvqdk&`2 z87EHY_3=8&rA+A%mFqAFI^2_n!XKpveo3U%#r3k&znS7@>FMW=(`$>A(*YefHAXY7 zM?4R(7d(J_@JjesmxMvU2AY4ySb{|`Z^;Hi1+7WQ%=3;fu1{kez3KIm&tDbc&9wF5 zRrgd?735Gr+6QGTpXEoa#Sw87n0&lk?kGDpy{oBxEp_3?<;_M0G$DzOdNw>y7@qG) z$#pf%j**l?^Y>)Lw@8MJhC~kj$v>KrmE%?Tv*()jK?r=226CMrq_m6riH$P7^nP&$ zS9RD89T+6(V%`Qx;$2aUeuD-m+JaH8#OD<&@^+e%Bre}y)M4{WSm64J!b683ki~;n zrvBAfkwF@PcuuxJI)ENB=6&niwyRelvo=KU$8&UT6)-((RARz9NShbAt!wrh>l#KG zs+;C)&_iWRELTGm7n6DNcHez|sOoHV8-6PSFI+JAsXtlvPHe`R|0%?tan}EdVzyH^jIkwptl94%<)&fD~$Mf2`E6tO#AF!+=v8EY_5V=7FQ-e>}R z`4Lxugxx6O^a0P+I?kt*hdCPGgPQ}b2pO7k6=Lx336nrTkUHIy|T*E>Xy* zdu-#sJI@SVKL7l}Hn5%S2l^fzm_5mke*(^yZvwV0rO z90YFu37ea%;@Zvk82O0Xi9wkjZZJ5s5_NGtDux-@rUhka4tnh{#)( za;~GR$S@Z@7`bIPZMO4mmLo!3k_n8s+gDx!B=LS~i7-GTDKT5VFB+8>Ru^u1<7DOt0259yH{_ z3M}{~1a0I!eS%njt?#jHl$&HsSk(Uq_EJ1YS&=h+9HJ?JGGKHZjQ z$TIsTS5he_M6T@g1doQUTz`hPPZ)Jnj0uD1Llk&? zz`L9U+Uj`ig~!J_`v>Y73`#l zGVG3zV(z~Y_3{0C3^?2caLyRp98lXSU|Q;bh9!T$GVt@&odlYTG!57HkS6myX4MD%2htzU?pqD0)A9pkLjLCofq%Zf z!v?X*)BeR#{)+_sDhwcVY9)So^&5u%&%-mRfVWDGHTHi(lrjJRypXRTSdl3Tmj5qZ znu-wcl&X8l#XWz1>37G`xWI2T+ls{ggmLfQNaiM1bZd$Gq>j+P*w1%xZ3!j-g+5Wk z{Xcl8Y83F9CK}4 z8VU0Cv4A>Gm9HPLM*S~({P)4RJlV@t(^?=mm`i|(83)-^X$UoaFnifj*mAlu&&t$i zk6GhafNqiX#FH(tdq%D*ER9ERgHDeCGO6D4-MQM4Z~86s#3J5;s!A1Ww6{Q3h`5vY3i%b=GPZg9-Icc=e?$6UzPYuu~LVj()YRf!4#&W+jZ8_bX z>-l4YwLPWG`mHU~s8qO=?A6Nj=2WX%Bs^@|ky)M|AU^|QJGd(Oa*j8Dez*f(_WU(|M)b;_Wqw%x3#D3&5HI+9|dWgxqe`i*d^)q+eD-=wGMG zR|mT{q}AkQ#{0kT{)Tyf`yIFd(L4X7Uw(L=^W;U-bJ>q()x>cfbcz> zqizonDHhn6Ak~|F(;5astAWro1@!?v%0nQNM>S}&S~`lPv@}Ff%<4`EAFts`&+Yz$ zwsxdleQh{x3qj%7Cz~YA==ot7>LyH;P(3%e^-6n0U{8?ruwiPZ!Fi5fKF#nhslMlD zVnStRW^0Z0XuHjPaP3NzJmKjS~xuY`s_NP>baMb#uR#{3Um)`?azl zq3&fzpt*lX_o|$w`B3Q>mU!18RJh8#Fwa-}(!9v*+t;6tp9@SwZ59#?p_HNLt)Z2q9@ zA0Sfd7Z)^gwP8ax2UMp`YwzJDns*9MZ|}Q^t?To35r5amIEi1Dg%Thi6@eVA9GRjs zM6UqdVRfju(&@hz1+uNb*npp(gT`bgDEI8?b?j+y8pvGFF%4axVGs$fKQsd@kbw;6 zf!VCxxog3J(*>UvjVkw(?G3!Mmx4x(&O3)ooF&L%Q>pwm$s$2h9(OUeGFCiJ+#57V zU-oAJ4(A2usLx>EQ&^dz@Q20or(!Wzmv5lh@;U>Z)M6Z?`-w}dx%$$PL=M^z+Yf-F z_Q5fSOLa+nZ|WrO2rB-D9iFFf~G5Pd`@{9S_2#6^v5-==XDp7vvi%B7T2H4?ewZi(uR60G5i-%JOo|td^<6c4529`UD}HXS9Zu<_xU$kNULNi@%ZG zu1jVOMhSi+i%{qI*Y}<&c6TfKKNa`toMT0tJQRz36d7+9`31}TS>e|V)C9%t|vgJ4CiRBs1B@-?dYZH#)YU}9z4j9U-Tu8@n3 ztx-|Iv;UHA{k7V!z9ch88dp0%K#G27{R!(40heHc^;QKiC0k$oGIF59UV!~MgqZ6y zpZPG+t!GUL7ien38Y!k+xs&YDzLYN2slI=hQc+VUXhPqwB8DOGmnj=5Xhljr&!zq@igcKX+EvG@ zqUO{^i0`jo=m))`Cc#)6_c964g1-g36O9j%jwwAdEFWc^w`azSdil}+^rdTBSVNVv zbnfHN1=C*{obnFSw+))R`ZmSlD@Ou{?;%P@eGU7tpIui&llYx74~V@$T(EZm;QW!P0YGQ==xR(9(0 zS|{oQ%F!Bao8R@UuQhgI`cS(n1ZCy1Zzx?{agutO;Lf~)_gI)LoJ9n&U9~&fax-DK z&>^Oh!L~P7zm~3D1h0rwuw){H?fzoR!{`h5?n+azzID1i3u;`A6qp6D-4WW*>X%<= z(iuavxi3nTOc%6GRBgSn3q=hpX~;F?aSSx0x31cSbbZc?_3Hs8|3=xG zT#DVlX^}O z+P2!{73fM%FbMrG5XUq1WIF0I-#-YhKzfgv0QDZ}B!rnr!rL-owNck*2k{h0WgLbs zXZwby!$%u>!4y=NsXpFH#Dbc3ND=OV(?m+)_Nsc6m|s)ea-dR~&NWQ>H>(5sCmwKl zwkS#lwJ0F;9!>a=o1+a-d!zAeW`_}7E>qB_XzCqC)$NPbj-S9y$uj`?wtz(Dh_Wk5 z6s=Eg-f%bQq|pswws$SLPtgu&kk*?mKLJJ(u%J_&l#$oED=&2@Ux^^TDx3?w&+u7% z=+b#1FEj}dAIoHKfA>V0*>|zW**bJJ2u4&DvdLA=6Hc6ISV{s0%mRsH^p0zQQDX)B z2^3V+2k$E;?}(UXq-PzZP`t`mH#Q$*5~a?G5$|(Zz%}8z#Nrtk!Ow=gIO~Rhu0`5- zUjN93STfaT85%d>=Q>0OsL2Cz2;KC>( zp-mEx6|6m9$zV#d1M5oS@fM%!Ej^3dD;g08-4Z)|F&uFfO4|j`Ev8%dEr`vH(*K-- z))HFv*E@qm_brF8HL;sRYHTO^MSikJk?A#b1U_ho=8_`d zBc~K687)WD86XU%PcA7E`D3AdjH5>!2-hoMRMOAM~duEY|w^U@`8Lo5X`mXOop7-Cj6Ybf#RPOR)wG(Q;u&L}?lEk`ZnruC(h8;R_Hb+G)$>}7 z)6-dcaUC=C;(BP$8&8{xWSoPM5%Qd5i(Png>}U$qj0yHLWV04L-LpL=@tZom zgvf@pQ2-nib+mSSec93|GV{9LP9b?EsXUokN)+?{g#pQo7gTY)4;KI`vhmJ38~&$G{cxolK_%+y#lUc=AB z(K%|^r+^{q3xDuNLO?W|X^nb4T552`$g^yv50B#VeiV)_nKo@_i zfz3RaE#hj#rk1)&l)AtFM^Jla_8o*`q0T71!-P%2SHw~~@%)52N+8J&iKBM1;Bdk@ zGGu1&cG4#eYc~uJ$Ido&z0~vh56b^yAihPSqE4*E?W$^|%cv^Mlh&T3%aQPk@<=ON zQ{HYdaaa0j{s6Z`|3_uIjX%GYD;ocKJ%9mRCvM|i5er2$=sw+GHLEEvmJ`!m%JMC{ zPIc(-X4@A^o(agS;cpAc>nWpo$9Y}2ioySV3x8oSg@XXJ#(v5r4V7m`zHMQeAX=Vtk*wZ(KNHAMRwUNf#E{p`)>6ORfaj-D^ z5s?2;p(-ArrjF;jA?yk^r2sC)-27alGPR7`Zs1m#3qZkhe2hV4?{Tur6b~{Kqh(C* z*Awz+xPV|?O2IU|0b{y5Dwl0nC~ntSPm}0VKtMH;!Ef-v;0vrkffSGu%GQ2>al1^! zsW$+7ecUvFOZ;Mz?Pm5q_zTrCL zygZKAd7wkc{cZ)Yl1bonP>N-;X@D!oQ+PG3(x?5aM;6VrwrzS_UaXW8i03(R@LK9A z7VlC!8qmT!F`|pzSqutyJ;SkaWzqcELe=qL@|!l#h)13sULoP3GZ%XwS zn#l)VL$3~+!d;yviWlq_Pt5i_h^tBIRks8*1J#- z>zsnUXM%c-t6WL|mgWtXna9w0_>ueSg!*;GdQ06QxMx2?7LUmscCs_iFgtnyZ4>NV zwSCX)C2dg8qmKR$2z__p>q!;u<(q^VsZPZ_0shVIdO>`>ye{4bmYdn}8R1P9o&~V{)QfFvH^(l#9PA>*!Pc+FaCE8bZkL-QT7IP`Snf}8d00|L)G;37!qcDNe(+xomsuN!dR!F*7JJo5R zjhJzU@I^4#crRn2c@cjYl}r~{dzUgLDHr1%A3)B+#k<50_OmH}yWX2mjxs?2k&@FQim*h>x^-(=%2+EH1pTta?wz zgEUS+DwtqcN|in!LGH@!^>itQ9aGQ&0Tw@ZrSS{GF$~>L=Cg_ydBf+rIL5Y#8u(on zf-jG0@pN`Ad6H%LQ#6z1u(8qn1~@;7CbS?LQwOeuT_l^P%%p>-K-t|79mJ8eSl*B@ zu;n3t0h(@032yE5aQpS^rTPN0T{#GDlkerK+_5Ac-0_Z$0U@8v`E}i_y&fEcl{jjJ z)4#%Y6jwQ*U?8QXB3T1o90c4**bcqFEC1e^dX14Pmbp`rtLwkNY~7@J>JY;C)F*W~ ze^tsr>oIEC({wDsWE5fvnykE}$`_z_0-b^ut}-;u2U25|9Tb8;WCg@D7|N7Um4|5v z7OVXyPio5*)%ql>CD9LF14Ii!xH(wc8YPO_Is1zsW&(az(-a>VM35K+g5hdT1-qj? zdE$w0UwtcVhn3}s+<5P zo<6?-OnrY#0QggIP1|O3ec<<`Dbd#+#=@cBd7hEiBk*-66(j?a+=S;ol;~r1L<6{hG{+UTHQ{v7Cf?+oQU~ z(L+zNI{(&jQC_XySFP*M^Is4C)sz$mr`*ESVb|MA9^WfdKt z_2Y`SZ>AfBo(Xig#Mc!YPD^5Y{fo=PMhYh5mW46cBbln)%;aBhlFN#yMUmymwDc!JMXIGL&Dk0?b3yMUQ2GxlF7| zT0#)&o{(Ag(=t<5sFy4A00lGM14B(MhZmcX9&!bOGpVn2-ZC3D4mRmPJl%A-R_z%b zt*8&Hi3nmQ=&!k{oF9n(sm@WXLH+gI$U4z-z&)4|aZ_$$7#$qmr<`AR=fntL>mcn> zuJN>?(?iS{J578CZszxvmyzB5SFoM;3~G0iCPv-lxJGb+72QCU^{LyEq zX>ib@O!mbir2#c$Gu2-NkUTjZ{D&@3ln}ECBf)*@04eio7WzLLFo|E=C|?|{6Xi{@ z6E_s!Ceur`ep&q*{Jlh?iRI1NvD=5uI>hGBH!t4Gx|Ja7cHF$1DBT&Zh>OO6k3%@( z)@Qyl{J!C$Es^qG5aw&ws$ITH_%fy{gcPT2od1D%dOS`@PS)>Z^$K~}pD&&H4jbsA zk|%YWkxj&#VKGUFOn_;<$t>=mDnDM@2mpXh-A-Ve#1Rko0*Eg$boSY%JA!KwFQ z?5^_ZY-~-wp5sB+F5t2;SbR9myaCa+JBH~{bB7ZW)h;-vEa>wcr0V^1bv>tv-kmCU zE&WmUTI7?QZu)SjC^ebldabyXnr&0kkrvjWmYQr_El{=YW{F(2U0?XzPI}!oE`0Fu zTETJ|kKl=~p`QeX)u(Yj=Xfpvec6Qdmh{?AaX?RGTQsu{5dSs+w1hRz^=V`&{MwU)Mx+9kW4aw zLHo%Ibs(&&5K_4nhtYrdrP#TW^L|m1R|;EMWl22aUOhzd`<+>4smyY>?SaQ${hGdO zo1HS^Ajo0D?BmU~n|mPj zfOL{lluMB}HAF;9rt&bBI10Y%R*fFAHpWEBmht;^e~J7EI;SIM$piTkVe@|OXb4sR zz&*yTIS*)j+#Krd4~$MDZqG1UF1DY*Fplps1Ut|sf1I>mcLI*N-T+|T2-NHjE^}@L zgg{RAB{a=$7I1PJ5wrqgbzOq2v9dydX?gn9<-(jx%Q4e}Jv0-ropu&E&$xAPl@YOB zeZMn1Z8HO%zP<+1OBsMH+dy>UP2tN8g+^fZR$mIb=`X4sxn3sSjX7A>4FfEo?leYG z-iJ6W&bJ#*8BQ%1$7Rn$wro+%n*mBgR`|izI5Ao6o;EXaSjv=x=5DW%C z=dGCY@mwco@fQYw()!PoQfF&-VA1tOZOgGuLr1&7!R8$);+s~}%=HD}6FQ(?vcD6g zK)AK02Gc(q1>ysVZibM-Cm)QTh;RC}wfQ|JhDls6D)=}a4;7!H8H+cA;K!!-3nn8L z_efmHW^_*Pz{?3pSF$@$iR}x0;TU#dXsHh3RoryUisTQ&vNwspx~NC)@KBe65YA3)1lxfvg*_`W zsO(_hYf6m7<9K_6q?~ja>qXifMZ6^p zBoQOix!t9C@A4SR)U6Hi%&mmwl2&h+w_N4H>a7wKq?_I(a~}9mT{P4f{(6X4xAumY zoKhUz6Ay9O8tV!hCKhmMD7Miv&0agr15!?DY_^hYz!F&dZNoKJFkqI= zVK(HbE2pUMGQ=fm`nG#8iDQn?t~se%|4VGib!r%c$V3g5LOI-7s9vV+6382AMu`tK z|HJIncMw=42N@OO)V`v8DX0`g^G#SHFe&))99K$yk*j0h^Pw^65MX2LZl}!)I;{i7 zCO&5Xk7a+u$0A*dg&xQ>=t)S%l(;(L#Oj|Y=$oP8t?L`Cu(-IFmvxT2zVBE5Oj?V3E9%4wy7dj29_Ai zHl;8tp7tnjgF$Kud_(_y5b5J=j39Oy zJRwDwx@<`Pn9An?*(25KURL}0@U#7-jB@^&Q?OY{SYFhOb}YC)=jS72_yGJXx^pWu zON1L^edNuo5z3jtm-B(ky;Gf-7)O80aSmTAyxP0pNw&AOj3_a!SwN1LS3lM zfL+ZSe02@vbpKBd`kg5zp|LpXV9{0N+XY0Ts9UIZhf#akU_2GeV!4LOel?rjc+1RH zTNu-GZhSp{Mt=3ZC^+@i-SCJBE(F~klf6cIiBe9%aFiT)9`4Fqv5&9oN{ccJFqAH$ ze^~Hm&@Ga-M?Ey%Po z&+Q)!Q522}*t@5HeO(fcJS=9XDnVpEK4;>lITghBQC-iw{_?2l^x_*!v!!8v4ogGt z4$orMK)Y}SVQI_!oc-Jf+q$7?w39p*?@=^aI68Y7W(Ul53T|u!Xig7d>6l~g?VW^9 z?5G$`mpB>wYX{EpUHkIheeHnN2Dd`Y#5+_}^y6RT+D&E=cn(6=-WHfHS*0dB-Y~&{ zwnUuQiB>>o;7O{1g6i8d+b0g+F}{wv)mGL0)Ih3g#>*Oc-fhjMi>2b2{u*GA& zukAu6+G2r*i{;*6F`BKYsulI4?{xz(VS2;h#i>;3oi~1C!6%3jF53{#VC_x6SAQpu zL)#mfnf+;C9e~rrkvf_4&rC1aP(Z|8>RQVmvxjTgO8P!18>9#h63pHKt zMD;2>WH29Gvj0RrgRSGdJ{nS|pn&tvW!kedCtxwdby#B*_vQISnP*o8%d$XxdVm9_ z3WxQLe$O-F*&sQUyO5IWBXbc1Qp7cRQk3+oZALUg^d%NSY+-`*An6$rs67#(0pdlH zN64Hx+HHB}GsDau2oaIn`V5|aZaQP@&q-g$^Xq~14qVDFC3B1@oCAW{JahJbm+d0g zu_NNNyXoHDWD1tTTDCor1if02(1q`#l|c>>+XaZUOC8Fy_i zMYRv8CY0QbMx%^W(c+jY6K#T~0PrstT2#m<1DXgK=)LMR5cY|ImhguLl`uL_LESH3 zqqLbclVz0$2N#NF34<fuDxM-K{^Je(?AJXmJY0$FN0aJw@m(mn zuSY9g(}!zvt?L$5<*Q8uamF|PmP`Qsi6oMPacR|Jrp@c&!1P0)grxrXvBL=u`f|e= zM=6%}X6cYUfyGvs)DovljngMl9ABv0398ywj5|y%OE_%4$)<$d9hXwVR35q(9)!auf1C3SVk!n(oADNX zDH0_cZOXv#Vs_D__u70dAYI0IyrN1%w+n3`dKdotLBXQlSiA)?2>62h zL~a8ldF4Ndf27b3UV376%ZZx@{ygw%7v?sKg7-WKMU(svV z$6pVH%s3aqnfwL)x!5?u2fF7$SeRrF#v9w+*KWgcWU=^gOZ75D+6H#77L+EW5W8)za*D@PMF+A>QnD zRTC1xeD=DAo?dWKrZ}EyPGbw0JZ!NvB%&Sz%mS`bUmKy(5=-Sts3cko5>=QRrnAiX z_2n@lFR!XqTC7IXUGo(=mK5k+PG}%tWqD1fW_&!vDxX)e4n8*R(@$>rqV@;=O?Hba zQ6mo4N&zH1vilwi2_b!j4%TvlQ#pcCE$(0Q`2ST0TXS%U0UPxbp5+ z{4w-_H}>vHp*9C@m8{!m=KMb#MeUw{27}%MefRJVO!!y_((XP)B6E)t4HXE z?T&!+WwVdRQ1MlNaasCjbPNg~>rasZldk-JZ_)+%1S7mUi;2!C4}n>k>!ZC%u$;Yz zTp>3P%hto|%SM2OSx+q+Pc_$00m**tzMp%*GJ58k_qn9g@yp*cvH$*L7bUwy;xLTX zp<;fcXh6tF<$MwT!kmejF06b6h!?_*o2Yj(e}=~adbT-PTXq^=aY&t&;7k;144uCi?Xw5%+qvW;2g)9o3{)+r&&t3WnM8)!G&g=3l| z#KFzUn^8Ku)O{siZuqkB&aC?P>XZ2txsrrp3)U$*?GOGGz?U%ZWJ!Z*1|ax@Fo`oP z#}FlTX+*MvpoWcr2tZoV5yy$ry8?x@MU2P z{+vRvFAC^&J#T|wZQGIXLLKfLWZjYo`#~6Oud2?PPN5&gd*A98aTD{fHCj)zXAmFM zz26v9RH=}L0%}Ssq*(57!25Bc`b2?igqpmf4Kt$VO)7S`Y$5vyR-5d%d7ztYVE!o& z?|FO5k27FRj^j@$xS1##!6BHAVx+NH--(!Rv%Qzg-ro_KgfgrkuKCA!_^*#2gVo#o zxT+lftNy7G$_TY#MW2{TEEn&zgTD!otw7w4UPhi>9vA{X8IyQ!#OUl7{L%z?R*fU% zHi^rbq+bH>oUmyWl9q(tGjhIP#Z^CBZo>)r*y$I9H5O|A+1)QXSA=o!@FZ6z@;r7l zz{X5V7@kndVK{)qhyKPsz51wc#-G;X_WD#;M0@*@?|HME&~o^9b-|e6dS0~l2q%AZ z8fJKG;^UXWtrCiQHaj|I>0Q)d)(w{I3fn=Q$ximVVOK}GFo+FX#mvu6IPw@5#> z+Am!h$y&4=lOffv9Hzs0xD*VEzww_t(Sh_GC3h&LD>QRr2TLLU>ub_?p@ozt95{jH z;+WVsuU#3$aMdWDh$a+gS*l7<sGi(y1OCh56CO2ze&8lc>i?KvH6=8{}ib5<#*OHR)Qb+ zo#ffOidpu?R?j=>7>XRzB`q<-QRqmvl3dv7Un#rk9Q+)r{jSwdR`SIyEaiz%Uf+!V z+mzu7Zi8y-;r(>%{N&fH6u%M?sA~XAj!(_X%WFx3WqYY#5N7O8LrKL3S2Y@J+Mdn{ z!uzG<`vP=+5JKN6GOsjCb^a?$j8ik>KfMjGJKt^4L|DPXu7k)p-2Nel z1S4dZ;+4db;HM*#?r0*Czpw&dv}=h^4=Qz5^}a4*?Ddn5+TlRy>(mayH+e>Z6u#UM zgzi-H1{z0&O-I`1d0jJkVXxL{uzw@j@lsqMJBqp{#4XWP0L$8N_Z_DX3ZFdpZA`Tx zmyTem^+WMqM(xhrW$N%JM;1+A({Y}J4OKSmzdEe_-g!!Zc4|5zD^XDb_@jeScy!)Q z<7;ykMc5Q8s~PL7f-dO8u=wS9J?WPlefPrmd*0;QF9%_s+dG9-emWrL`Sl6yioS0V zKMOGmU38tLL}VpV$|Jwx{2`=$+*R}PKYj*xOHU>zawQ8ZRsx({LWi9Ni)ALou4%P4 z`xoz}IJ>KnOm`*mD?^mJW6*rxZ2vIGR^Z*rE0IhIm4|zbFt5&l zg!muee!XU+0K8?&H_yQY2$t#F82ewmXiMy=IT3?jJ39tG^4f<5wgi5Y$=(*bv1lO6 z?x^3N>X$$|4xISV;Q%dUyy=)PXv2o#MfOY(4{uZ~VOba&&H?~~W`7{wlzG8RiPStf zBBvOOd!XQ@?mn-KTTdkoXSp$sXA^7(8Zr0sG4Noa1B{+sq{VNP@klPU?drRv{ccv=tXOvE zu=Sty{FgO~%GONrq$Lfm5mQz*2p*xOPg{;HpLcu+A|xZHIxt&o3nhT5m5b_*&=G6g~cT&AlzjQco{bYPL*lp){@;ZVVnuj7K7&&{-m>DtR zb1s{Kcb|{WFhd=5v}A^_{qXwsWca~+(dO{!mY%zML$JG2^t+d#y4y{JIZ_ebG2wl@ zGT`xflhce(nqNzI z+T{a0;dl3L&r=}-^b5Mf7vFAuz72iA=l}3%Vxm3_kXg++OzPfz76z_}B$&ZD=C^~p zET_41L`LIQY12=kqx}wVPu|IGq0{*-ZcPnu?hb?uawnt55%$yOcb>cD)xk)#lHSw( z*&qH{zb9lfF^6I!q&9>SH66{(ln6Y~N2CT_$*Z>CR;!{6a5~6<13u+1Y6jO)jaZvf z%+=N9lFQdwLJksg85p>I<#^~ zZ~0ctyWTrM#y%Okk&ulhG5TDMnGmk;JpnDmOZRD3h<%X|QWxy9PG*)wcd=ofey|K} zu{Nx@ad-s+H^P2Cqe+%k zdNW0iD99{6XHThNhMzFe243VuEV@yx#3dzfL_hgCMWtofST3^sX`$9(f}F}HL026A z6O$|J_1>Uk3p=7a*HGDph;NlFtp;q6q-fpb26{10B$ys-9B(KHw}1op>KYUq6z=jN zI^x^a@%8ED)w;%jS$KyDMYZ`L;lP7*t~>>;`4=Kvok!Y zYoL!xJGX;(xbE{$(*(WE0R{Ijv0ll^`huj_zeSY3M%c7&!PabO#nt@^Wd41-mhK56 zxBWv{rgZf6B%~6!EQ2FDsW0|=Pk-^Pa10u3OB#U>WI!?fymH_va+P?t1Yu%XmTf}s zSdz=(8cdyT0qo@4%7D+5KWrfmvHtb!DO}z7Z@LX5s}JJC?VcJIxDxb*P3MUxO?!8T zzj#J|76B~h=j_7RWfZ1lua{S1oEZe@1NsrvPsYNoP=l`hUC6HeWhgpa8e=`{nCF`Y; z`N}!vBjXVY$WrH1EOI9}b2K+;mHs_fI`3j1GF2tnzPL<^!cEo}cC*m_ z@6^+GlLpgnoU^9;(11J60C$hYFlo6Bw~**1mrWHbGsmRL5)vCq!}^Uw+l!xFr|1Lc zCh%#hfdbFbO0rjYpncjxF@!9AWBec2QU##w1%;X57APY<){Xm4|E;p53(;LHH&Rp* z<2SZc)K*gc$N07ogp|BepcR?B`6nUqFL~0C93aL{!uVMEze-@=cjSOXm`VEWaUnla zBQAmVhaMGN?7yqDyF%?95G$y;Bdu}Fz;?geFxgc2o0zc%+W!4ht92xt0ts{y6&H4FJ(fxJROM-XG z@I`=z?MtY7-k+NR*tkR65*Rfd=1OOXIK$ekAT7E35A#!~cCjn10mo#xIgU-C)aU8wIt!fiE_B%?Zr9 z2ZKK=45gX50L|xiAZ_ajdg?yV00>y0i#>gIKGp*5C%M-ae}9($iDwiEMUI!#TWvPd zQU$!fTBElYu7D-L8Nj+7iWrF#Sd&7A0SS87el5hu<{|p7%$-|Uy@{vHA_||i!WKKO-@GyKLp2vXO{OGnlreHd`son+!S>s@g;RiT z8V78fK)WM}PA^p?Pe;1ToOvl2JQXGvmg9VD#dwcH~=&O3TL=wSF7~e;d@cxf_sm*av~ib(lD6? zQW}BC3MO_kRNyyu z3jz^|HKiX4Q4iamOLh<`&Q=Id|{hT;p?M3 zqpkq6YYjM^JeBxoT&xB+;b z!U|$c^|4>4?lM$@+6(}`dm-JqhoH1I@E6+@Hir2mW}=!~53RxEePoDYYugeu8?kfn zT|Lz1|As<<#}M#_l6pbK?j!53=rZ^ClGIMvyRdOBtGW$B-U);?4-zik?Aro_coxFidOjG8v8cC_M{#Ei5)?OjL(D0iG^L`0)=Spe-Jj-8 zz4!e2*ZJ+?xks)FPlFMI{=^(JapQ@R7)etyD4mjpKs>aKxa*g|#+Z?Ry4JpknCdXH ztF{r&4Z^OGeu?Z24!S4$uEP~Ew@q5={YtRU0`0n%MMq5y5!EdA9$V<6YVK`^(}tCG(X59tUF?;Y4d(vAeMktrWkifh~FD?p#Z@ zg_$dA{rgZ|uNDRh>;y3OKKhlM;>LoK*zGd=OVk`Zao+oeZbpg-*1rdq#qUy+MX<8u z0_Ek$_Mf1ct?Bxl`(>5>^iD|#H*#r8M<4)r^j2Ib?C&zT^o#XLm zN8Zpkh$pc8y2GU=@7^Bg~oe2Q-$-0<{ z%ji|rfK+ql9f!$R>ylMA4*{_P@qnbz(#ebO)FRcQEvwj=dz~#21UPmPgq#DTcMCJ% zbsb?NsNO{3Oc6Vd4bJ8w_gvCgKo1V%EmyG=4+(TY_W0pE@U^b&ALVvD*7aAT!P-7B=SwhMkHb0`Sh zx#`*p9R6NXej7B3Tz<(aa$+W`zjzPW7)uNoAl2q^z7p?P$1S$asb!)Pbczx_+%3LZ zDUvJ^9S|+G_HUD&$2%M+^WEexEr_W1@f{()xWrMoj4U83Yh8*Ui5Ac6Uwv4xu7w=X3SVdm^mjqVym7eu?Dc^J^pZi=tI6R? zR*+Ks2acG_<7&1_lBkGxl~&sYd3bVkIzg8W)m}OC+rLjUBK9cp#<7{Jxax&pQrng` zyy_iQe1;{zzUmdQhI9@W1=Sf1o$1~VLoR1cg>SZ~E*6EasazK-dK(@p#L6C+%`t%4 zOf@vDZ1q@lJ~*9_cZ5`)2~b^}+jLJ-y5Hy)y;mK+sf((>)-HTKCXQE^XQQ&P*IsKr83a>b(q?IVuN~)paw*U|ifB zCkjQBv#xGLX5Ms-Cf%0Z-yI6s4nQBDEcn>OMc(Fk%X7rs`b)~*#Gq(Ma4C_*hq4*s zX}e`Lp00*zk7fui!g}`aZ!de%#ZkqC1g=9JSlGFvT%Wqk(j?Z&SV1yx_Dl~VxFJAW zoHw{^a1>}@Yd*i;^{Kj*{n|^=0w5bci-DV&hc@-ww9H#%mB#|m?ygofVR$vqP+HPM!8a#Oj0=)EIZyC%Xy|Mj?UA* z-?i(fGi{{LZ7SsQC<$4S_DLu5{V(p-U-Y==yk0Xbt_6Y9ErQ++sdfXeBBh@{VWwK$ zk;G9&&TqZD13+t>@C)@6(1!0WczywkYpE)3__K_MfaOJhL52fR33#Ueg&pGFJIorj zqej%*tJYFZk_mzeo8kBzS=cUpIM>zBeo+R|ymbaa(6tx|LY`CamK%KKoq{L69@@U4fizClu&8 zKXOL5LVQNNML_8>F)+$%;86P@))O>JhXag4&N#HP$o;Tqfo|8J_V=cgZ+YnYHV=#0 z;m7c6h{CZ9ZUs|gh;1()?P?)UskP|y|9OJO2LUoc^Sal{K#wy4N}_Uq4XL8QIfCJM znVwq)bk2IVl7`{k&4QW#WB$Vh$J=vZHiaC#FF@T^;)&HFcY&N^r!0u=15=gAn(@sx ziR_VqQ zpEiMS&aG95RwWrNq?e+ms$5gTZgL6SOT|VHqS3IVXaDcb6f=_PR4)81GDs`Zr~jF* za_qBY$BUeK>}O#Vlwu(pk50r^)2Y4K@O9W2_=xrwH)yn)+$ta!514JVzC?s|oyEs!=l_S7Dzj zfb{&(WO63>TH6wKa)}E%be`7Pm0YsIkpvAOQjzF{vv#xNbxpB(HsMedPDEglj&7ZJ zbhgl?+4+0+bbMOtaeLokYI`*|tyPjYXth*`%fFOb(u6;X5UGh-=*PXgw!Vq~K?Pxh zHAlLUonfJVq-S82u_tyMV|%}vps4~HzFNKj0BI6uHZuI>A*8K|vMhA#o@Jp1zegC6 zZ4|&l>(MhHil9O4m(Qa;aZVF&4_N6AnjA;$A$4aeu6OR6^{rr*i4XF8{b6O!xbmO1 zEew$eT;0+FMG#?;fqs+>Sa0;xD3*GxH@Z8J>DDXgK-C9qD}70_5?gY##pPyd-*ltb zh%%}UXVirElyPK-R0cs}6l56}^*=f^l4s1mMPQ)&(@{r#lS06$1b*Xc%hiX70O!j~&rUxmnR4clfe4)y*;p&|U4i_^{x{8HV(w zar@rkxjalIrl-w^KGTaHC&Nqh#9S|_A?0O4zv1x|pyIb`8?A^hkfTEJ=NFarCf9F@ zXl@CyXi+~Qb6s4>&$`yhl0vqOvr^rjB!haQ0;~JIgOU|t-yJ5`&Tp2L82*G=MAlc_Y_ii83gonA(&LbygfTbdO6Q}@^xg-^O z#h)P;ZVW19JI8KZq`kX35x&A9%a;+7UFe(r~bC9QHQQnfj*WA`mqLjE3Is zShH%Fp7*=b!VdIuT5Kxu(&35)(+nkvy6)YAH^49l)j2My;uAWaF>LY5YI2jk-`2hB zjyk$QH8d>un$s7=nIEe5Vx>J?nQgHTV-KIov?lT)N{qI-H1gKCDtru2e7J2*`w4~H z#sTpRkd$&=9Q|Q>!a3$%=`3$2RIJ{ZkbUV$ z!>HTp_XmP{C;!z13SIH!1aZor-_69i>RNtftXOhX-=$HU@F>#BO)*8@fQyww(xCFp z3zYB(3uF%R=cG=q6tcn-BFMz&Z1wGEk=5x;Y83+r?9aL717;d-TgZWu-=6qkO;OFc zsBdkZ%U)WjjhNyQjfT8=npL7tqMz54ZC=5+)Us7s?#(JpVE8sYM?WPnca)tt8SUjx z7BxviJAxl+IwNPI08|T`au6g~n~KN7^eM-jnl-%CcF$AeigzTQA4JQY@NBn`9Tvzk zSMAM{nM3kqZ2xILcSCE-{|eMaEGeQVH!R*Qaw1l}I;}24=3s~80})D?U=8{M3L|N8 zVKVWd?rz=jJGa~pBZi~1S=yVoIK;?Fb(invxiWM=__Bj5@q=oa`}IEoEoteD}RhFsHN0$W=BcqQ?=Wov^e1&}OP)O#mkZ)Oa*(A#4>6{g=u z3r|3pcwn4m%L@Mnr7N$v5P=Q5`N?Z%gd7RO4^ge>e-JjmDRJ^vIF*v%FpK{utBx|7 z4i05Vz^^8MSo3RVi9be^SNjVuyN?`yVLX16fPtFI>ivL zf;onF6iIOvL035@?9i2FGG)mO&T<-(cYmk<0f ziyW_|N68e7Mo?Lc7SacWx&R^qW9v)njw(4oPQ0}e+(@W-e$aRr#CST3hL9VW0_Q{oi|GOs9B^ISa&(d6eZ|XuKB`NEB5&7HkrGoIE;o0pLB~_C(^dkmC~` z#a28*otA}bw7~o8>fx|}%A8nF7JjEK=o2NN$U?XRI&BQdJ^=l^JL_zXCO7x;r^gHJ zNMu$c!_6tR&4LTftW>99g~O%LRO=d~?$LDa(Xt}`UzpPm2J5M&oZaa%S2CZ|7>sVY zg)!XoLkQ$-cY1Wc72?y92592#9^xiaF}@c$=0pk2$$1^Wy|=rn=7>!=`Rbu#7hPij z&)Z0=$x5ZH{r8`s8|!6#n-#u=Nw&pWPsG6&Roz7@2C8pdGsnh*3GS?J9Y?*5HncoP z29#ZdRS$I~q5+6k!w+p4!mj&s>%zjYJ@(8EC27*_PN!M`xO6#K*zOIWl%)fv9)sf? zptPVf2>oOiY&E<3kaxBt`w=)pPd9U0CRmzw)N;kC8o;aLl3Ey(2zpd|c67ahSNq#u z^baicQ|#+6_z8XsSq92I9iJ=X+F1n66bcbtuWcIFb9GaIhXQLmUGZ~C#o@W^T^^Oz zy0c^1wqq6sCtYBdt)l|YWCvp31$!MM;IqwAkmpQwC$Qc*7RYf(s85w@ZZ2}Bc zHg~~f*IPkb$6BqrPAh>2%@<3KVM2F2)7pgKU7$BQ$8i@(^RZFGz3-qlcEWtJHb*Pw zZw5NwpKqiHK;qwdOR}}lvBf~-HKT76Dk)s7(>%w#t2a;Tv6Y1myAFJ1i=;;VB!&cl zfgQ=zknqxt)C!MfUIpR)cIAYKj3Au%Z;4+WAW)lDIc7~jFmOwt& z;ZpBId5o*MNhFfQT_Zu|Ehi{npC6+;9lTy!PZB?gO2yU80UdsBZArJkd96PVsUcpmg^h0-+(UT;=Ud*udsHW_#3^ zy7c~Y(;X9|ys*7uq`;Sc2ehY8ImHfyYc118Qs)7SNw0HKku%$oo0}6`C5WW3lb|5; z;2FDpAL_~*9nhlgRUUJFg<%g*PXq+eNJsWP4Tp7#f`P7!GACr2z3LAN&Yg^6*%d7A z2Y9MF_JQ-h_qVsQ3%TvL#UHP(I=1Ehtm%|DUw@Ix>I&g}wpY*xJ0LNevd%$3gCjuU zFsgvSOYLDIUZ*X^hf1GX{mvlo&+SPvXi?&mxXkLSZu(gi?;fAu@;ctQ$q8U*`dk;{ zjj#1aoJ1L|u+qCpU{nQd0Tn&BIjRr(v!qM2$o*P}pWn@JubJRW{2yh!lfc75n0MW? zD{H-LxY$s-T4Q*+!m*n+t3Q~T!qFc*!rqhei~uy{0O%8ceNec%ExkTYY&PqZxm*U~ zpekliahaTSxhkZCM^sB&<^=WUD@W@$)7B6*)C4lRO&K|SzG!=f!=zYNn{y{#Ibi}G z$2QcyM21yLi*%qAyl4pZsxt73>RCXdLo~{ojtZFnpkdIH5Ext7Tu+sx>j7Whr9C7{ z=sc9-T+v3!o$g?BgDT z+~JTEu3_6bDk^zhfI~9{Tovf^QS2)o%~r&8=;N5QwUftmR<$(htO&j`-)+qYQ?k@e zz8Q}&VNvMkYDIrd<-?>nc}l3KLMKDtt%j)7AiIcfg_35rbZMQ zaOPzfjty|kYdHxCZSR<|C>1R|iL>%VAcWyW*!{zWzwI4e1DcT)jOxh9B?y8^((S_c z8|Rw+oVgd>avC4Pb)4Ym74rce9L2C(Wd@|jfC?$AyK(Rxoi$l00o^OO-?@|V5ecOD zbb`3~dbV;q@N0)}A2VA6-^`c(xW6f~kGy{GtI-G7+N&|un}QvMF7+}|d{%qIgElF+ zHu@G&JV{ltAz#}d97bgBr%I4F5=PkH8|DNeIb?HsK;lE>gL}35O&^@RPx&L1(GK^R zP=|~1hD^j6Zz7;zv&S`Y?v~%%c~CAUD+w)f_&D(3dJI;rCE~RWGkWyG3-UkY7ob!F zRw_3PnzirsJs}7pTJED6p&Wbr+d%xC$V}#_b8=)VCvG0J-Wiv5&9T{?csA2MJ~vp6 znz4)K!cTQL%wy$h6K1qj9hn2b?ad0v^ad{N{!b1b$aEavl~**>;F7@p1F&;DayN!y zak6=eod#b#bOD>7UC1=uJs)q(72>EMzU4C`d>6{?6kpLM6Nb#>>~nN;ruo-;qEPg} zpj~^pA2>s3O4?;LH7M2!<#N;S@gZnWu+Hq6!b*@z(U+a1JyC;(V286R+oP_r6X?F+ zG)eDDnFej2ZKzXg2|2x_h*S@cR z*`id1pb*}kcaDc5TB}FD_=Zfh0^Aq~4EyT_W)E8r#jOY~WYzdC@WtfZBWmTDyY!u6 znM`bqrDp3Q@P!r><~n(7vKauuMWc@X+V@vD8Bz!4;YX2?E3|tV6&WECJK|S!v$t@} zn>M>|sM1B9osagsG!nHv3YmqsmE_OOEqq``xljI_+Y}`pxCTk;DT$^SX<~a2o(K9` zRJP7LFsjL*IrWBNi#~x|r%S&6z?xD|l86x+trXQ4%sMwWhTWTUu27_K4Gsmen9X@VcZ^NVu$g5F$Ekk=^Ds3JoBma{4 zWJ#mN9P&DwkfkL!l3J*f^RR4P77&uLJd2B@-~%G8wl8WpI$Kt_w<`{{P}^x%^@_AT zweFE?Qmq%L^-BeOkPX9W*H}=JO}z|}9lIyq=#;e{N&T;;KnK}pZA#O-B|NID=nX-B z%VqWCVST-PYN1n#9vM@o8H` z@oJdF;9R+I0XhXk;|Y&{uLzh+K7rrkXMZf#NL&c$XJ@=M6pSTowYMj@wT1}y3*hFe zeHm7z9&ZbeN*}6W*dwz2Bz4gpM~UNy2!C3|@v=WCPxAhKzSUtWHh zTJ*3q`~D}i8IMQ>+hkPI5eDd@zQ^er0tN?Ng#6#{`j4=HT+tt_?`{(Fa|K{L&C)~& zsxve7=-$V&84>i$>Hfw?09x*Ks7RBwT6pDKJ=}oo@flNxmlUr)i4ah#q69>LV_2Rs z`)dUG#s*)FB7;Rq@274|_s;Pn-4-QEV*U?qkGt2Su~JxK_;sO1scp0}2O2bg2w+M@ zNdz;TcX}#ga3%TQzlKq>g*iz)?h}h0RQi;#WAgq@Oik3Ehgpco6-xa0HaUjLRg|gn z#!Fz`bUy7KCxUmx6I96?eaHJZ!Tu2+Zthd{6PXu3Y5mmok(y}m=w7pFItZqe?QOI$ z#O=&mkLv%dQcFF2LvQ-;f&gyEGoaNop^oIG-z>2-`148l=OH%pyw}+%6~{`JP@@fe z4Nb|f$}j%<56%e4VcqSD7IfnMnE|sqk!i2@Xt2dmQrXhV&kPutIc-=LtSl@oZ;P7h zYnO70Q~o+k*CQ{tv{hj0^YI`P3*v@I|L?c5-r(N2lhzlKHNY8L`z6 zd>A`}c>U_{JNF(w)ZTW3N3TAUWUA#PO{N6~pY`z}sQgh&iT$!l>a{_CDu3Yy z6`qYyV=9r~@t=_#xY7y)4#cW%(uM(MH-oZw>v)s^nA7>Q*Ghb4dw4;Q{kQA)Z`i(^V}{w9U%htz zbLTMq7JwIQ^t{#leTw=0_u~-zS`1$WPzML$6ABkm!Xb%8z#!{GGP)M0Ob6FwbzJ?z z#j3l6#b!?C?{{oM82G;NF!-HAY=x(bRLi_AW@{D5T1$bvaai$${|+U2#Yc+XiHuO{ z|C0R%*k%cvdM*c({S;hCfcPQU$ERA)Z+ARzFeA7Tugc|JkpT+1wZ&_&)9a+a*5iYE zg#uyVHOq9V_U@7g%zApUDIQ1}a26t-YI+$=>{Xu1?b`x~G*Vzzok3zdD^GSl!96C< z_>q!ASZi@+Q44q-02K;tCaJ*##@-mJAhc~^eftn_ln-$HLU+I7{EsK(<><&`W}Sip z^U2QxV<&w6>}3BS5OR6||2~xt?B;-1!)CDAn5|;EXX;_+j2h*i>Dr(#YuS0wPPnE@ zwP3k;4!kPh>2W()sGmT*{@HkU3xjd!dTi6w_JA07Ev(b%g367D=$L;a%KxK?dALw} zC%@$S<+4Bu(bO9)h+aYxyu}ka>mBku=Q~V+)U*}( zbe4AJ-I%y>+eJ zb^q~I)Mf;UZ0|#_nH1>IcDCVX2{;<2RTAy(AlFD^H9dR~CLV-l|3H{Q_~}cAI)k18 z{7#uG{hpBS+bQgkRIVu$nF2;}jSgs>7XC3=D-Z~KFPuJjUT^iGu$MKlQo5GFt_>~{ zj3Aq8QC*d^Mk#b!P+Jq&phimOdt?8h>IV?A3wS?p{3_j9YOUutniD`RfCy!h5j?%J z@srrX_k7_L)45T0K_9RsjTSo)VLTDsLFz$RVyVMDQAC^FYYaHw;@3rRLT=pk zN%)-X+?26G$MMRVmETXO?F=)&VaDS2b%z*5=atEL83FliKDD>RW2DgqUz~jD(r-1S43bpoc%_yhIasavt!2VV77gv zx-x=_oYhf3q*=zo?z zWlW7m?%eIqWa7H-#8{*(LNX{!w|4w=Cc7$0qbw z@!4z?gefU7%!MW}(`SlA+P=LC)Q%e<6MS~qe38+Lu}TxJG44~Wph>#OJ@0aKyQcMw z!;jbG)QrqrjIXusLZ>#Y;uG}>ns9o z2ij3cG|@-Z%_oFextb-cp)C9ZJxW1mpr%rA^t2Yk&De^0J1Sw#viGc>ezD;2Ns@`U z+}=3Vpd5!9-H9wBRW%(x>C9Hf1!*IKIlFPe7@M_+Z)C*H6ZnE4m+&W?YOXj_yRpV`Ywh_`Z@&RKv`jiZ%2wM4sYdlFp>Gf zuoWdtfjygyN#NcKa@5uQy+n@fJVTNXlh+=|z-fRTv_gXCre}ftTEoi;Ak_6tV7>)s z-*1?GMJ^y7Wn#6MkbV@7>uB~xqVB;#%O`Il z63<#2Av9<|5HWr;9*JJ%E6upyw4z05TNTrpDXpzEf)mMqfU2cab1chqWpr5B0_YD; zHz(CiGq!_G?MRjA_%M%R9ryBEZYs$#+H>vjW@Ojgc5CMqQM%YDvZ4U(!JQY++UbOM zGfMh~mU_K~a&_N917PIOEFjh_{Za;T2=iQaZ9Oq=22>l1ulesD1G>qFOn5=qf+GwR ziOuABZC-G-`zccGSY+Wy*hO@MI9uR@uGiQw_zICnnuz{`;U?0(FQnW-9=w}G@j5=P z9)Z;37uM(sapTfq(=LJoX0#%{|ZIDDMPSUE)*{cV$y{=;0wYEti^eOdEe z1JK`7X<5!AogH-az=##J&5wxHBL0)3V(wZTxn+O*}rnCw!((`-gX})Qi zwO;#{cf=^`8%g&-bj?u=HStA2ahy+c-bJ?a=Lr<)P0r0%V;>e|YUjhE@2KY?e4TGI z6-M}v=Pl;*(=Ay@^s#818YX}&ki=Hm=32V-0%KsSC`oP8kyJK#cC$jxgcSo*H#|gW zbf&UBg8TDemcDVy;A_gi&yW%!o*^gNZ5@x(5gkbVE{Yda(K1 z4bEnywru0z}ZafG$ zoWCTouY@T4?=d^kz35*hLhlUYTVP8==d4v zaXLm34kxnedrU=0P7FEk2{J4ni0tl6pt}$3O==4vglYTC8XtVX5lyR&$Djx%aowx9 zo1?HDnWnlNWwk7ptxqHS)p@^7-bjiml14;KKcH!vPD&(8ZjWwn#oIHxgDp5+pTRrI zI^M84F4O$&9;gktRPc#YF?0{6;1HtlMD1nWCq|h++CJgTpY(%`^5H}!&Tt--HOCu% zaf(`7YIhD969u>JB)CKbomi%ZvLAQ#s7p_Ma3Qf1gwAlxiNrIeX=2QKN1iyvuD7%C zHI}0?re#7fW}1UMkuEq(Ip^ewsYX8V5n)D|O9^SA;*{X@OUJ1rDS;g$EE8SkiMV>Z z3|)jr)-zHQY7>B=t6B(N*UJ1$`5k(OkVGy)M4?c75y5edFtfzg=2S@k&&v-+J!)Nx zucq3*0$Xzd#@IlE)k@)B)KTy|su)B}!POhAb<13!<23?R%*y<$-p#AQN}TAvp^*Z8 z>kUHl%SBmTF(kC5_9q*H+2T*4$aJs?Gm#&K7kMjcr`tYXLCIzn94*Bl6kpLeBbp%7eld0Uxu||cIJu+^DX${Tb3-q-(L~Tqj zHxjcU!v$3{!0)X<72Ow{MGH1}G9{var*S(J@Mg^>#$C?MzRY}RIm9go4d2*!R%XLI ztjeNTU;znBYj0E#xBVHFK%yHBR=gX=(KVLJQ`7JuFP0g_bG>l!f|y~SwV~8L1Z#Uu z{j24Y_8COmXU6^OGkYu}Z#U7G_`>DUbvRn0Dp=H-{Op<1ihd<>DdCW6jWFLmW@`O~ zeOR-`^MTuhwpR}~NNhQDb!mj!PIl;h-!BFyHE1T!C?5qcqS6|cCNdM~6=l>t&IVSS z=FmO%x`-M>B^y;~MbAL|?$tm=7rUG{*4mBh&)KuVL%U~Eg98_C+OZ?f0{ZyVk1miR z<1$ZsB~HnfTvBy)-Yn%NyF`{VsreepwrElPl99&MTFd6N8a160WRjhe4o26~k8_G( zyCMq`S<~s|?WySGZyM)&iBCT}Hbrh!cEEYFF_$$F;EAPk zY-d$B9W+vC*5Fm}IP~RXH2e8ZzNE@@ORHXIk|3mr*7Lb8PtVhgL7{&%pPwF1h6_K; z$q!B{g_RCx!-+9kt$(>1>Y5|46h7lNF$q`?d;?fpQ?1H9a6W~!Ehala(^;s*w4f>D z{6lo;@qquw&{>UiD$uEuE<$mf+9@pKp5-J@%;<5}US{md>{Yy@^%iFK5h(+QmWx^^ ze%5gj&`ptL%|U7l{y{IcBo+{X$jnTr!y&TOO~>K5(Rg?*HO#51y_Y8^q0n;?gv{ju=y)8mc`>=yVyEE?VkZ)+{V^*UF=-ZO~ zsW;v)sjV{GtKMx{u}C1Uqu>tU`UH<^4T`^fHA*ODrog@;ZMVb9|MlcYmh|+Umw^Yt> zO~Q<$BX?(u>@EZmdme{I@d=BRm^0FI8Q)D}8aPokSxrXWvzSnAruqI9;~)bf^6w%3 zbhnn=3(MV7hQeu`F$YQ^BWlnT|{RPKn?jXIxT@<}Rk^`kEkX z{Kv^3*Q&j;=U1}~wH6ag>pFrLpMmY)y_LcztP-3kQrMy*k<%I!MNm{2XqqTB@8XDP zTpspdXP?E3Soo0@ACF|dB1%Qpr07P99MuIa9ak(+ih>@mjj@L+a?uK>+Ag7m-xTuW z8h^2LH|t_YeJQnNAZ#d?yj1H+*PDPSbk+vL#?U|Kf3eLtZ5ZdWv@F?e7%CDlX0L)I zjMh1Tx8A|qW!r(Wjn;QIMoq?2I?`i7`4ozaMc6#kQ!++iUmxhi#`WbM5#%=R9-fG1 zi=$JoL~y8Gy7iK(7e0z6JvYr4^x*@BKy3*8T`-gd8$8o4P?ngo$*Gy_ASmp%fVW9U zf*IbNORPF~CVmNhO=L<4QUf%J-l4cWOKQAxYll20LFJCQVmsj2$c$yp?=Px>{PWUI|RU**k@J3921%~I&ixSN@QEWjD zl?=r6k+vIe+}X zS#gJYbKf^7N;hC;?wG;Y)H1$Cx63015Jka^Zgg1flFk0dAbTdB>*x5k) zm(<=pSmX5_hE_Zm1Ty_cZ}?TrFL-vI+0usjOV4oqZ?+5YEZ4N@rCk$q=ZV}C|$JsNpH^pVYow_EpT4) z&YrfJ6HM&G9KoY)h;2gyoxK81{ht;M!9USRSq;(`X)XjzJ8gJ8ZACqoPirc8WcCzF z3VU)CJUJsWB2JGxCeC`WRL1z#PmPHHK z&8%bs7v3lDo^z0!F^su|5|N@4(vJlp&$Ga&Gcw&wWuTuB>|ZEELH2?ryTf#&p$Q2X zsciX?h(bEl2rL=Ogkr*X+X^_lVl7Rv15!dPA!Sq;9b4j;YGPOy>Jsr!ve)Ukh-l!6 zV?G%dq3)LRZ1NO|4XoirHYq8LXMOl&sQGm8;^A;TNv41ykb}D^mJUT+_eg`b*q1ix z83u-0wZr6}qv5>JH^1r`jQ$AT;)?K=o^(L(E(^?Eua7Up-;_2qjw~%mGv8F=>CK+! zUiSp{6ooi8nsjF1S#U;bx~cP#BVRk`=qB|zmCb>v>+rET0H>DaI=(i)&xJkT)VU#+ zn^S%8cxj;crKFs31_WdDD)Y3d&JyArZAergn9z%Ux4?~g1RruzA*%L*b^o)%0Ac4B zN*4MIPIo*xv^C}Z9H$|x>@!xr_Ee3%-RrW!P4L}C^My8TAN9BDwX!xxzy zqe775TjPJNfKMsQ(foKJPpeJsVi8Q9kPy9i0xzls=F58Jr#^}Lslu_|qwI4^1-50p z2=J=}*xl@9*88oWM{@o93vi{{=Jv>@rY5ItZS>bw>@~6cmX$EXs$M8B{!HzQe-Zqt z%h;YLf%3~cAucy&8YUV0$uIf;faM}J*yU5Z8yc2f0MoOJRT}F-!4~kNIQI4WNA#Wu z$r4VskSSfre~71yHj`D~DHShPWf4F$U!rWbv;2oRc|fC4Dayf9`BL$1Rus}Q-3#I( z8BM>~MYUq<%?kel=tX9c-Tc&N9@7n~NEuVZ!?4ZJmxgjTllNuBWd6%$h#^l-Gt)@8 z*n?MD+S~D@{f*}}b|t`>*>+kkeDh}{Vyoaf#kPXQxoQ1dFgMJ%-*T%>B&+j zi}3o(BNFnTv>XrZTrT?y>VDaQ}0Nz#&z;Km+Ozs{=rHq0e_LrIDf6o$$2rmi5{9d+wtJ@ zjR2ZHZh;86^x?)z`%c7K!(A1~mi)Z*V5$^3$dill#eO=yAIFmUVBGky`#b^`u#G1y zGBSWmBvFfNEh8-d=hXF^3}FoXBnSs5x82lcrK<^>J6~)xojc5#cwKG?bE);{emY$b zw53c4Fw|Jj)y-WKs_LeKgCFKXYAobLG=C>NqM4zx2Zyg_t(E=mkmGisg+H>~JDLC5 zF~7ni4@rD@ABk8nxUAnf0*1uJc0Bg>E5S-@7_t4z4P2`MW*;p}RSDH$iU7#m(ELRX z(|<*%*C>0Xg}j@c>Qxz8`R&Q8LVFP3&z$nUYPUaC(Ivm~p>UkyDx02tr2lKt_&uf8 zlkX#Ywzuf{?xjX5v>Wi~HrgI89cuI~`AsPbR_fQcJRsCxM7Tx`vUX!%M>Ce>?*HMM z|6zTz5q5TW4=t-aFEH}uQm3F^&CT!=;G5G4h1&D2=|kmIs!LuK=BHzRknlu zMTAO7n-D@Ja=$mY<_S8UMZzsdfE70He@dfY!uxr;kI+O>CJTC==UZ)aR%=#SKc7M; z;j5nC>7h=K0~3BDf!?iU(Wj1|M3D%4}H$Zka_aMe6%LAlT2P z+H}9ocG2{ksFRJ>RGhBP@7DhP&~nBsuZWwsu&}I6PM9~Cvkt34P5v4@t#SXoOt+yC v>|+1$04cSe4?LR(GiCz*`W`LI`+KM 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 literal 0 HcmV?d00001 From 1efb2da534e5d8b991b7f440f2fec769420f2420 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 07:28:46 +0100 Subject: [PATCH 013/105] fix: render ClawRouter CLI usage --- Sources/CodexBarCLI/CLIRenderer.swift | 38 ++++++++++++++- .../ClawRouterUsageFetcherTests.swift | 47 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 4eda4aa4a2..ad37803d2d 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -36,6 +36,7 @@ enum CLIRenderer { self.appendTertiaryLines(snapshot: snapshot, labels: labels, context: context, now: now, lines: &lines) self.appendMiMoBalanceLine(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendCrossModelUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) + self.appendClawRouterUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendDeepgramLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendAmpBalanceLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendLimitsUnavailableLine( @@ -111,7 +112,7 @@ enum CLIRenderer { return } - guard let cost = snapshot.providerCost else { return } + guard provider != .clawrouter, let cost = snapshot.providerCost else { return } // Fallback to cost/quota display if no primary rate window. let label = cost.currencyCode == "Quota" ? "Quota" : "Cost" let value = "\(String(format: "%.1f", cost.used)) / \(String(format: "%.1f", cost.limit))" @@ -181,6 +182,41 @@ enum CLIRenderer { } } + private static func appendClawRouterUsageLines( + snapshot: UsageSnapshot, + useColor: Bool, + lines: inout [String]) + { + guard let usage = snapshot.clawRouterUsage else { return } + + let spend = usage.budgetSpentUSD ?? usage.actualCostUSD + let spendValue = UsageFormatter.currencyString(spend, currencyCode: "USD") + if let limit = usage.budgetLimitUSD, limit > 0 { + let limitValue = UsageFormatter.currencyString(limit, currencyCode: "USD") + lines.append(self.labelValueLine("Spend", value: "\(spendValue) / \(limitValue)", useColor: useColor)) + } else { + lines.append(self.labelValueLine("Spend", value: spendValue, useColor: useColor)) + } + + let requests = UsageFormatter.tokenCountString(usage.requestCount) + let tokens = UsageFormatter.tokenCountString(usage.totalTokens) + lines.append(self.labelValueLine("Usage", value: "\(requests) requests · \(tokens) tokens", useColor: useColor)) + + if usage.errorCount > 0 { + lines.append(self.labelValueLine( + "Results", + value: "\(usage.successCount) succeeded · \(usage.errorCount) failed", + useColor: useColor)) + } + + if !usage.providers.isEmpty { + let providerMix = usage.providers.prefix(5) + .map { "\($0.provider): \(UsageFormatter.tokenCountString($0.requestCount))" } + .joined(separator: " · ") + lines.append(self.labelValueLine("Routed providers", value: providerMix, useColor: useColor)) + } + } + private enum CrossModelMetric { case tokens case requests diff --git a/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift b/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift index 28668cc373..68713024d8 100644 --- a/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift +++ b/Tests/CodexBarTests/ClawRouterUsageFetcherTests.swift @@ -2,6 +2,7 @@ import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCLI struct ClawRouterUsageFetcherTests { @Test @@ -135,6 +136,52 @@ struct ClawRouterUsageFetcherTests { #expect(decoded.identity?.providerID == .clawrouter) } + @Test + func `text CLI renders budgeted spend and routed usage`() throws { + let parsed = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.budgetedResponse.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + + let output = Self.renderText(parsed.toUsageSnapshot()) + + #expect(output.contains("Spend: $0.01 / $25.00")) + #expect(output.contains("Usage: 6 requests · 54K tokens")) + #expect(output.contains("Results: 5 succeeded · 1 failed")) + #expect(output.contains("Routed providers: openai: 4 · anthropic: 2")) + } + + @Test + func `text CLI renders unmetered and zero spend without a zero limit`() throws { + let unmetered = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.unmeteredResponse.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + let zeroSpend = try ClawRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.unmeteredResponse.replacingOccurrences(of: "1250000", with: "0").utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + + let unmeteredOutput = Self.renderText(unmetered.toUsageSnapshot()) + let zeroSpendOutput = Self.renderText(zeroSpend.toUsageSnapshot()) + + #expect(unmeteredOutput.contains("Spend: $1.25")) + #expect(unmeteredOutput.contains("Usage: 3 requests · 0 tokens")) + #expect(!unmeteredOutput.contains(" / 0.0")) + #expect(zeroSpendOutput.contains("Spend: $0.00")) + #expect(zeroSpendOutput.contains("Usage: 3 requests · 0 tokens")) + #expect(!zeroSpendOutput.contains(" / 0.0")) + } + + private static func renderText(_ snapshot: UsageSnapshot) -> String { + CLIRenderer.renderText( + provider: .clawrouter, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "ClawRouter (api)", + status: nil, + useColor: false, + resetStyle: .countdown)) + } + private static let budgetedResponse = """ { "policyId": "openclaw-smoke", From cec3d6dc2cc38930c8c7dec9871c37cc21a81822 Mon Sep 17 00:00:00 2001 From: Kostarev Kirill <37106274+Kirchberg@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:26:33 -0700 Subject: [PATCH 014/105] Add Russian localization (#1860) * Add Russian localization * feat: complete Russian localization * test: account for Russian language label --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + Scripts/check-site-locales.mjs | 2 +- Sources/CodexBar/PreferencesGeneralPane.swift | 2 + .../Resources/ar.lproj/Localizable.strings | 1 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/fa.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/gl.lproj/Localizable.strings | 1 + .../Resources/id.lproj/Localizable.strings | 1 + .../Resources/it.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../Resources/nl.lproj/Localizable.strings | 1 + .../Resources/pl.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/ru.lproj/Localizable.strings | 1152 +++++++++++++++++ .../Resources/sv.lproj/Localizable.strings | 1 + .../Resources/th.lproj/Localizable.strings | 1 + .../Resources/tr.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1 + .../Resources/vi.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../LocalizationLanguageCatalogTests.swift | 14 + docs/index.html | 1 + docs/site-locales.mjs | 145 +++ 29 files changed, 1338 insertions(+), 1 deletion(-) create mode 100644 Sources/CodexBar/Resources/ru.lproj/Localizable.strings diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c12346c2..544023a25b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.38.1 — Unreleased ### Added +- Localization: add complete Russian coverage for the app and redesigned website. Thanks @Kirchberg! - Localization: add Galician app translations and language selection. Thanks @B1NAR10! - 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! diff --git a/Scripts/check-site-locales.mjs b/Scripts/check-site-locales.mjs index 60a13be14a..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/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 9d141a1af1..11572ec8e2 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -19,6 +19,7 @@ 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" @@ -49,6 +50,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .dutch: L("language_dutch") case .turkish: L("language_turkish") case .ukrainian: L("language_ukrainian") + case .russian: L("language_russian") case .indonesian: L("language_indonesian") case .polish: L("language_polish") case .persian: L("language_persian") diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 93a6cf1ce3..dd74cc6042 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" = "تركجه"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index e75ce7539d..1217458dee 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"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index b09487e8bd..75139c8ba8 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"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 373271362f..be1c618939 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"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index b020990893..059122ac0c 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"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index a1472d129f..0ff879eea6 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" = "ترک چه"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 492c4d9a14..10126b9982 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"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index a17b8481a5..0897317896 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -406,6 +406,7 @@ "language_german" = "Alemán"; "language_french" = "Francés"; "language_ukrainian" = "Ucraíno"; +"language_russian" = "Русский"; "language_japanese" = "Xaponés"; "language_korean" = "Coreano"; "language_italian" = "Italiano"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index eec57948f7..881099f896 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"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 3a99ad8407..98fab71602 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"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 6870724dc2..e6193ab0df 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" = "イタリア語"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index d235752315..8be911dce8 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"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 94fa2b2a8d..f9ea445940 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"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 659cf82956..c263d474c5 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"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index aca57672dc..8e87d55141 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"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings new file mode 100644 index 0000000000..1eab0a1920 --- /dev/null +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,1152 @@ +/* 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 минут"; +"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 %@" = "Следующий срок действия истекает %@"; +"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 92f6e66315..7bbe6684c8 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"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 06567c9bd8..322ae5b4ca 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"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 1216820af4..ad552962b1 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" = "한국어"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 40ddfbcfd1..0949658872 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"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 0fc767bba6..2fbe38c9d1 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"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 89a864706b..1abdd45a01 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"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index f721329c0f..700ab17d6e 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"; diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 897639204f..315491ad7e 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -16,6 +16,7 @@ struct LocalizationLanguageCatalogTests { "language_french", "language_dutch", "language_ukrainian", + "language_russian", "language_italian", "language_vietnamese", "language_japanese", @@ -35,6 +36,12 @@ struct LocalizationLanguageCatalogTests { #expect(AppLanguage.ukrainian.rawValue == "uk") } + @Test + func `app language catalog includes Russian`() { + #expect(AppLanguage.allCases.contains(.russian)) + #expect(AppLanguage.russian.rawValue == "ru") + } + @Test func `app language catalog includes Korean`() { #expect(AppLanguage.allCases.contains(.korean)) @@ -104,6 +111,12 @@ struct LocalizationLanguageCatalogTests { "quit_app": "ออกจาก CodexBar", "usage_percent_suffix_left": "คงเหลือ", ], + "ru": [ + "language_russian": "Русский", + "tab_general": "Общие", + "quit_app": "Выйти из CodexBar", + "usage_percent_suffix_left": "осталось", + ], "gl": [ "language_galician": "Galego", "tab_general": "Xeral", @@ -335,6 +348,7 @@ struct LocalizationLanguageCatalogTests { "language_galician", "language_italian", "language_persian", + "language_russian", "language_thai", "link_email", "link_github", diff --git a/docs/index.html b/docs/index.html index 55f6a1c6ac..c354bc44a2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -26,6 +26,7 @@ + diff --git a/docs/site-locales.mjs b/docs/site-locales.mjs index 3040b2f004..d640a78828 100644 --- a/docs/site-locales.mjs +++ b/docs/site-locales.mjs @@ -60,6 +60,10 @@ export const localeCatalog = [ "code": "uk", "name": "Українська" }, + { + "code": "ru", + "name": "Русский" + }, { "code": "id", "name": "Bahasa Indonesia" @@ -2207,6 +2211,147 @@ export const localeMessages = { "widgets.dragToDesktop": "Перетягніть віджет на робочий стіл…", "detail.more": "Більше" }, + "ru": { + "meta.title": "CodexBar — все лимиты AI-кодинга в вашей строке меню", + "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 56 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", + "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 56 AI-провайдеров для кодинга прямо из строки меню macOS.", + "nav.primary": "Основная навигация", + "nav.language": "Язык", + "nav.docs": "Документация", + "nav.source": "Исходный код", + "nav.download": "Скачать", + "theme.toLight": "Переключиться на светлую тему", + "theme.toDark": "Переключиться на темную тему", + "hero.prefix": "Все лимиты AI-кодинга,", + "hero.accent": "в строке меню.", + "hero.description": "CodexBar отслеживает окна использования, балансы кредитов и обратный отсчет до сброса у провайдеров, за которых вы действительно платите, — по одному элементу статуса на каждого или все вместе в одном.", + "hero.download": "Скачать для macOS", + "hero.fineprint": "Бесплатно и с открытым исходным кодом · macOS 14+ · универсальная сборка через GitHub Releases и Homebrew", + "providers.title": "56 провайдеров,{mobileBreak}одна строка меню", + "providers.description": "Популярные провайдеры становятся элементами статуса со своими окнами использования, обратным отсчетом до сброса, графиками и меню провайдера.", + "providers.yourProvider": "Ваш провайдер", + "providers.authoringGuide": "Руководство по добавлению", + "auth.cookies": "Cookie", + "auth.cookiesGo": "Cookie + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API-ключ", + "auth.token": "Токен", + "auth.legacyApi": "Старый API", + "auth.local": "Локально", + "auth.apiKeyCookies": "API-ключ + Cookie", + "auth.virtualKey": "Виртуальный ключ", + "showcase.alt": "Поповер CodexBar в строке меню с плитками провайдеров, индикаторами использования, обратным отсчетом до сброса и данными о кредитах.", + "features.resetsTitle": "Сбросы, которые можно планировать", + "features.resetsBody": "Сессионные, недельные и месячные окна по каждому провайдеру с обратным отсчетом до следующего сброса — больше не нужно гадать, стоит ли начинать долгую задачу.", + "features.spendTitle": "Кредиты, расходы и сканирование стоимости", + "features.spendBody": "Балансы кредитов и месячные расходы там, где провайдер их показывает, плюс настраиваемое локальное сканирование стоимости для Codex и Claude.", + "features.statusTitle": "Статус и инциденты", + "features.statusBody": "Опрос статуса провайдеров показывает бейджи инцидентов в меню и индикатор поверх значка в строке меню.", + "features.cliTitle": "CLI и виджеты", + "features.cliBody": "Виджеты WidgetKit для поддерживаемых провайдеров и встроенный CLI {codexbar} для скриптов и CI — сборки для macOS и Linux в каждом релизе.", + "detail.installTitle": "Установка и обновления", + "detail.installBody": "Универсальное приложение через {releases} с автообновлениями Sparkle или через {cask} (обновления через {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Встроенный бинарный файл {codexbar} плюс отдельные tarball-архивы для macOS и Linux в каждом релизе. В Linux: {linuxCommand}.", + "detail.privacyTitle": "Конфиденциальность", + "detail.privacyBody": "Повторно использует существующие сессии провайдеров — OAuth, device flow, API-ключи, cookie браузера, локальные файлы — поэтому пароли не сохраняются. Примечания аудита в {issue}.", + "detail.permissionsTitle": "Разрешения macOS", + "detail.permissionsBody": "Полный доступ к диску для cookie Safari, доступ к Keychain для расшифровки cookie и OAuth-потоков, а также запросы «Файлы и папки», когда вспомогательные процессы провайдеров читают каталоги проектов.", + "footer.builtBy": "Создал", + "footer.changelog": "История изменений", + "clipboard.copy": "Скопировать в буфер обмена", + "clipboard.copied": "Скопировано", + "auth.oauth": "OAuth", + "auth.cli": "CLI", + "auth.gcloud": "gcloud", + "nav.home": "Главная CodexBar", + "nav.projectLinks": "Ссылки проекта", + "nav.sourceGitHub": "Исходный код CodexBar на GitHub", + "nav.sourceStars": "исходный код", + "nav.displayDownload": "Отображение и загрузка", + "clipboard.copyBrew": "Скопировать команду установки Homebrew", + "hero.badgeStart": "Лимиты использования AI", + "hero.badgeEnd": "для macOS", + "hero.descriptionShort": "Отслеживайте окна использования, балансы кредитов и время до сброса лимитов в AI-инструментах, за которые вы платите.", + "hero.fineprintShort": "Бесплатно и с открытым исходным кодом · macOS 14+", + "features.title": "Лимиты,\u00a0расходы\u00a0и статус", + "mockup.openPanelAria": "CodexBar открыт в строке меню macOS", + "mockup.macbookAlt": "Строка меню MacBook", + "mockup.menuBarAria": "Элементы статуса в строке меню macOS", + "mockup.menuIconAria": "Значок CodexBar в строке меню", + "mockup.usagePanelAria": "Открытая панель использования CodexBar", + "mockup.updatedJustNow": "Обновлено только что", + "mockup.plus": "Plus", + "mockup.session": "Сессия", + "mockup.weekly": "Неделя", + "mockup.left60": "Осталось 60%", + "mockup.left73": "Осталось 73%", + "mockup.left89": "Осталось 89%", + "mockup.left98": "Осталось 98%", + "mockup.reserve2": "2% в резерве", + "mockup.reserve5": "5% в резерве", + "mockup.onPace": "По плану", + "mockup.resets2h53m": "Сброс через 2 ч 53 мин", + "mockup.resets4h12m": "Сброс через 4 ч 12 мин", + "mockup.resets5d4h": "Сброс через 5 д 4 ч", + "mockup.resets6d23h": "Сброс через 6 д 23 ч", + "mockup.runsOut4d21h": "Закончится через 4 д 21 ч", + "mockup.lastsUntilReset": "Хватит до сброса", + "mockup.limitResetCredits": "Кредиты сброса лимита", + "mockup.nextExpires28d2h": "Следующий истекает через 28 д 2 ч", + "mockup.nextExpires27d21h": "Следующий истекает через 27 д 21 ч", + "mockup.manualResetAvailable": "Доступен 1 сброс", + "mockup.recentUsageAria": "Недавнее использование Codex", + "mockup.today": "Сегодня", + "mockup.last31DaysCost": "Расходы за 31 день", + "mockup.last31DaysTokens": "Токены за 31 день", + "mockup.latestTokens": "Токены в последнем запросе", + "mockup.usageChartAria": "График использования за 31 день", + "mockup.topModel": "Основная модель: Example-1", + "mockup.estimatedLogs": "Оценка по локальным логам Codex для выбранного акка...", + "mockup.credits": "Кредиты", + "mockup.zeroLeft": "Осталось 0", + "mockup.buyCredits": "Купить кредиты...", + "mockup.partialDegradation": "Частичное ухудшение", + "mockup.twoIncidents": "2 инцидента", + "mockup.majorOutage": "Серьёзный сбой", + "mockup.degraded": "Работает с перебоями", + "mockup.operational": "Работает нормально", + "cli.outputPreviewAria": "Предпросмотр вывода CLI CodexBar", + "cli.left99": "Осталось 99%", + "cli.left100": "Осталось 100%", + "cli.left79": "Осталось 79%", + "cli.left74": "Осталось 74%", + "cli.resets5h": "Сброс через 5 ч", + "cli.resets7d": "Сброс через 7 д", + "cli.resets23d18h": "Сброс через 23 д 18 ч", + "cli.available1": "Доступен 1", + "cli.nextResetCredit": "Следующий кредит сброса", + "cli.expires27d22h": "истекает через 27 д 22 ч", + "cli.account": "Аккаунт", + "cli.plan": "Тариф", + "cli.total": "Всего", + "cli.auto": "Авто", + "cli.titleLine2": "в CLI", + "cli.lead": "Встроенный бинарный файл {codexbar} с теми же данными об использовании, что и в строке меню, — для скриптов, CI и быстрых проверок в терминале.", + "cli.note": "Отдельные tarball-архивы для macOS и Linux в каждом релизе.", + "cli.privacy": "CodexBar повторно использует существующие сессии провайдеров — пароли не сохраняются на диске.", + "widgets.titleLine2": "виджеты", + "widgets.lead": "Виджеты WidgetKit для поддерживаемых провайдеров: окна использования, время до сброса и расходы, если они доступны. Разместите их на экране «Домой» или рабочем столе.", + "widgets.note": "Малый, средний и большой размеры. Переключайте провайдеров, не покидая виджет.", + "widgets.galleryAria": "Предпросмотр галереи виджетов macOS", + "widgets.categoriesAria": "Категории виджетов", + "widgets.allWidgets": "Все виджеты", + "widgets.metricCaption": "Компактный виджет для кредитов или расходов.", + "widgets.historyCaption": "График истории использования с последними итогами.", + "widgets.usageCaption": "Сессионное и недельное использование с кредитами и расходами.", + "widgets.switcherCaption": "Виджет использования с переключателем провайдеров.", + "widgets.burndownCaption": "Оставшийся бюджет в сравнении с идеальным равномерным темпом расходования.", + "widgets.burndownCombinedCaption": "Графики расходования сессионного и недельного лимитов в одной плитке.", + "widgets.dragToDesktop": "Перетащите виджет на рабочий стол…", + "detail.more": "Подробнее" + }, "id": { "meta.title": "CodexBar — setiap batas pengkodean AI di bilah menu Anda", "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 56 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", From 4c2ede08b80d40adc0c6aa56da2b0c5abfba9d82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 00:49:03 -0700 Subject: [PATCH 015/105] Fix Gemini helper timeout reliability (#1873) * fix: make Gemini helper deadlines reliable * ci: allow macOS shards to finish * test: tighten Gemini helper timeout fix * ci: preserve macOS test headroom * fix: make Gemini helper waits deterministic * fix: harden Gemini helper completion * docs: clarify Gemini helper fix * fix: make Gemini helper waits deterministic --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 1 + .../Host/Process/ProcessPipeCapture.swift | 12 ++++ .../Providers/Gemini/GeminiStatusProbe.swift | 71 ++++++++++++++++--- .../GeminiStatusProbeAPITests.swift | 46 +++++++++--- .../CodexBarTests/GeminiTestEnvironment.swift | 2 +- 6 files changed, 112 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfaa584444..96cd1630c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: needs: changes if: ${{ needs.changes.outputs.macos-tests == 'true' }} runs-on: macos-15-intel - timeout-minutes: 40 + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -119,7 +119,7 @@ jobs: run: ./Scripts/lint.sh lint-macos - name: Swift Test - timeout-minutes: 35 + timeout-minutes: 40 run: | CODEXBAR_TEST_GROUP_SIZE=1 \ CODEXBAR_TEST_SUITE_TIMEOUT=120 \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 544023a25b..75f2eb8d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - 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! diff --git a/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift b/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift index affc29111a..defc1a0244 100644 --- a/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift +++ b/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift @@ -49,6 +49,18 @@ package final class ProcessPipeCapture: @unchecked Sendable { return self.stopAndSnapshot() } + /// Waits only for the first complete output line. Useful for helpers whose descendants may inherit stdout + /// after the helper itself exits, preventing EOF even though the caller already has its complete answer. + package func finishFirstLineSynchronously(timeout: TimeInterval) -> Data { + let deadline = Date().addingTimeInterval(max(0, timeout)) + self.condition.lock() + while !self.isFinished, !self.isStopping, !self.data.contains(0x0A) { + guard self.condition.wait(until: deadline) else { break } + } + self.condition.unlock() + return self.stopAndSnapshot() + } + package func stop() { _ = self.stopAndSnapshot() } diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 9d19dd7895..7e65919b3c 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -498,7 +498,8 @@ public struct GeminiStatusProbe: Sendable { // For fnm-managed installs, ask fnm where the package lives if Self.isLikelyFnmManagedPath(geminiPath) || Self.isLikelyFnmManagedPath(resolvedGeminiPath), - let fnmPath = TTYCommandRunner.which("fnm"), + let fnmPath = Self.resolveExecutableOnEnvironmentPath(named: "fnm", environment: env) + ?? TTYCommandRunner.which("fnm"), let packageRoot = Self.resolveGeminiPackageRootViaFnm(fnmPath: fnmPath, environment: env), let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot) { @@ -521,6 +522,22 @@ public struct GeminiStatusProbe: Sendable { || (normalized.contains("/node-versions/") && normalized.contains("/fnm/")) } + private static func resolveExecutableOnEnvironmentPath( + named executable: String, + environment: [String: String]) -> String? + { + guard let path = environment["PATH"] else { return nil } + for directory in path.split(separator: ":") where !directory.isEmpty { + let candidate = URL(fileURLWithPath: String(directory), isDirectory: true) + .appendingPathComponent(executable) + .path + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + private static func resolveGeminiPackageRootViaFnm( fnmPath: String, environment: [String: String]) -> String? @@ -1088,6 +1105,11 @@ public struct GeminiStatusProbe: Sendable { } extension GeminiStatusProbe { + private static let processTimeoutQueue = DispatchQueue( + label: "com.steipete.codexbar.gemini-process-timeout", + qos: .utility, + attributes: .concurrent) + package static func runProcess( executable: String, arguments: [String], @@ -1114,33 +1136,45 @@ extension GeminiStatusProbe { let stdoutCapture = ProcessPipeCapture(pipe: stdout) let stderrCapture = ProcessPipeCapture(pipe: stderr) - let exitSemaphore = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in - exitSemaphore.signal() - } - do { try process.run() } catch { - process.terminationHandler = nil stdoutCapture.stop() + stdout.fileHandleForWriting.closeFile() stderrCapture.stop() + stderr.fileHandleForWriting.closeFile() return nil } + // Process has duplicated these descriptors. Closing the parent's copies lets readers observe EOF as + // soon as the helper exits instead of spending the full drain grace period on every successful call. + stdout.fileHandleForWriting.closeFile() + stderr.fileHandleForWriting.closeFile() stdoutCapture.start() stderrCapture.start() let pid = process.processIdentifier let processGroup: pid_t? = setpgid(pid, pid) == 0 ? pid : nil - let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success - if !didExit { + // Wait synchronously for Foundation to reap the helper. A separate timer owns timeout termination, + // so neither termination-handler scheduling nor stale Process.isRunning state controls normal exits. + let timeoutState = GeminiProcessTimeoutState() + let timeoutTimer = DispatchSource.makeTimerSource(queue: Self.processTimeoutQueue) + timeoutTimer.schedule(deadline: .now() + max(0, timeout)) + timeoutTimer.setEventHandler { + guard process.isRunning else { return } + timeoutState.markTimedOut() SubprocessRunner.terminateProcess(process, processGroup: processGroup) + } + timeoutTimer.resume() + process.waitUntilExit() + timeoutTimer.cancel() + + if timeoutState.didTimeOut { stdoutCapture.stop() stderrCapture.stop() return nil } - let data = stdoutCapture.finishSynchronously(timeout: 1) + let data = stdoutCapture.finishFirstLineSynchronously(timeout: 1) stderrCapture.stop() let output = ProcessPipeCapture.decodeUTF8(data) .trimmingCharacters(in: .whitespacesAndNewlines) @@ -1152,3 +1186,20 @@ extension GeminiStatusProbe { .trimmingCharacters(in: .whitespacesAndNewlines) } } + +private final class GeminiProcessTimeoutState: @unchecked Sendable { + private let lock = NSLock() + private var timedOut = false + + var didTimeOut: Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.timedOut + } + + func markTimedOut() { + self.lock.lock() + self.timedOut = true + self.lock.unlock() + } +} diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index 57c8b003eb..8ea2650e96 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -392,11 +392,11 @@ struct GeminiStatusProbeAPITests { } let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) - let start = Date() let snapshot = try await probe.fetch() - let elapsed = Date().timeIntervalSince(start) #expect(snapshot.accountPlan == "Paid") - #expect(elapsed < 3, "fnm package discovery should not wait for inherited stdout EOF, took \(elapsed)s") + let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + #expect(kill(childPID, 0) == 0, "package discovery should return while the stdout-holding child is alive") let updated = try env.readCredentials() #expect(updated["access_token"] as? String == "new-token") @@ -416,24 +416,25 @@ struct GeminiStatusProbeAPITests { """.write(to: helper, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) - let start = Date() + let clock = ContinuousClock() + let start = clock.now let result = GeminiStatusProbe.runProcess( executable: helper.path, arguments: [pidFile.path], environment: [:], - timeout: 0.5) - let elapsed = Date().timeIntervalSince(start) + timeout: 5) + let elapsed = start.duration(to: clock.now) let text = try String(contentsOf: pidFile, encoding: .utf8) let processID = try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))) defer { _ = kill(processID, SIGKILL) } #expect(result == nil) #expect(kill(processID, 0) == -1) - #expect(elapsed < 2, "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)s") + #expect(elapsed < .seconds(7.5), "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)") } @Test - func `fnm helper completed no-output failure returns promptly`() throws { + func `fnm helper completed no-output failure returns before deadline`() throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } let helper = env.homeURL.appendingPathComponent("fnm-failure") @@ -443,15 +444,38 @@ struct GeminiStatusProbeAPITests { """.write(to: helper, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) - let start = Date() + let clock = ContinuousClock() + let start = clock.now let result = GeminiStatusProbe.runProcess( executable: helper.path, arguments: [], environment: [:], - timeout: 2) + timeout: 10) #expect(result == nil) - #expect(Date().timeIntervalSince(start) < 1) + #expect(start.duration(to: clock.now) < .seconds(5)) + } + + @Test + func `fnm helper successful output returns first line`() throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let helper = env.homeURL.appendingPathComponent("fnm-success") + try """ + #!/bin/sh + sleep 0.05 + printf '%s\n' '/tmp/gemini-package' + printf '%s\n' 'ignored trailing output' + """.write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + + let result = GeminiStatusProbe.runProcess( + executable: helper.path, + arguments: [], + environment: [:], + timeout: 2) + + #expect(result == "/tmp/gemini-package") } @Test diff --git a/Tests/CodexBarTests/GeminiTestEnvironment.swift b/Tests/CodexBarTests/GeminiTestEnvironment.swift index 1d2a427644..b22cc6e610 100644 --- a/Tests/CodexBarTests/GeminiTestEnvironment.swift +++ b/Tests/CodexBarTests/GeminiTestEnvironment.swift @@ -315,7 +315,7 @@ struct GeminiTestEnvironment { import sys child = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(5)"], + [sys.executable, "-c", "import time; time.sleep(30)"], stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, From 2d077cd8d6102b78a27d8342a956087f78b40cca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 09:45:53 +0100 Subject: [PATCH 016/105] docs: finalize 0.38.1 release --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94e128cc25..4857a06ccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.38.1 — Unreleased +## 0.38.1 — 2026-07-04 ### Added - Localization: add complete Russian coverage for the app and redesigned website. Thanks @Kirchberg! From 9ce9be54ff2c5bfe96ced59579391020c7bd0f41 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 10:05:31 +0100 Subject: [PATCH 017/105] fix: load notarization credentials for releases --- .mac-release.env | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.mac-release.env b/.mac-release.env index 4e491751ea..16dfc02adf 100644 --- a/.mac-release.env +++ b/.mac-release.env @@ -17,6 +17,10 @@ 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_VAULT=Molty From b435e27b429f167cc7dce532be896a1929e69b3b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 02:11:00 -0700 Subject: [PATCH 018/105] Fix Claude keychain prompt toggle (#1879) Replace the experimental security CLI preference with Security.framework's never-prompt policy while preserving legacy migration intent. Co-authored-by: Benjamin Meyer --- CHANGELOG.md | 1 + .../Claude/ClaudeProviderImplementation.swift | 9 ++- Sources/CodexBar/SettingsStore+Defaults.swift | 16 +++-- Sources/CodexBar/SettingsStore.swift | 17 ++++- .../ClaudeOAuthKeychainReadStrategy.swift | 19 +----- .../ProviderSettingsDescriptorTests.swift | 9 +-- .../SettingsStoreCoverageTests.swift | 65 ++++++++++++++++--- docs/ui.md | 4 +- 8 files changed, 96 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94e128cc25..99c7e3fa95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ ### 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! +- Claude: make the "Avoid Keychain prompts" setting use the no-prompt policy instead of the experimental `security` CLI reader. Thanks @gmkbenjamin! - 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! diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 80c808f67b..ea0e443f95 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -67,7 +67,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( @@ -141,8 +141,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 +161,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( diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 666da74e65..612fb0f309 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -406,9 +406,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 +418,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 + } } } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index aa350c4e90..9432677d77 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -423,8 +423,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 @@ -535,6 +535,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/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift index 01556f94a0..057f3abbb5 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainReadStrategy.swift @@ -17,26 +17,13 @@ public enum ClaudeOAuthKeychainReadStrategyPreference { if let taskOverride { return taskOverride } #endif if let raw = userDefaults.string(forKey: self.userDefaultsKey) { - return ClaudeOAuthKeychainReadStrategy(rawValue: raw) ?? .securityFramework + let strategy = ClaudeOAuthKeychainReadStrategy(rawValue: raw) ?? .securityFramework + return strategy == .securityCLIExperimental ? .securityFramework : strategy } - #if DEBUG - if self.isRunningUnderTests, - ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" - { - return .securityFramework - } - #endif - return .securityCLIExperimental + return .securityFramework } #if DEBUG - private static var isRunningUnderTests: Bool { - let processName = ProcessInfo.processInfo.processName - return processName == "swiftpm-testing-helper" - || processName.hasSuffix("PackageTests") - || ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil - } - public static func withTaskOverrideForTesting( _ strategy: ClaudeOAuthKeychainReadStrategy?, operation: () throws -> T) rethrows -> T diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index c115258d6f..e3bebb60e4 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -114,16 +114,17 @@ struct ProviderSettingsDescriptorTests { } @Test - func `claude prompt policy picker hidden when experimental reader selected`() throws { + func `claude prompt policy picker remains visible for prompt free toggle`() throws { let fixture = try self.makeSettingsFixture( - suite: "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental") + suite: "ProviderSettingsDescriptorTests-claude-prompt-visible-prompt-free") fixture.settings.debugDisableKeychainAccess = false - fixture.settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental + fixture.settings.claudeOAuthPromptFreeCredentialsEnabled = true let context = fixture.settingsContext(provider: .claude) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) - #expect(keychainPicker.isVisible?() == false) + #expect(keychainPicker.isVisible?() ?? true) + #expect(keychainPicker.binding.wrappedValue == ClaudeOAuthKeychainPromptMode.never.rawValue) } @Test diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 22e0724a2f..2edaeea4f0 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -471,9 +471,9 @@ struct SettingsStoreCoverageTests { } @Test - func `claude keychain read strategy defaults to security CLI experimental`() { + func `claude keychain read strategy defaults to security framework`() { let settings = Self.makeSettingsStore() - #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) } @Test @@ -484,13 +484,56 @@ struct SettingsStoreCoverageTests { let configStore = testConfigStore(suiteName: suite) let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) - first.claudeOAuthKeychainReadStrategy = .securityCLIExperimental + first.claudeOAuthKeychainReadStrategy = .securityFramework #expect( defaults.string(forKey: "claudeOAuthKeychainReadStrategy") - == ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue) + == ClaudeOAuthKeychainReadStrategy.securityFramework.rawValue) let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) - #expect(second.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) + #expect(second.claudeOAuthKeychainReadStrategy == .securityFramework) + } + + @Test + func `claude legacy security CLI read strategy preserves no prompt intent`() throws { + let suite = "SettingsStoreCoverageTests-claude-keychain-read-strategy-migration" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set( + ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue, + forKey: "claudeOAuthKeychainReadStrategy") + let configStore = testConfigStore(suiteName: suite) + + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + #expect(settings.claudeOAuthKeychainPromptMode == .never) + #expect(settings.claudeOAuthPromptFreeCredentialsEnabled) + #expect( + defaults.string(forKey: "claudeOAuthKeychainReadStrategy") + == ClaudeOAuthKeychainReadStrategy.securityFramework.rawValue) + #expect( + defaults.string(forKey: "claudeOAuthKeychainPromptMode") + == ClaudeOAuthKeychainPromptMode.never.rawValue) + } + + @Test + func `claude legacy security CLI migration preserves explicit prompt policy`() throws { + let suite = "SettingsStoreCoverageTests-claude-keychain-explicit-prompt-migration" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set( + ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue, + forKey: "claudeOAuthKeychainReadStrategy") + defaults.set( + ClaudeOAuthKeychainPromptMode.always.rawValue, + forKey: "claudeOAuthKeychainPromptMode") + let configStore = testConfigStore(suiteName: suite) + + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + #expect(settings.claudeOAuthKeychainPromptMode == .always) + #expect(!settings.claudeOAuthPromptFreeCredentialsEnabled) } @Test @@ -506,15 +549,17 @@ struct SettingsStoreCoverageTests { } @Test - func `claude prompt free credentials toggle maps to read strategy`() { + func `claude prompt free credentials toggle maps to never prompt policy`() { let settings = Self.makeSettingsStore() - #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == true) + #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == false) - settings.claudeOAuthPromptFreeCredentialsEnabled = false + settings.claudeOAuthPromptFreeCredentialsEnabled = true #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + #expect(settings.claudeOAuthKeychainPromptMode == .never) - settings.claudeOAuthPromptFreeCredentialsEnabled = true - #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) + settings.claudeOAuthPromptFreeCredentialsEnabled = false + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + #expect(settings.claudeOAuthKeychainPromptMode == .onlyOnUserAction) } @Test diff --git a/docs/ui.md b/docs/ui.md index e5d5e085cb..a7ee9e2fc9 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -56,8 +56,8 @@ window has elapsed. reports sizes and cleanup ideas, it does not delete files. - Display: “Overview tab providers” controls which providers appear in Merge Icons → Overview (up to 3). - If no providers are selected for Overview, the Overview tab is hidden. -- Providers → Claude: “Avoid Keychain prompts” uses the prompt-free Security CLI reader when available. -- The lower-level “Keychain prompt policy” picker only appears when the Security.framework reader is active. +- Providers → Claude: “Avoid Keychain prompts” selects the Security.framework reader's `Never prompt` policy. +- The lower-level “Keychain prompt policy” picker remains visible as the source of truth for Claude OAuth prompts. ## Widgets (high level) - Widgets render shared usage snapshots for the supported widget families and From d88b670d2e4b801a1ff976731cd06c7959a6cca6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 02:17:59 -0700 Subject: [PATCH 019/105] Fix Claude keychain prompt safety tests (#1882) Fully isolate Claude availability tests from real Security.framework credential paths and harden the source audit against partial overrides. Co-authored-by: Benjamin Meyer --- ...eOAuthFetchStrategyAvailabilityTests.swift | 30 ++++-- .../KeychainPromptSafetyAuditTests.swift | 93 ++++++++++++++++++- 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift index 82f4fb58cb..57a1a4640e 100644 --- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift @@ -144,7 +144,9 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { _ = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride .withValue(recordWithoutRequiredScope) { await ProviderInteractionContext.$current.withValue(.userInitiated) { - await strategy.isAvailable(context) + await self.withAvailabilityKeychainDoubles { + await strategy.isAvailable(context) + } } } @@ -181,9 +183,11 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { - await ProviderRefreshContext.$current.withValue(.startup) { - await ProviderInteractionContext.$current.withValue(.background) { - await strategy.isAvailable(context) + await self.withAvailabilityKeychainDoubles { + await ProviderRefreshContext.$current.withValue(.startup) { + await ProviderInteractionContext.$current.withValue(.background) { + await strategy.isAvailable(context) + } } } } @@ -246,9 +250,11 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.nonZeroExit) { - await ProviderRefreshContext.$current.withValue(.startup) { - await ProviderInteractionContext.$current.withValue(.background) { - await strategy.isAvailable(context) + await self.withAvailabilityKeychainDoubles { + await ProviderRefreshContext.$current.withValue(.startup) { + await ProviderInteractionContext.$current.withValue(.background) { + await strategy.isAvailable(context) + } } } } @@ -365,5 +371,15 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { #expect(available == false) } + + private func withAvailabilityKeychainDoubles( + operation: () async throws -> T) async rethrows -> T + { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting( + true, + operation: operation) + } } #endif diff --git a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift index fb00d1ae64..3386d3bb77 100644 --- a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift +++ b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift @@ -41,6 +41,60 @@ struct KeychainPromptSafetyAuditTests { } } + @Test + func `claude availability tests with keychain enabled use test doubles`() throws { + let file = Self.repoRoot().appendingPathComponent( + "Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift") + let lines = try Self.lines(in: file) + let callSites = lines.enumerated().compactMap { lineNumber, line -> PromptCallSite? in + guard line.contains("strategy.isAvailable(context)") else { return nil } + let oneBasedLineNumber = lineNumber + 1 + guard Self.hasOpenScope( + containing: "KeychainAccessGate.withTaskOverrideForTesting(false)", + lines: lines, + before: oneBasedLineNumber) + else { + return nil + } + return PromptCallSite(file: file, lineNumber: oneBasedLineNumber) + } + + #expect(callSites.isEmpty == false) + for callSite in callSites { + let failureMessage = "\(callSite.file.path):\(callSite.lineNumber) calls strategy.isAvailable(context) " + + "with test keychain access enabled and incomplete scoped keychain isolation" + #expect( + Self.hasOpenAvailabilityKeychainIsolation(lines: lines, before: callSite.lineNumber), + "\(failureMessage)") + } + } + + @Test + func `availability audit rejects a Claude-only keychain override`() { + let lines: [Substring] = [ + "KeychainAccessGate.withTaskOverrideForTesting(false) {", + "ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {", + "strategy.isAvailable(context)", + "}", + "}", + ] + + #expect(Self.hasOpenAvailabilityKeychainIsolation(lines: lines, before: 3) == false) + } + + @Test + func `availability audit accepts combined cache and Claude keychain doubles`() { + let lines: [Substring] = [ + "KeychainAccessGate.withTaskOverrideForTesting(false) {", + "self.withAvailabilityKeychainDoubles {", + "strategy.isAvailable(context)", + "}", + "}", + ] + + #expect(Self.hasOpenAvailabilityKeychainIsolation(lines: lines, before: 3)) + } + @Test func `tests do not call SecItemCopyMatching except no UI query coverage`() throws { let offenders = try Self.swiftTestFiles().filter { file in @@ -96,13 +150,44 @@ struct KeychainPromptSafetyAuditTests { private static func hasOpenKeychainTestDouble(lines: [Substring], before oneBasedLineNumber: Int) -> Bool { let helperNames = [ "withClaudeKeychainOverridesForTesting", + "withKeychainAccessOverrideForTesting(true)", "withSecurityCLIReadOverrideForTesting", "KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting", ] + return helperNames.contains { helperName in + self.hasOpenScope(containing: helperName, lines: lines, before: oneBasedLineNumber) + } + } + + private static func hasOpenAvailabilityKeychainIsolation( + lines: [Substring], + before oneBasedLineNumber: Int) -> Bool + { + if self.hasOpenScope( + containing: "withAvailabilityKeychainDoubles", + lines: lines, + before: oneBasedLineNumber) + { + return true + } + + let bypassesCacheKeychain = self.hasOpenScope( + containing: "nonInteractiveCredentialRecordOverride", + lines: lines, + before: oneBasedLineNumber) + return bypassesCacheKeychain + && self.hasOpenKeychainTestDouble(lines: lines, before: oneBasedLineNumber) + } + + private static func hasOpenScope( + containing needle: String, + lines: [Substring], + before oneBasedLineNumber: Int) -> Bool + { let targetIndex = oneBasedLineNumber - 1 let lineRange = lines.indices.prefix(through: targetIndex) return lineRange.contains { index in - helperNames.contains { lines[index].contains($0) } + lines[index].contains(needle) && self.hasOpenBraceScope(lines: lines, from: index, through: targetIndex) } } @@ -110,7 +195,8 @@ struct KeychainPromptSafetyAuditTests { private static func hasOpenBraceScope(lines: [Substring], from startIndex: Int, through endIndex: Int) -> Bool { var balance = 0 var sawOpeningBrace = false - for line in lines[startIndex...endIndex] { + for index in startIndex...endIndex { + let line = lines[index] for character in line { switch character { case "{": @@ -122,6 +208,9 @@ struct KeychainPromptSafetyAuditTests { continue } } + if index < endIndex, sawOpeningBrace, balance <= 0 { + return false + } } return sawOpeningBrace && balance > 0 } From 2fa61d07844c92dcaaf45cdec591841f63632163 Mon Sep 17 00:00:00 2001 From: zhiyue Date: Sat, 4 Jul 2026 17:18:23 +0800 Subject: [PATCH 020/105] Add Kimi monthly subscription usage (#1839) Surface Kimi monthly subscription consumption alongside weekly and five-hour usage while bounding optional membership enrichment. Co-authored-by: zhiyue --- CHANGELOG.md | 1 + .../Providers/Kimi/KimiModels.swift | 11 + .../Providers/Kimi/KimiUsageFetcher.swift | 172 +++++++++++--- .../Providers/Kimi/KimiUsageSnapshot.swift | 39 +++- Tests/CodexBarTests/KimiProviderTests.swift | 210 ++++++++++++++++++ 5 files changed, 399 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c7e3fa95..f1d0978a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.38.1 — Unreleased ### Added +- Kimi: show monthly subscription usage alongside weekly and five-hour limits with a short total budget for the optional membership request. Thanks @zhiyue! - 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. diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift b/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift index f1aa6a7a59..e4cdcc3835 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiModels.swift @@ -9,6 +9,17 @@ struct KimiCodeAPIUsageResponse: Codable { let limits: [KimiRateLimit]? } +struct KimiSubscriptionStatResponse: Codable { + let subscriptionBalance: KimiSubscriptionBalance? +} + +struct KimiSubscriptionBalance: Codable, Sendable { + let feature: String? + let type: String? + let amountUsedRatio: Double? + let expireTime: String? +} + struct KimiUsage: Codable { let scope: String let detail: KimiUsageDetail diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift index 903b475b28..ecfcc2bc41 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift @@ -6,8 +6,11 @@ import FoundationNetworking public struct KimiUsageFetcher: Sendable { private static let log = CodexBarLog.logger(LogCategories.kimiAPI) + private static let subscriptionGraceSeconds: TimeInterval = 2 private static let usageURL = URL(string: "https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages")! + private static let subscriptionStatURL = + URL(string: "https://www.kimi.com/apiv2/kimi.gateway.membership.v2.MembershipService/GetSubscriptionStat")! public static func fetchCodeAPIUsage( apiKey: String, @@ -52,43 +55,97 @@ public struct KimiUsageFetcher: Sendable { } public static func fetchUsage(authToken: String, now: Date = Date()) async throws -> KimiUsageSnapshot { - // Decode JWT to get session info + try await self.fetchUsage( + authToken: authToken, + now: now, + transport: ProviderHTTPClient.shared, + subscriptionGrace: .seconds(self.subscriptionGraceSeconds)) + } + + static func _fetchUsageForTesting( + authToken: String, + now: Date = Date(), + transport: any ProviderHTTPTransport, + subscriptionGrace: Duration) async throws -> KimiUsageSnapshot + { + try await self.fetchUsage( + authToken: authToken, + now: now, + transport: transport, + subscriptionGrace: subscriptionGrace) + } + + private static func fetchUsage( + authToken: String, + now: Date, + transport: any ProviderHTTPTransport, + subscriptionGrace: Duration) async throws -> KimiUsageSnapshot + { let sessionInfo = self.decodeSessionInfo(from: authToken) - var request = URLRequest(url: self.usageURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - request.setValue("kimi-auth=\(authToken)", forHTTPHeaderField: "Cookie") - request.setValue("https://www.kimi.com", forHTTPHeaderField: "Origin") - request.setValue("https://www.kimi.com/code/console", forHTTPHeaderField: "Referer") - request.setValue("*/*", forHTTPHeaderField: "Accept") - request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") - let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" - request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - request.setValue("1", forHTTPHeaderField: "connect-protocol-version") - request.setValue("en-US", forHTTPHeaderField: "x-language") - request.setValue("web", forHTTPHeaderField: "x-msh-platform") - request.setValue(TimeZone.current.identifier, forHTTPHeaderField: "r-timezone") + let subscriptionTask = Task { + try await self.fetchSubscriptionStat( + authToken: authToken, + sessionInfo: sessionInfo, + transport: transport) + } + let subscriptionRace = BoundedTaskJoin(sourceTask: subscriptionTask) + let subscriptionOutcomeTask = Task { + await subscriptionRace.value(joinGrace: subscriptionGrace) + } - // Add session-specific headers from JWT - if let sessionInfo { - if let deviceId = sessionInfo.deviceId { - request.setValue(deviceId, forHTTPHeaderField: "x-msh-device-id") - } - if let sessionId = sessionInfo.sessionId { - request.setValue(sessionId, forHTTPHeaderField: "x-msh-session-id") - } - if let trafficId = sessionInfo.trafficId { - request.setValue(trafficId, forHTTPHeaderField: "x-traffic-id") + let codingUsage: KimiUsage + do { + codingUsage = try await withTaskCancellationHandler { + try await self.fetchRequiredUsage( + authToken: authToken, + sessionInfo: sessionInfo, + transport: transport) + } onCancel: { + subscriptionOutcomeTask.cancel() } + } catch { + subscriptionOutcomeTask.cancel() + _ = await subscriptionOutcomeTask.value + throw error + } + + let subscriptionOutcome = await withTaskCancellationHandler { + await subscriptionOutcomeTask.value + } onCancel: { + subscriptionOutcomeTask.cancel() } + try Task.checkCancellation() + + let subscriptionStat: KimiSubscriptionStatResponse? + switch subscriptionOutcome { + case let .value(response): + subscriptionStat = response + case .timedOut: + Self.log.warning("Kimi subscription stat timed out") + subscriptionStat = nil + case let .failure(error): + Self.log.warning("Kimi subscription stat unavailable: \(error.localizedDescription)") + subscriptionStat = nil + } + + return KimiUsageSnapshot( + weekly: codingUsage.detail, + rateLimit: codingUsage.limits?.first?.detail, + subscriptionBalance: subscriptionStat?.subscriptionBalance, + updatedAt: now) + } + private static func fetchRequiredUsage( + authToken: String, + sessionInfo: SessionInfo?, + transport: any ProviderHTTPTransport) async throws -> KimiUsage + { + var request = self.webRequest(url: self.usageURL, authToken: authToken, sessionInfo: sessionInfo) let requestBody = ["scope": ["FEATURE_CODING"]] request.httpBody = try? JSONSerialization.data(withJSONObject: requestBody) - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await transport.response(for: request) let data = response.data guard response.statusCode == 200 else { let responseBody = String(data: data, encoding: .utf8) ?? "" @@ -111,10 +168,7 @@ public struct KimiUsageFetcher: Sendable { throw KimiAPIError.parseFailed("FEATURE_CODING scope not found in response") } - return KimiUsageSnapshot( - weekly: codingUsage.detail, - rateLimit: codingUsage.limits?.first?.detail, - updatedAt: now) + return codingUsage } private static func parseCodeAPIUsage(from data: Data, now: Date) throws -> KimiUsageSnapshot { @@ -142,6 +196,60 @@ public struct KimiUsageFetcher: Sendable { .appendingPathComponent("usages") } + private static func fetchSubscriptionStat( + authToken: String, + sessionInfo: SessionInfo?, + transport: any ProviderHTTPTransport) async throws -> KimiSubscriptionStatResponse? + { + var request = self.webRequest(url: self.subscriptionStatURL, authToken: authToken, sessionInfo: sessionInfo) + request.httpBody = Data("{}".utf8) + + do { + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + Self.log.warning("Kimi subscription stat returned \(response.statusCode)") + return nil + } + return try JSONDecoder().decode(KimiSubscriptionStatResponse.self, from: response.data) + } catch { + if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled { + throw CancellationError() + } + Self.log.warning("Kimi subscription stat unavailable: \(error.localizedDescription)") + return nil + } + } + + private static func webRequest(url: URL, authToken: String, sessionInfo: SessionInfo?) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("kimi-auth=\(authToken)", forHTTPHeaderField: "Cookie") + request.setValue("https://www.kimi.com", forHTTPHeaderField: "Origin") + request.setValue("https://www.kimi.com/code/console", forHTTPHeaderField: "Referer") + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("1", forHTTPHeaderField: "connect-protocol-version") + request.setValue("en-US", forHTTPHeaderField: "x-language") + request.setValue("web", forHTTPHeaderField: "x-msh-platform") + request.setValue(TimeZone.current.identifier, forHTTPHeaderField: "r-timezone") + + if let deviceId = sessionInfo?.deviceId { + request.setValue(deviceId, forHTTPHeaderField: "x-msh-device-id") + } + if let sessionId = sessionInfo?.sessionId { + request.setValue(sessionId, forHTTPHeaderField: "x-msh-session-id") + } + if let trafficId = sessionInfo?.trafficId { + request.setValue(trafficId, forHTTPHeaderField: "x-traffic-id") + } + return request + } + private static func codeAPIError(statusCode: Int) -> KimiAPIError { switch statusCode { case 400: diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift index d19ff420d1..ddc6b2b76f 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageSnapshot.swift @@ -4,11 +4,25 @@ public struct KimiUsageSnapshot: Sendable { public let weekly: KimiUsageDetail public let rateLimit: KimiUsageDetail? public let updatedAt: Date + let subscriptionBalance: KimiSubscriptionBalance? public init(weekly: KimiUsageDetail, rateLimit: KimiUsageDetail?, updatedAt: Date) { self.weekly = weekly self.rateLimit = rateLimit self.updatedAt = updatedAt + self.subscriptionBalance = nil + } + + init( + weekly: KimiUsageDetail, + rateLimit: KimiUsageDetail?, + subscriptionBalance: KimiSubscriptionBalance?, + updatedAt: Date) + { + self.weekly = weekly + self.rateLimit = rateLimit + self.subscriptionBalance = subscriptionBalance + self.updatedAt = updatedAt } private static func parseDate(_ dateString: String?) -> Date? { @@ -28,6 +42,10 @@ public struct KimiUsageSnapshot: Sendable { let minutes = Int(date.timeIntervalSince(Date()) / 60) return minutes > 0 ? minutes : nil } + + private static func clampedPercent(_ value: Double) -> Double { + min(100, max(0, value)) + } } extension KimiUsageSnapshot { @@ -40,7 +58,7 @@ extension KimiUsageSnapshot { return max(0, weeklyLimit - remaining) }() - let weeklyPercent = weeklyLimit > 0 ? Double(weeklyUsed) / Double(weeklyLimit) * 100 : 0 + let weeklyPercent = weeklyLimit > 0 ? Self.clampedPercent(Double(weeklyUsed) / Double(weeklyLimit) * 100) : 0 let weeklyWindow = RateWindow( usedPercent: weeklyPercent, @@ -57,7 +75,9 @@ extension KimiUsageSnapshot { guard let remaining = rateRemaining else { return 0 } return max(0, rateLimitValue - remaining) }() - let ratePercent = rateLimitValue > 0 ? Double(rateUsed) / Double(rateLimitValue) * 100 : 0 + let ratePercent = rateLimitValue > 0 + ? Self.clampedPercent(Double(rateUsed) / Double(rateLimitValue) * 100) + : 0 rateLimitWindow = RateWindow( usedPercent: ratePercent, @@ -66,6 +86,20 @@ extension KimiUsageSnapshot { resetDescription: "Rate: \(rateUsed)/\(rateLimitValue) per 5 hours") } + let monthlyWindow = self.subscriptionBalance.flatMap { balance -> NamedRateWindow? in + // Monthly = shared subscription pool (`amountUsedRatio`), not the Code-only `kimiCodeUsedRatio`: + // the pool is shared across features, so amountUsedRatio is the real "subscription remaining". + guard balance.feature == nil || balance.feature == "FEATURE_OMNI" else { return nil } + guard balance.type == nil || balance.type == "SUBSCRIPTION" else { return nil } + guard let ratio = balance.amountUsedRatio else { return nil } + let window = RateWindow( + usedPercent: Self.clampedPercent(ratio * 100), + windowMinutes: nil, + resetsAt: Self.parseDate(balance.expireTime), + resetDescription: nil) + return NamedRateWindow(id: "kimi-monthly", title: "Monthly", window: window) + } + let identity = ProviderIdentitySnapshot( providerID: .kimi, accountEmail: nil, @@ -76,6 +110,7 @@ extension KimiUsageSnapshot { primary: weeklyWindow, secondary: rateLimitWindow, tertiary: nil, + extraRateWindows: monthlyWindow.map { [$0] }, providerCost: nil, updatedAt: self.updatedAt, identity: identity) diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift index 2d2bd20014..839139aca7 100644 --- a/Tests/CodexBarTests/KimiProviderTests.swift +++ b/Tests/CodexBarTests/KimiProviderTests.swift @@ -364,6 +364,154 @@ struct KimiUsageResponseParsingTests { #expect(snapshot.rateLimit?.resetTime == "2026-01-06T13:33:02Z") } + @Test + func `parses subscription stat response`() throws { + let json = """ + { + "ratelimitCode5h": { + "ratio": 0.4689, + "enabled": true, + "resetTime": "2026-07-02T11:56:36.876796734Z" + }, + "ratelimitCode7d": { + "ratio": 0.0946, + "enabled": true, + "resetTime": "2026-07-09T06:56:36.876796734Z" + }, + "subscriptionBalance": { + "id": "19eee1de-9092-8315-8000-0000e4e34d79", + "feature": "FEATURE_OMNI", + "type": "SUBSCRIPTION", + "unit": "UNIT_CREDIT", + "amountUsedRatio": 1, + "kimiCodeUsedRatio": 0.2854, + "expireTime": "2026-07-23T00:00:00Z" + }, + "giftBalances": [ + { + "id": "19efdb95-e082-804c-9ecd-978b7ab37d36", + "feature": "FEATURE_OMNI", + "type": "GIFT", + "unit": "UNIT_CREDIT", + "amountUsedRatio": 1, + "kimiCodeUsedRatio": 1, + "expireTime": "2026-07-31T15:59:59Z" + } + ] + } + """ + + let response = try JSONDecoder().decode(KimiSubscriptionStatResponse.self, from: Data(json.utf8)) + + #expect(response.subscriptionBalance?.feature == "FEATURE_OMNI") + #expect(response.subscriptionBalance?.type == "SUBSCRIPTION") + #expect(response.subscriptionBalance?.amountUsedRatio == 1) + #expect(response.subscriptionBalance?.expireTime == "2026-07-23T00:00:00Z") + } + + @Test + func `subscription grace is a total budget for existing usage windows`() async throws { + let usageJSON = """ + { + "usages": [ + { + "scope": "FEATURE_CODING", + "detail": { "limit": "100", "used": "25", "remaining": "75" }, + "limits": [ + { + "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }, + "detail": { "limit": "20", "used": "5", "remaining": "15" } + } + ] + } + ] + } + """ + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + if url.path.hasSuffix("/GetUsages") { + return await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + continuation.resume(returning: (Data(usageJSON.utf8), response)) + } + } + } + + return await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: (Data("{}".utf8), response)) + } + } + } + + let startedAt = ContinuousClock.now + let snapshot = try await KimiUsageFetcher._fetchUsageForTesting( + authToken: "test-token", + transport: transport, + subscriptionGrace: .milliseconds(20)) + let elapsed = startedAt.duration(to: .now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.secondary?.usedPercent == 25) + #expect(usage.extraRateWindows == nil) + #expect(elapsed < .milliseconds(250), "Subscription enrichment outlived its total budget: \(elapsed)") + + // Drain the deliberately cancellation-ignoring test request before the test exits. + try await Task.sleep(for: .milliseconds(550)) + } + + @Test + func `subscription stat enriches usage when it finishes within the total budget`() async throws { + let usageJSON = """ + { + "usages": [ + { + "scope": "FEATURE_CODING", + "detail": { "limit": "100", "used": "25", "remaining": "75" }, + "limits": [] + } + ] + } + """ + let subscriptionJSON = """ + { + "subscriptionBalance": { + "feature": "FEATURE_OMNI", + "type": "SUBSCRIPTION", + "amountUsedRatio": 0.42, + "expireTime": "2026-07-23T00:00:00Z" + } + } + """ + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + if url.path.hasSuffix("/GetUsages") { + return (Data(usageJSON.utf8), response) + } + return (Data(subscriptionJSON.utf8), response) + } + + let snapshot = try await KimiUsageFetcher._fetchUsageForTesting( + authToken: "test-token", + transport: transport, + subscriptionGrace: .seconds(1)) + let monthly = try #require(snapshot.toUsageSnapshot().extraRateWindows?.first) + + #expect(monthly.id == "kimi-monthly") + #expect(monthly.window.usedPercent == 42) + } + @Test func `builds default code API usage endpoint`() throws { let baseURL = try #require(URL(string: "https://api.kimi.com")) @@ -494,6 +642,62 @@ struct KimiUsageSnapshotConversionTests { #expect(usageSnapshot.updatedAt == now) } + @Test + func `converts subscription balance to monthly extra window`() throws { + let now = Date() + let weeklyDetail = KimiUsageDetail( + limit: "2048", + used: "375", + remaining: "1673", + resetTime: "2026-01-09T15:23:13.373329235Z") + let subscriptionBalance = KimiSubscriptionBalance( + feature: "FEATURE_OMNI", + type: "SUBSCRIPTION", + amountUsedRatio: 1, + expireTime: "2026-07-23T00:00:00Z") + + let snapshot = KimiUsageSnapshot( + weekly: weeklyDetail, + rateLimit: nil, + subscriptionBalance: subscriptionBalance, + updatedAt: now) + let usageSnapshot = snapshot.toUsageSnapshot() + + let monthly = try #require(usageSnapshot.extraRateWindows?.first) + #expect(monthly.id == "kimi-monthly") + #expect(monthly.title == "Monthly") + #expect(monthly.window.usedPercent == 100) + #expect(monthly.window.resetsAt == Self.date("2026-07-23T00:00:00Z")) + } + + @Test + func `reflects partial subscription usage in monthly window`() throws { + let now = Date() + let weeklyDetail = KimiUsageDetail( + limit: "2048", + used: "375", + remaining: "1673", + resetTime: "2026-01-09T15:23:13.373329235Z") + // A live, partially-used balance (not the fully-exhausted 1.0 fixture): amountUsedRatio is a + // real consumption ratio, so the Monthly window must track it rather than pin to 100%. + let subscriptionBalance = KimiSubscriptionBalance( + feature: "FEATURE_OMNI", + type: "SUBSCRIPTION", + amountUsedRatio: 0.7716, + expireTime: "2026-07-23T00:00:00Z") + + let snapshot = KimiUsageSnapshot( + weekly: weeklyDetail, + rateLimit: nil, + subscriptionBalance: subscriptionBalance, + updatedAt: now) + let usageSnapshot = snapshot.toUsageSnapshot() + + let monthly = try #require(usageSnapshot.extraRateWindows?.first) + #expect(monthly.id == "kimi-monthly") + #expect(abs(monthly.window.usedPercent - 77.16) < 0.0001) + } + @Test func `converts to usage snapshot without rate limit`() { let now = Date() @@ -552,6 +756,12 @@ struct KimiUsageSnapshotConversionTests { let usageSnapshot = snapshot.toUsageSnapshot() #expect(usageSnapshot.primary?.usedPercent == 100.0) } + + private static func date(_ text: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: text) + } } struct KimiTokenResolverTests { From f84a171bca1b3fd1f0beef9813fdb46401abc0a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 10:21:13 +0100 Subject: [PATCH 021/105] docs: update appcast for 0.38.1 --- appcast.xml | 66 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/appcast.xml b/appcast.xml index 5f4a3a3ccd..f1543b0880 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,50 @@ CodexBar + + 0.38.1 + Sat, 04 Jul 2026 10:21:12 +0100 + https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml + 95 + 0.38.1 + 14.0 + CodexBar 0.38.1

    +

    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!
    • +
    +

    Changed

    +
      +
    • Branding: replace the app and website icon with a usage-meter prompt mark that matches CodexBar's core UI.
    • +
    • Website: redesign codexbar.app around faster download, provider discovery, feature, CLI, and widget paths with responsive dark/light and localized layouts. Thanks @vyctorbrzezowski!
    • +
    • Architecture: accept a bounded opt-in adaptive refresh design with a deterministic 2–30-minute cadence and no behavioral telemetry. Thanks @hhh2210!
    • +
    • Architecture: define the security and identity boundaries required before custom HTTP JSON providers can be implemented safely.
    • +
    • Claude: accept a display-only multi-account design based on read-only claude-swap --list --json, without account switching or credential storage.
    • +
    • Notifications: accept a default-off predictive pace warning design that alerts once per risk episode and re-arms only after authoritative recovery.
    • +
    • OpenCode Go: accept bounded automatic multi-workspace fan-out while preserving the configured workspace as an exact single-workspace override.
    • +
    • 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!
    • +
    +

    View full changelog

    +]]> + + 0.38.0 Fri, 03 Jul 2026 04:18:13 -0700 @@ -102,28 +146,6 @@ ]]> - - 0.37.1 - Mon, 22 Jun 2026 00:16:24 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 91 - 0.37.1 - 14.0 - CodexBar 0.37.1