diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..2886124
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,37 @@
+---
+version: 2
+
+updates:
+ - package-ecosystem: swift
+ directory: "/"
+ open-pull-requests-limit: 2
+ schedule:
+ interval: weekly
+ labels:
+ - dependencies
+ - swift
+ groups:
+ all-version-updates:
+ patterns:
+ - "*"
+ all-security-updates:
+ applies-to: security-updates
+ patterns:
+ - "*"
+
+ - package-ecosystem: github-actions
+ directory: "/"
+ open-pull-requests-limit: 2
+ schedule:
+ interval: weekly
+ labels:
+ - dependencies
+ - github-actions
+ groups:
+ all-version-updates:
+ patterns:
+ - "*"
+ all-security-updates:
+ applies-to: security-updates
+ patterns:
+ - "*"
diff --git a/.github/github-repo-workflow.json b/.github/github-repo-workflow.json
new file mode 100644
index 0000000..e647b9c
--- /dev/null
+++ b/.github/github-repo-workflow.json
@@ -0,0 +1,68 @@
+{
+ "defaultBranch": "main",
+ "projectType": "native-macos-app",
+ "docs": {
+ "agentNotes": "AGENTS.md",
+ "index": "docs/README.md",
+ "productGoals": "docs/product-goals.md",
+ "architecture": "docs/architecture.md",
+ "designDirection": "docs/design-direction.md",
+ "localLimitProbe": "docs/local-limit-probe.md",
+ "providerUsageAccess": "docs/provider-usage-access.md",
+ "repoSettings": "docs/repo-settings.md"
+ },
+ "qualityGate": {
+ "build": {
+ "default": "swift build"
+ },
+ "test": {
+ "default": "swift test"
+ },
+ "validate": {
+ "default": "scripts/commit-gate.sh"
+ },
+ "docsRequiredWhen": [
+ "provider behavior changes",
+ "provider account model changes",
+ "credential storage changes",
+ "user-visible UI changes",
+ "widget layout changes",
+ "release or signing changes"
+ ]
+ },
+ "importantWorkflows": ["CI", "CodeQL"],
+ "qaLabels": [],
+ "deployLabels": [],
+ "healthUrls": [],
+ "validatedThrough": [],
+ "githubSignals": {
+ "postMerge": {
+ "waitForActions": true,
+ "checkSecurityAndQuality": true
+ },
+ "capabilities": {
+ "codeScanning": "available",
+ "secretScanning": "available",
+ "dependabotAlerts": "available",
+ "securityAdvisories": "available"
+ },
+ "refreshWhen": [
+ "repo visibility changes",
+ "GitHub plan changes",
+ "security settings change",
+ "token permissions change"
+ ]
+ },
+ "cleanup": {
+ "deleteMergedLocalBranches": true,
+ "removeMergedCleanWorktrees": true
+ },
+ "metadataFreshness": {
+ "updateWhen": [
+ "validation gates change",
+ "important workflows change",
+ "repo relationship changes",
+ "ownership boundaries change"
+ ]
+ }
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e1e36fd
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+---
+name: CI
+
+"on":
+ pull_request:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ swift:
+ runs-on: macos-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Select Xcode
+ run: sudo xcode-select -s /Applications/Xcode.app
+
+ - name: Build
+ run: swift build
+
+ - name: Test
+ run: swift test
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..68b5a4f
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,37 @@
+---
+name: CodeQL
+
+"on":
+ pull_request:
+ push:
+ branches:
+ - main
+ schedule:
+ - cron: "31 9 * * 1"
+ workflow_dispatch:
+
+permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+jobs:
+ analyze:
+ name: Analyze Swift
+ runs-on: macos-latest
+ timeout-minutes: 360
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: swift
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v4
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ccb9d4a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+.DS_Store
+.build/
+DerivedData/
+*.xcuserdata/
+*.xcuserstate
+*.xcodeproj/project.xcworkspace/xcuserdata/
+*.xcodeproj/xcuserdata/
+*.xcworkspace/xcuserdata/
+.swiftpm/
+.idea/
+dist/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..e02c37c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,59 @@
+# Context Panel Agent Notes
+
+## Product Shape
+
+Context Panel is a native macOS app plus WidgetKit extension for tracking AI
+usage limits across providers. Treat multi-account support as a core product
+requirement, not a later enhancement. The initial provider set is OpenAI,
+Anthropic, and Google.
+
+The widget is for glanceable state: remaining capacity, limit pressure, reset
+time, and whether refreshes are healthy. The app is for setup and detail:
+multiple logins, credential management, provider diagnostics, detailed charts,
+history, and settings.
+
+## Engineering Defaults
+
+- Keep the repo native-first. Do not add web or Docker surfaces unless Chris
+ explicitly asks for them.
+- Prefer Swift, SwiftUI, WidgetKit, and Swift Package Manager.
+- Keep provider-specific behavior behind adapters. Shared UI and storage should
+ consume normalized provider/account/limit snapshots.
+- Keep credentials local. Favor Keychain-backed storage for secrets and avoid
+ logging tokens, account identifiers, or raw provider responses that may expose
+ sensitive data.
+- Model unknown and degraded states explicitly. Provider APIs and unofficial
+ usage surfaces can change; the UI should make stale or partial data obvious.
+- Design for multiple accounts per provider from the data model up through the
+ widget layout.
+
+## UX Direction
+
+- The widget should be beautiful, dense, and calm: compact charts, rings, bars,
+ sparklines, reset countdowns, and provider/account grouping where useful.
+- For weekly or rolling account limits, include forecast language that helps the
+ user decide whether higher-burn modes are safe before reset.
+- Avoid making the widget a dashboard crammed into a rectangle. Put setup,
+ troubleshooting, raw details, and long histories in the app.
+- Clicking the widget should open the app to the most relevant provider/account
+ detail.
+- Prefer plain status language: available, close to limit, limited, unknown,
+ stale, refreshing.
+
+## Validation
+
+Run the commit gate before publishing changes:
+
+```sh
+scripts/commit-gate.sh
+```
+
+For UI work, also run the native app/widget locally and inspect the actual macOS
+presentation before calling the work ready.
+
+## Repo Workflow
+
+- Default branch: `main`.
+- Work on focused branches and open pull requests.
+- Keep `.github/github-repo-workflow.json` current when docs, validation gates,
+ important workflows, or repo ownership assumptions change.
diff --git a/Config/ContextPanel.entitlements b/Config/ContextPanel.entitlements
new file mode 100644
index 0000000..5816106
--- /dev/null
+++ b/Config/ContextPanel.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ group.com.shinycomputers.contextpanel
+
+
+
diff --git a/Config/ContextPanelWidget-Info.plist b/Config/ContextPanelWidget-Info.plist
new file mode 100644
index 0000000..6ef3ac5
--- /dev/null
+++ b/Config/ContextPanelWidget-Info.plist
@@ -0,0 +1,27 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/Config/ContextPanelWidget.entitlements b/Config/ContextPanelWidget.entitlements
new file mode 100644
index 0000000..7320a3d
--- /dev/null
+++ b/Config/ContextPanelWidget.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ group.com.shinycomputers.contextpanel
+
+
+
diff --git a/ContextPanel.xcodeproj/project.pbxproj b/ContextPanel.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..459cb4a
--- /dev/null
+++ b/ContextPanel.xcodeproj/project.pbxproj
@@ -0,0 +1,651 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 0BBD1DF433F345E62C6A26EF /* LimitProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */; };
+ 10A2940E7E3FE6E253ECFEDE /* GeminiCodeAssistQuota.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */; };
+ 1DD89F5634070B75C09BDE99 /* ClaudeLocalStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */; };
+ 36A035769881D4A450BA3BD8 /* libContextPanelCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */; };
+ 38E9E0A22491522FF4E0A8F4 /* ContextPanelPreviewApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */; };
+ 459C8EC82194CE1A950FE1B5 /* UsageLimit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D459054C90410A784EBD308B /* UsageLimit.swift */; };
+ 47414B1A42BE473BF1644296 /* CodexRateLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */; };
+ 64BA3F57751ADAB181558D58 /* SampleUsageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */; };
+ 85EBA57F3F1633F2D6E54103 /* ContextPanelWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 9C74713AEFFAC6D85E7ACF3F /* FastModeForecast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */; };
+ B6C35EBBE9327327BDCA324F /* SnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D8515D047F5A8117DB762C /* SnapshotStore.swift */; };
+ B83302BDC1EAC3EE45DBE8B8 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */; };
+ BE7F8E276624C8E6D93BC329 /* libContextPanelCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */; };
+ BF04DB8945D058C7A63BEAB3 /* ClaudeWebUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */; };
+ C1DF7B59257060AC0A6AF44D /* ContextPanelLocations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */; };
+ C6CA96F5E75E32FCCD329736 /* ContextPanelWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */; };
+ E77C3B3D8985239A004E8CDC /* AccountConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */; };
+ F4B9B4FB75A285E38AE52B1D /* ProviderConnector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 350ED5589FB23C42ED32704A /* ProviderConnector.swift */; };
+ FE093A1996BA546A10A800DA /* ContextPanelWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 32954FE71C65F1FD1E590272 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = EE4B8035BB7A9BD03B668BB8;
+ remoteInfo = ContextPanelCore;
+ };
+ 3C3392E33451B979623C2996 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 92EF616FEBA46FCB02E887BE;
+ remoteInfo = ContextPanelWidgetExtension;
+ };
+ A81BFB53BF2A07F0375FCF7E /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = EE4B8035BB7A9BD03B668BB8;
+ remoteInfo = ContextPanelCore;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 09DAA1F320B84A007C14B253 /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 85EBA57F3F1633F2D6E54103 /* ContextPanelWidgetExtension.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ContextPanelWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiCodeAssistQuota.swift; sourceTree = ""; };
+ 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfigurationStore.swift; sourceTree = ""; };
+ 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleUsageData.swift; sourceTree = ""; };
+ 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelLocations.swift; sourceTree = ""; };
+ 32D8515D047F5A8117DB762C /* SnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotStore.swift; sourceTree = ""; };
+ 350ED5589FB23C42ED32704A /* ProviderConnector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderConnector.swift; sourceTree = ""; };
+ 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelWidgetViews.swift; sourceTree = ""; };
+ 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; };
+ 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeWebUsage.swift; sourceTree = ""; };
+ 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimitProbe.swift; sourceTree = ""; };
+ 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastModeForecast.swift; sourceTree = ""; };
+ 8D6350007C73BEF27D871AB0 /* ContextPanel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ContextPanel.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexRateLimits.swift; sourceTree = ""; };
+ A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelPreviewApp.swift; sourceTree = ""; };
+ D459054C90410A784EBD308B /* UsageLimit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageLimit.swift; sourceTree = ""; };
+ E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelWidget.swift; sourceTree = ""; };
+ E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeLocalStatus.swift; sourceTree = ""; };
+ F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libContextPanelCore.a; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 0A536561059AF7F95E2D3FD7 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 36A035769881D4A450BA3BD8 /* libContextPanelCore.a in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7BFE158EBBBAE213DE50F249 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ BE7F8E276624C8E6D93BC329 /* libContextPanelCore.a in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 45E9D2C527D1EF15E9811358 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 8D6350007C73BEF27D871AB0 /* ContextPanel.app */,
+ 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */,
+ F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 537271134D87C7167DEBA0C1 /* ContextPanelCore */ = {
+ isa = PBXGroup;
+ children = (
+ 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */,
+ E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */,
+ 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */,
+ 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */,
+ 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */,
+ 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */,
+ 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */,
+ 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */,
+ 350ED5589FB23C42ED32704A /* ProviderConnector.swift */,
+ 32D8515D047F5A8117DB762C /* SnapshotStore.swift */,
+ D459054C90410A784EBD308B /* UsageLimit.swift */,
+ 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */,
+ );
+ name = ContextPanelCore;
+ path = Sources/ContextPanelCore;
+ sourceTree = "";
+ };
+ 77BADA15D2FF6ABB6F666F43 = {
+ isa = PBXGroup;
+ children = (
+ 537271134D87C7167DEBA0C1 /* ContextPanelCore */,
+ F71E88199DC89A225C8F07FC /* ContextPanelPreview */,
+ A538E84B22D9D75DE0FD30D4 /* ContextPanelWidget */,
+ 45E9D2C527D1EF15E9811358 /* Products */,
+ );
+ sourceTree = "";
+ };
+ A538E84B22D9D75DE0FD30D4 /* ContextPanelWidget */ = {
+ isa = PBXGroup;
+ children = (
+ E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */,
+ 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */,
+ );
+ name = ContextPanelWidget;
+ path = Sources/ContextPanelWidget;
+ sourceTree = "";
+ };
+ F71E88199DC89A225C8F07FC /* ContextPanelPreview */ = {
+ isa = PBXGroup;
+ children = (
+ A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */,
+ 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */,
+ );
+ name = ContextPanelPreview;
+ path = Sources/ContextPanelPreview;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 6CB2601991081CFCCB5C6E04 /* Build configuration list for PBXNativeTarget "ContextPanelWidgetExtension" */;
+ buildPhases = (
+ 255CF9DC3E13CC5E897143A3 /* Sources */,
+ 7BFE158EBBBAE213DE50F249 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 8361FD30418655D673073A5A /* PBXTargetDependency */,
+ );
+ name = ContextPanelWidgetExtension;
+ packageProductDependencies = (
+ );
+ productName = ContextPanelWidgetExtension;
+ productReference = 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+ EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 9CBD94C5FBD8FC3E4201DB61 /* Build configuration list for PBXNativeTarget "ContextPanelCore" */;
+ buildPhases = (
+ 5CBBB511F795B6E330EACF99 /* Sources */,
+ A6FD12E22FB2385AD98885D1 /* Copy Swift Objective-C Interface Header */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = ContextPanelCore;
+ packageProductDependencies = (
+ );
+ productName = ContextPanelCore;
+ productReference = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */;
+ productType = "com.apple.product-type.library.static";
+ };
+ FF80548BA14604BB9B1E9115 /* ContextPanel */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 54E2B892F7409618FAE8F040 /* Build configuration list for PBXNativeTarget "ContextPanel" */;
+ buildPhases = (
+ 8BE7305E45C25AEF658A9E47 /* Sources */,
+ 0A536561059AF7F95E2D3FD7 /* Frameworks */,
+ 09DAA1F320B84A007C14B253 /* Embed Foundation Extensions */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ D29DE5B6DB7D5B324359BCA0 /* PBXTargetDependency */,
+ 4A468DA4B7F0B61AAE457FF9 /* PBXTargetDependency */,
+ );
+ name = ContextPanel;
+ packageProductDependencies = (
+ );
+ productName = ContextPanel;
+ productReference = 8D6350007C73BEF27D871AB0 /* ContextPanel.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 194387DBD3D0F10E17F284F9 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1430;
+ TargetAttributes = {
+ 92EF616FEBA46FCB02E887BE = {
+ DevelopmentTeam = MM5YXC7T6E;
+ ProvisioningStyle = Automatic;
+ };
+ EE4B8035BB7A9BD03B668BB8 = {
+ DevelopmentTeam = MM5YXC7T6E;
+ ProvisioningStyle = Automatic;
+ };
+ FF80548BA14604BB9B1E9115 = {
+ DevelopmentTeam = MM5YXC7T6E;
+ ProvisioningStyle = Automatic;
+ };
+ };
+ };
+ buildConfigurationList = 9B6F6DC10F1BF3A02285845F /* Build configuration list for PBXProject "ContextPanel" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ Base,
+ en,
+ );
+ mainGroup = 77BADA15D2FF6ABB6F666F43;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 45E9D2C527D1EF15E9811358 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ FF80548BA14604BB9B1E9115 /* ContextPanel */,
+ EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */,
+ 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ A6FD12E22FB2385AD98885D1 /* Copy Swift Objective-C Interface Header */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "$(DERIVED_SOURCES_DIR)/$(SWIFT_OBJC_INTERFACE_HEADER_NAME)",
+ );
+ name = "Copy Swift Objective-C Interface Header";
+ outputPaths = (
+ "$(BUILT_PRODUCTS_DIR)/include/$(PRODUCT_MODULE_NAME)/$(SWIFT_OBJC_INTERFACE_HEADER_NAME)",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "ditto \"${SCRIPT_INPUT_FILE_0}\" \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 255CF9DC3E13CC5E897143A3 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ FE093A1996BA546A10A800DA /* ContextPanelWidget.swift in Sources */,
+ C6CA96F5E75E32FCCD329736 /* ContextPanelWidgetViews.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 5CBBB511F795B6E330EACF99 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ E77C3B3D8985239A004E8CDC /* AccountConfigurationStore.swift in Sources */,
+ 1DD89F5634070B75C09BDE99 /* ClaudeLocalStatus.swift in Sources */,
+ BF04DB8945D058C7A63BEAB3 /* ClaudeWebUsage.swift in Sources */,
+ 47414B1A42BE473BF1644296 /* CodexRateLimits.swift in Sources */,
+ C1DF7B59257060AC0A6AF44D /* ContextPanelLocations.swift in Sources */,
+ 9C74713AEFFAC6D85E7ACF3F /* FastModeForecast.swift in Sources */,
+ 10A2940E7E3FE6E253ECFEDE /* GeminiCodeAssistQuota.swift in Sources */,
+ 0BBD1DF433F345E62C6A26EF /* LimitProbe.swift in Sources */,
+ F4B9B4FB75A285E38AE52B1D /* ProviderConnector.swift in Sources */,
+ B6C35EBBE9327327BDCA324F /* SnapshotStore.swift in Sources */,
+ 459C8EC82194CE1A950FE1B5 /* UsageLimit.swift in Sources */,
+ B83302BDC1EAC3EE45DBE8B8 /* WidgetSnapshot.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 8BE7305E45C25AEF658A9E47 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 38E9E0A22491522FF4E0A8F4 /* ContextPanelPreviewApp.swift in Sources */,
+ 64BA3F57751ADAB181558D58 /* SampleUsageData.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 4A468DA4B7F0B61AAE457FF9 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */;
+ targetProxy = 3C3392E33451B979623C2996 /* PBXContainerItemProxy */;
+ };
+ 8361FD30418655D673073A5A /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */;
+ targetProxy = 32954FE71C65F1FD1E590272 /* PBXContainerItemProxy */;
+ };
+ D29DE5B6DB7D5B324359BCA0 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */;
+ targetProxy = A81BFB53BF2A07F0375FCF7E /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ 04634A97AA8175852ED3CA9D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = Config/ContextPanel.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_CFBundleDisplayName = "Context Panel";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel;
+ PRODUCT_NAME = "Context Panel";
+ SDKROOT = macosx;
+ };
+ name = Release;
+ };
+ 58ADB2EF94022C1803CA1F30 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ COMBINE_HIDPI_IMAGES = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.ContextPanelCore;
+ PRODUCT_NAME = ContextPanelCore;
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ };
+ name = Debug;
+ };
+ 7FFB726DFDD92831614DCE30 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = Config/ContextPanel.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_CFBundleDisplayName = "Context Panel";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel;
+ PRODUCT_NAME = "Context Panel";
+ SDKROOT = macosx;
+ };
+ name = Debug;
+ };
+ 8A5FA37DF23FC4E3C4201B12 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ COMBINE_HIDPI_IMAGES = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.ContextPanelCore;
+ PRODUCT_NAME = ContextPanelCore;
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ };
+ name = Release;
+ };
+ 8FBB69A1251FDC3C8BC250F6 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = Config/ContextPanelWidget.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = "Config/ContextPanelWidget-Info.plist";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel.widget;
+ PRODUCT_NAME = ContextPanelWidgetExtension;
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ };
+ name = Debug;
+ };
+ AF229A6A5953793A6011AFFA /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = MM5YXC7T6E;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 6.0;
+ };
+ name = Debug;
+ };
+ C37F715DCB05E50AB0896150 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = Config/ContextPanelWidget.entitlements;
+ COMBINE_HIDPI_IMAGES = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = "Config/ContextPanelWidget-Info.plist";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel.widget;
+ PRODUCT_NAME = ContextPanelWidgetExtension;
+ SDKROOT = macosx;
+ SKIP_INSTALL = YES;
+ };
+ name = Release;
+ };
+ D8D2A31D229BB9A52739BE36 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = MM5YXC7T6E;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_VERSION = 6.0;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 54E2B892F7409618FAE8F040 /* Build configuration list for PBXNativeTarget "ContextPanel" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7FFB726DFDD92831614DCE30 /* Debug */,
+ 04634A97AA8175852ED3CA9D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 6CB2601991081CFCCB5C6E04 /* Build configuration list for PBXNativeTarget "ContextPanelWidgetExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 8FBB69A1251FDC3C8BC250F6 /* Debug */,
+ C37F715DCB05E50AB0896150 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 9B6F6DC10F1BF3A02285845F /* Build configuration list for PBXProject "ContextPanel" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AF229A6A5953793A6011AFFA /* Debug */,
+ D8D2A31D229BB9A52739BE36 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ 9CBD94C5FBD8FC3E4201DB61 /* Build configuration list for PBXNativeTarget "ContextPanelCore" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 58ADB2EF94022C1803CA1F30 /* Debug */,
+ 8A5FA37DF23FC4E3C4201B12 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 194387DBD3D0F10E17F284F9 /* Project object */;
+}
diff --git a/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme b/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme
new file mode 100644
index 0000000..1d06c79
--- /dev/null
+++ b/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..38c6cff
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,87 @@
+// swift-tools-version: 6.0
+
+import PackageDescription
+
+let package = Package(
+ name: "ContextPanel",
+ platforms: [
+ .macOS(.v14)
+ ],
+ products: [
+ .library(
+ name: "ContextPanelCore",
+ targets: ["ContextPanelCore"]
+ ),
+ .executable(
+ name: "ContextPanelPreview",
+ targets: ["ContextPanelPreview"]
+ ),
+ .executable(
+ name: "OpenAILimitProbe",
+ targets: ["OpenAILimitProbe"]
+ ),
+ .executable(
+ name: "CodexRateLimitProbe",
+ targets: ["CodexRateLimitProbe"]
+ ),
+ .executable(
+ name: "GeminiQuotaProbe",
+ targets: ["GeminiQuotaProbe"]
+ ),
+ .executable(
+ name: "ClaudeLimitProbe",
+ targets: ["ClaudeLimitProbe"]
+ ),
+ .executable(
+ name: "ClaudeWebUsageProbe",
+ targets: ["ClaudeWebUsageProbe"]
+ ),
+ .executable(
+ name: "SnapshotStoreProbe",
+ targets: ["SnapshotStoreProbe"]
+ ),
+ .executable(
+ name: "ContextPanelWidget",
+ targets: ["ContextPanelWidget"]
+ )
+ ],
+ targets: [
+ .target(name: "ContextPanelCore"),
+ .executableTarget(
+ name: "ContextPanelPreview",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "OpenAILimitProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "CodexRateLimitProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "GeminiQuotaProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "ClaudeLimitProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "ClaudeWebUsageProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "SnapshotStoreProbe",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .executableTarget(
+ name: "ContextPanelWidget",
+ dependencies: ["ContextPanelCore"]
+ ),
+ .testTarget(
+ name: "ContextPanelCoreTests",
+ dependencies: ["ContextPanelCore"]
+ )
+ ]
+)
diff --git a/README.md b/README.md
index b6c1876..e1dabde 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,131 @@
-# context-panel
-macOS widget for tracking AI usage limits across AI providers
+# Context Panel
+
+Context Panel is a planned macOS app and widget for seeing AI usage limits
+across providers at a glance. The first target providers are OpenAI, Anthropic,
+and Google.
+
+The product goal is a small, native Mac utility that can answer the everyday
+question before you prompt: which accounts and models are still available,
+which limits are close, and when each allowance resets.
+
+## Current Status
+
+This repository is a native starter shell. It currently includes a Swift package
+for shared provider and usage-limit domain types, CI, Dependabot, and repository
+workflow metadata. The macOS app and widget targets will be added once the first
+data and UI contracts settle.
+
+## Product Direction
+
+- Native macOS first, with WidgetKit as the primary glanceable surface.
+- A companion app for account setup, provider connection health, and deeper
+ usage detail.
+- Multiple logins per provider, because friends, work accounts, personal
+ accounts, and team accounts all need to coexist.
+- Provider-neutral usage state for OpenAI, Anthropic, Google, and later services.
+- Local-first handling of account credentials and usage snapshots.
+- Beautiful compact charts and state widgets that emphasize remaining capacity,
+ reset time, and trend instead of billing-dashboard noise.
+- Small enough to share with friends without setup becoming a project.
+
+## Planned Experience
+
+The widget should be useful at a glance: provider/account rows, remaining usage,
+reset timing, and compact visual indicators such as rings, bars, sparklines, or
+small multiples when they make the state easier to read.
+
+Clicking the widget should open the native app. The app is the place for account
+setup, provider-specific status, refresh history, raw limit details, charts over
+time, and troubleshooting when a provider changes behavior.
+
+## Local Setup
+
+```sh
+swift build
+swift test
+scripts/commit-gate.sh
+```
+
+Useful entry points:
+
+- [Product Goals](docs/product-goals.md)
+- [Architecture](docs/architecture.md)
+- [macOS Release Path](docs/release.md)
+- [Repository Settings](docs/repo-settings.md)
+
+## Local App Bundle
+
+To build the native macOS app with the embedded WidgetKit extension:
+
+```sh
+xcodegen generate --spec project.yml
+xcodebuild \
+ -project ContextPanel.xcodeproj \
+ -scheme ContextPanel \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ -allowProvisioningUpdates \
+ build
+```
+
+To build a quick launchable macOS app bundle from the SwiftPM app shell:
+
+```sh
+scripts/package-macos-app.sh --output dist --identity auto
+open "dist/Context Panel.app"
+```
+
+When a Developer ID Application identity is available in Keychain, the script
+uses it through `codesign`; otherwise it falls back to ad-hoc signing. This is
+the interim friend-installable path for the app shell only; use the Xcode build
+when testing the widget extension.
+
+## Local Provider Probes
+
+The package includes development probes for validating provider limit signals
+without printing secrets or raw provider responses:
+
+```sh
+swift run CodexRateLimitProbe --auth ~/.codex/auth.json
+GEMINI_OAUTH_CLIENT_ID=... GEMINI_OAUTH_CLIENT_SECRET=... \
+ swift run GeminiQuotaProbe --auth ~/.gemini/oauth_creds.json
+swift run ClaudeLimitProbe
+swift run SnapshotStoreProbe --codex-auth ~/.codex/auth.json --include-claude
+```
+
+The Codex and Gemini probes can return live percent-window quota buckets for
+their respective CLI-backed accounts. The Claude probe intentionally reports
+local auth/subscription metadata and local stats-cache freshness until a Claude
+Code status-line cache has been populated.
+
+To capture Claude subscription usage percentages, configure Claude Code's
+status line to call the helper in this repo. Claude Code sends the helper a JSON
+payload after session responses; the helper stores only five-hour and weekly
+used percentages plus reset timestamps under Context Panel's Application
+Support directory.
+
+```json
+{
+ "statusLine": {
+ "type": "command",
+ "command": "/absolute/path/to/context-panel/scripts/claude-statusline-cache.sh"
+ }
+}
+```
+
+The helper does not store auth tokens, prompts, transcript contents, emails,
+organization IDs, or raw Claude session JSON. Claude Code's non-interactive
+`claude -p` path does not appear to run the status-line hook, so Context Panel
+marks old Claude subscription readings stale instead of treating them as live.
+For Every Code-driven Claude usage, Context Panel also reads `ccusage` aggregate
+block output when available and shows a clearly marked estimated 5-hour token
+window; that estimate is useful for "am I likely to run out soon?" but is not
+Anthropic's official subscription percentage.
+
+For Gemini, use the OAuth client values from the locally installed Gemini CLI;
+they are intentionally not checked into this repository.
+
+The probes call the same `ContextPanelCore` connectors the app will use, so
+passing probe output is also a smoke test for the production connector runtime.
+`SnapshotStoreProbe` additionally writes and reloads the local JSON cache shape
+that the app and widget will consume.
diff --git a/Sources/ClaudeLimitProbe/main.swift b/Sources/ClaudeLimitProbe/main.swift
new file mode 100644
index 0000000..b471d46
--- /dev/null
+++ b/Sources/ClaudeLimitProbe/main.swift
@@ -0,0 +1,104 @@
+import ContextPanelCore
+import Foundation
+
+struct ClaudeProbeError: LocalizedError {
+ let message: String
+
+ var errorDescription: String? { message }
+}
+
+struct ProbeConfiguration {
+ let claudeBinary: String
+ let statsPath: String
+
+ static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration {
+ var claudeBinary = "claude"
+ var statsPath: String?
+ var iterator = arguments.dropFirst().makeIterator()
+
+ while let argument = iterator.next() {
+ switch argument {
+ case "--claude-bin":
+ guard let value = iterator.next() else {
+ throw ClaudeProbeError(message: "--claude-bin requires a path or executable name")
+ }
+ claudeBinary = value
+ case "--stats":
+ guard let value = iterator.next() else {
+ throw ClaudeProbeError(message: "--stats requires a path")
+ }
+ statsPath = value
+ case "--help", "-h":
+ printHelp()
+ Foundation.exit(0)
+ default:
+ throw ClaudeProbeError(message: "unknown argument: \(argument)")
+ }
+ }
+
+ return ProbeConfiguration(
+ claudeBinary: claudeBinary,
+ statsPath: statsPath ?? defaultStatsPath()
+ )
+ }
+
+ private static func defaultStatsPath() -> String {
+ let environment = ProcessInfo.processInfo.environment
+ let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path
+ return "\(home)/.claude/stats-cache.json"
+ }
+
+ private static func printHelp() {
+ print("""
+ Usage: swift run ClaudeLimitProbe [--claude-bin claude] [--stats ~/.claude/stats-cache.json]
+
+ Prints a redacted Claude local status summary. This probe intentionally
+ does not read Keychain secrets, token files, raw transcripts, emails,
+ account IDs, org IDs, or provider response bodies.
+ """)
+ }
+}
+
+@main
+struct ClaudeLimitProbe {
+ static func main() async {
+ do {
+ let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments)
+ let connector = ClaudeLocalStatusConnector(accounts: [
+ ClaudeAccountConfiguration(
+ claudeBinary: configuration.claudeBinary,
+ statsPath: configuration.statsPath
+ )
+ ])
+ let result = await connector.refresh(now: Date())
+ printSummary(result: result, statsPath: configuration.statsPath)
+ } catch {
+ fputs("ClaudeLimitProbe failed: \(error.localizedDescription)\n", stderr)
+ Foundation.exit(1)
+ }
+ }
+
+ private static func printSummary(result: ConnectorRefreshResult, statsPath: String) {
+ print("Claude local status probe")
+ print("stats cache: \(ConnectorRedactor.redactedPath(statsPath))")
+ print("accounts: \(result.reports.count)")
+ print("limits: \(result.snapshot.limits.count)")
+ print("redacted: tokens, Keychain secrets, account identifiers, org identifiers, emails, raw transcripts, raw provider responses")
+ print("")
+
+ for report in result.reports {
+ print("- \(report.accountName): \(report.status.rawValue)")
+ if let errorMessage = report.errorMessage {
+ print(" error: \(errorMessage)")
+ }
+ for limit in report.limits {
+ print(" - \(limit.label): \(limit.status.rawValue)")
+ if let note = limit.note {
+ print(" \(note)")
+ }
+ }
+ }
+ print("official non-interactive subscription percentage: not exposed by Claude Code")
+ print("Every Code estimate: shown when ccusage aggregate block data is available")
+ }
+}
diff --git a/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift b/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift
new file mode 100644
index 0000000..aa91d20
--- /dev/null
+++ b/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift
@@ -0,0 +1,583 @@
+import ContextPanelCore
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+
+@main
+struct ClaudeWebUsageProbeApp: App {
+ init() {
+ NSApplication.shared.setActivationPolicy(.regular)
+ DispatchQueue.main.async {
+ NSApplication.shared.activate(ignoringOtherApps: true)
+ }
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ ClaudeUsageProbeRootView()
+ .frame(minWidth: 1180, minHeight: 760)
+ }
+ }
+}
+
+struct ClaudeUsageProbeRootView: View {
+ @StateObject private var model = ClaudeUsageProbeModel()
+
+ var body: some View {
+ HStack(spacing: 0) {
+ sidebar
+ .frame(width: 390)
+ .padding(18)
+ .background(Color(nsColor: .controlBackgroundColor))
+
+ Divider()
+
+ ClaudeProbeWebView(model: model)
+ }
+ .fileExporter(
+ isPresented: $model.isExportingReport,
+ document: ProbeReportDocument(report: model.reportMarkdown),
+ contentType: .plainText,
+ defaultFilename: "claude-web-usage-probe-report.md"
+ ) { _ in }
+ }
+
+ private var sidebar: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ header
+ controls
+ status
+ Divider()
+ capturedLimits
+ sanitizedFields
+ Spacer()
+ safetyFooter
+ }
+ }
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Claude Usage Probe")
+ .font(.system(size: 24, weight: .semibold))
+ Text("Log in to Claude in this window, open Usage, then capture official subscription windows from the authenticated page context.")
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var controls: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Button("Open Usage") { model.openUsagePage() }
+ Button("Reload") { model.reload() }
+ Button("Export") { model.exportReport() }
+ }
+
+ HStack {
+ Button("Save Snapshot") { model.saveSnapshot() }
+ .disabled(model.limits.isEmpty)
+ Button("Clear") { model.clear() }
+ }
+ }
+ }
+
+ private var status: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Label(model.statusText, systemImage: model.statusIcon)
+ .font(.system(size: 12, weight: .medium))
+ Text(model.currentURLText)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ .lineLimit(2)
+ }
+ .foregroundStyle(model.hasCapturedUsage ? .primary : .secondary)
+ }
+
+ private var capturedLimits: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Captured windows")
+ .font(.system(size: 12, weight: .semibold))
+ .textCase(.uppercase)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(model.limits.count)")
+ .font(.system(.caption, design: .monospaced, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ if model.limits.isEmpty {
+ ContentUnavailableView(
+ "No usage windows yet",
+ systemImage: "gauge.with.dots.needle.67percent",
+ description: Text("Complete Claude login or verification, then wait for the Usage page to load.")
+ )
+ .frame(maxHeight: 220)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(model.limits) { limit in
+ ClaudeUsageLimitRow(limit: limit)
+ }
+ }
+ }
+ .frame(maxHeight: 280)
+ }
+ }
+ }
+
+ private var sanitizedFields: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Sanitized fields")
+ .font(.system(size: 12, weight: .semibold))
+ .textCase(.uppercase)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(model.fieldPaths.count)")
+ .font(.system(.caption, design: .monospaced, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ if model.fieldPaths.isEmpty {
+ Text("No Claude usage response fields captured yet.")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(model.fieldPaths.prefix(18), id: \.self) { field in
+ Text(field)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(maxHeight: 160)
+ }
+ }
+ }
+
+ private var safetyFooter: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Label("Login stays inside the visible web session.", systemImage: "person.crop.circle.badge.checkmark")
+ Label("Only percent windows, reset times, and field paths leave the page.", systemImage: "lock.shield")
+ Label("No cookies, auth headers, tokens, local storage, emails, org IDs, or raw bodies are stored.", systemImage: "eye.slash")
+ }
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+}
+
+struct ClaudeUsageLimitRow: View {
+ let limit: UsageLimit
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ HStack(alignment: .firstTextBaseline) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(limit.displayLabel)
+ .font(.system(size: 13, weight: .semibold))
+ Text(limit.contextLabel)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Text(percentText)
+ .font(.system(size: 18, weight: .semibold, design: .rounded))
+ }
+
+ ProgressView(value: limit.usageRatio ?? 0)
+ .tint(tint)
+
+ Text(resetText)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(nsColor: .textBackgroundColor))
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+
+ private var percentText: String {
+ guard let ratio = limit.usageRatio else { return "?" }
+ return "\(Int((ratio * 100).rounded()))%"
+ }
+
+ private var resetText: String {
+ guard let resetsAt = limit.resetsAt else { return "reset unknown" }
+ return "resets " + resetsAt.formatted(date: .abbreviated, time: .shortened)
+ }
+
+ private var tint: Color {
+ switch limit.status {
+ case .limited:
+ .red
+ case .close:
+ .orange
+ case .healthy:
+ .green
+ default:
+ .secondary
+ }
+ }
+}
+
+@MainActor
+final class ClaudeUsageProbeModel: ObservableObject {
+ @Published var limits: [UsageLimit] = []
+ @Published var fieldPaths: [String] = []
+ @Published var statusText = "Waiting for Claude usage response"
+ @Published var statusIcon = "clock"
+ @Published var currentURLText = ""
+ @Published var isExportingReport = false
+
+ private let snapshotStore = JSONSnapshotStore(rootDirectory: ContextPanelLocations.snapshotDirectory())
+
+ private lazy var navigationDelegate = ClaudeUsageNavigationDelegate(owner: self)
+
+ lazy var webView: WKWebView = {
+ let configuration = WKWebViewConfiguration()
+ configuration.websiteDataStore = .default()
+ configuration.userContentController.add(ClaudeUsageScriptHandler(owner: self), name: "claudeUsageProbe")
+ configuration.userContentController.addUserScript(
+ WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false)
+ )
+
+ let view = WKWebView(frame: .zero, configuration: configuration)
+ view.navigationDelegate = navigationDelegate
+ return view
+ }()
+
+ init() {
+ openUsagePage()
+ }
+
+ var hasCapturedUsage: Bool { !limits.isEmpty }
+
+ var reportMarkdown: String {
+ var lines = [
+ "# Claude Web Usage Probe Report",
+ "",
+ "- Captured: \(Date().ISO8601Format())",
+ "- Windows: \(limits.count)",
+ "- Sanitized fields: \(fieldPaths.count)",
+ "",
+ "## Captured Windows",
+ ]
+
+ if limits.isEmpty {
+ lines.append("- No usage windows captured.")
+ } else {
+ for limit in limits {
+ let percent = limit.used.map { "\($0)%" } ?? "unknown"
+ let reset = limit.resetsAt?.ISO8601Format() ?? "unknown reset"
+ lines.append("- \(limit.displayLabel) / \(limit.contextLabel): \(percent), resets \(reset)")
+ }
+ }
+
+ lines.append(contentsOf: [
+ "",
+ "## Sanitized Fields",
+ ])
+
+ if fieldPaths.isEmpty {
+ lines.append("- No field paths captured.")
+ } else {
+ lines.append(contentsOf: fieldPaths.map { "- `\($0)`" })
+ }
+
+ lines.append(contentsOf: [
+ "",
+ "## Redactions",
+ "- cookies",
+ "- authorization headers",
+ "- bearer tokens",
+ "- Keychain credentials",
+ "- OAuth tokens",
+ "- local storage",
+ "- account and organization identifiers",
+ "- emails",
+ "- raw response bodies",
+ "- transcripts and prompt/response content",
+ ])
+
+ return lines.joined(separator: "\n")
+ }
+
+ func openUsagePage() {
+ load("https://claude.ai/settings/usage")
+ }
+
+ func reload() {
+ statusText = "Reloading Claude usage page"
+ statusIcon = "arrow.clockwise"
+ webView.reload()
+ }
+
+ func clear() {
+ limits = []
+ fieldPaths = []
+ statusText = "Waiting for Claude usage response"
+ statusIcon = "clock"
+ }
+
+ func exportReport() {
+ isExportingReport = true
+ }
+
+ func saveSnapshot() {
+ guard !limits.isEmpty else { return }
+ do {
+ try saveCurrentSnapshot()
+ statusText = "Saved sanitized Claude usage snapshot"
+ statusIcon = "checkmark.circle"
+ } catch {
+ statusText = "Save failed: \(error.localizedDescription)"
+ statusIcon = "exclamationmark.triangle"
+ }
+ }
+
+ fileprivate func record(payload: [String: Any]) {
+ let windows = payload["windows"] as? [String: Any] ?? [:]
+ let fields = payload["fields"] as? [String] ?? []
+ let wrapped = ["rate_limits": windows]
+
+ do {
+ let data = try JSONSerialization.data(withJSONObject: wrapped)
+ let parsedLimits = try ClaudeWebUsageParser.usageLimits(
+ from: data,
+ accountID: "claude-web",
+ accountName: "Claude Web",
+ observedAt: Date()
+ )
+ guard !parsedLimits.isEmpty else {
+ statusText = "Usage response found, but no percent windows were present"
+ statusIcon = "questionmark.circle"
+ return
+ }
+
+ limits = parsedLimits
+ fieldPaths = Array(Set(fields)).sorted()
+ saveSnapshotAfterCapture()
+ } catch {
+ statusText = "Capture failed: \(error.localizedDescription)"
+ statusIcon = "exclamationmark.triangle"
+ }
+ }
+
+ private func saveSnapshotAfterCapture() {
+ do {
+ try saveCurrentSnapshot()
+ statusText = "Captured and saved Claude subscription usage"
+ statusIcon = "checkmark.circle.fill"
+ } catch {
+ statusText = "Captured Claude usage; save failed: \(error.localizedDescription)"
+ statusIcon = "exclamationmark.triangle"
+ }
+ }
+
+ private func saveCurrentSnapshot() throws {
+ let report = ProviderConnectorReport(
+ provider: .anthropic,
+ accountID: "claude-web",
+ accountName: "Claude Web",
+ generatedAt: Date(),
+ limits: limits,
+ status: .healthy
+ )
+ try snapshotStore.saveMerged(
+ refreshResult: ConnectorRefreshResult(generatedAt: Date(), reports: [report]),
+ savedAt: Date()
+ )
+ }
+
+ fileprivate func updateCurrentURL(_ url: URL?) {
+ currentURLText = url?.absoluteString ?? ""
+ if let host = url?.host, host.contains("claude.ai"), limits.isEmpty {
+ statusText = "Claude page loaded; waiting for usage API"
+ statusIcon = "network"
+ }
+ }
+
+ private func load(_ rawURL: String) {
+ guard let url = URL(string: rawURL) else { return }
+ statusText = "Opening Claude usage page"
+ statusIcon = "safari"
+ webView.load(URLRequest(url: url))
+ }
+
+ private static let networkProbeScript = #"""
+ (() => {
+ if (window.__contextPanelClaudeUsageProbeInstalled) return;
+ window.__contextPanelClaudeUsageProbeInstalled = true;
+
+ const windowKeys = new Set([
+ 'five_hour',
+ 'seven_day',
+ 'seven_day_opus',
+ 'seven_day_sonnet',
+ 'seven_day_oauth_apps'
+ ]);
+ const fieldKeys = new Set([
+ 'used_percentage',
+ 'remaining_percentage',
+ 'utilization',
+ 'resets_at',
+ 'reset_at'
+ ]);
+
+ function isUsageURL(rawUrl) {
+ try {
+ const url = new URL(rawUrl, window.location.href);
+ return /^\/api\/organizations\/[^/]+\/usage$/.test(url.pathname);
+ } catch (_) {
+ return false;
+ }
+ }
+
+ function sanitizeWindow(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ const sanitized = {};
+ for (const key of fieldKeys) {
+ const raw = value[key];
+ if (typeof raw === 'number' || typeof raw === 'string') sanitized[key] = raw;
+ }
+ return Object.keys(sanitized).length ? sanitized : null;
+ }
+
+ function collectWindows(value, out = {}) {
+ if (!value || typeof value !== 'object') return out;
+ if (Array.isArray(value)) {
+ value.slice(0, 3).forEach(item => collectWindows(item, out));
+ return out;
+ }
+ for (const [key, child] of Object.entries(value)) {
+ if (windowKeys.has(key)) {
+ const sanitized = sanitizeWindow(child);
+ if (sanitized) out[key] = sanitized;
+ }
+ collectWindows(child, out);
+ }
+ return out;
+ }
+
+ function collectFields(value, prefix = '', out = new Set()) {
+ if (!value || typeof value !== 'object' || out.size > 80) return out;
+ if (Array.isArray(value)) {
+ value.slice(0, 3).forEach(item => collectFields(item, prefix, out));
+ return out;
+ }
+ for (const [key, child] of Object.entries(value)) {
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (windowKeys.has(key) || fieldKeys.has(key) || key === 'rate_limits' || key === 'usage') out.add(path);
+ collectFields(child, path, out);
+ }
+ return out;
+ }
+
+ function post(payload) {
+ try { window.webkit.messageHandlers.claudeUsageProbe.postMessage(payload); }
+ catch (_) {}
+ }
+
+ function inspect(url, contentType, text) {
+ if (!isUsageURL(url) || !/json/i.test(contentType || '')) return;
+ try {
+ const parsed = JSON.parse(String(text || ''));
+ const windows = collectWindows(parsed);
+ if (!Object.keys(windows).length) return;
+ post({ windows, fields: Array.from(collectFields(parsed)).slice(0, 80) });
+ } catch (_) {}
+ }
+
+ const originalFetch = window.fetch;
+ if (originalFetch) {
+ window.fetch = async function(input, init) {
+ const response = await originalFetch.apply(this, arguments);
+ try {
+ const clone = response.clone();
+ const url = typeof input === 'string' ? input : (input && input.url) || '';
+ clone.text().then(text => inspect(url, clone.headers.get('content-type') || '', text)).catch(() => {});
+ } catch (_) {}
+ return response;
+ };
+ }
+
+ const originalOpen = XMLHttpRequest.prototype.open;
+ const originalSend = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function(method, url) {
+ this.__cpClaudeUsageUrl = url;
+ return originalOpen.apply(this, arguments);
+ };
+ XMLHttpRequest.prototype.send = function() {
+ this.addEventListener('load', function() {
+ try { inspect(this.__cpClaudeUsageUrl || '', this.getResponseHeader('content-type') || '', this.responseText || ''); }
+ catch (_) {}
+ });
+ return originalSend.apply(this, arguments);
+ };
+ })();
+ """#
+}
+
+final class ClaudeUsageScriptHandler: NSObject, WKScriptMessageHandler {
+ weak var owner: ClaudeUsageProbeModel?
+
+ init(owner: ClaudeUsageProbeModel) {
+ self.owner = owner
+ }
+
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let payload = message.body as? [String: Any] else { return }
+ Task { @MainActor [weak owner = self.owner] in
+ owner?.record(payload: payload)
+ }
+ }
+}
+
+final class ClaudeUsageNavigationDelegate: NSObject, WKNavigationDelegate {
+ weak var owner: ClaudeUsageProbeModel?
+
+ init(owner: ClaudeUsageProbeModel) {
+ self.owner = owner
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ Task { @MainActor [weak webView, weak owner] in
+ owner?.updateCurrentURL(webView?.url)
+ }
+ }
+}
+
+struct ClaudeProbeWebView: NSViewRepresentable {
+ @ObservedObject var model: ClaudeUsageProbeModel
+
+ func makeNSView(context: Context) -> WKWebView {
+ model.webView
+ }
+
+ func updateNSView(_ nsView: WKWebView, context: Context) {}
+}
+
+struct ProbeReportDocument: FileDocument {
+ static var readableContentTypes: [UTType] { [.plainText] }
+
+ var report: String
+
+ init(report: String) {
+ self.report = report
+ }
+
+ init(configuration: ReadConfiguration) throws {
+ report = ""
+ }
+
+ func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
+ FileWrapper(regularFileWithContents: Data(report.utf8))
+ }
+}
diff --git a/Sources/CodexRateLimitProbe/main.swift b/Sources/CodexRateLimitProbe/main.swift
new file mode 100644
index 0000000..f82d9df
--- /dev/null
+++ b/Sources/CodexRateLimitProbe/main.swift
@@ -0,0 +1,114 @@
+import ContextPanelCore
+import Foundation
+
+struct CodexProbeError: LocalizedError {
+ let message: String
+
+ var errorDescription: String? { message }
+}
+
+struct ProbeConfiguration {
+ let authPath: String
+ let endpoint: URL
+
+ static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration {
+ var authPath: String?
+ var endpoint = URL(string: "https://chatgpt.com/backend-api/wham/usage")!
+ var iterator = arguments.dropFirst().makeIterator()
+
+ while let argument = iterator.next() {
+ switch argument {
+ case "--auth":
+ guard let value = iterator.next() else {
+ throw CodexProbeError(message: "--auth requires a path")
+ }
+ authPath = value
+ case "--endpoint":
+ guard let value = iterator.next(), let url = URL(string: value) else {
+ throw CodexProbeError(message: "--endpoint requires an absolute URL")
+ }
+ endpoint = url
+ case "--help", "-h":
+ printHelp()
+ Foundation.exit(0)
+ default:
+ throw CodexProbeError(message: "unknown argument: \(argument)")
+ }
+ }
+
+ return ProbeConfiguration(
+ authPath: authPath ?? defaultAuthPath(),
+ endpoint: endpoint
+ )
+ }
+
+ private static func defaultAuthPath() -> String {
+ let environment = ProcessInfo.processInfo.environment
+ let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path
+ let codexHome = environment["CODEX_HOME"] ?? "\(home)/.codex"
+ return "\(codexHome)/auth.json"
+ }
+
+ private static func printHelp() {
+ print("""
+ Usage: swift run CodexRateLimitProbe [--auth /path/to/auth.json] [--endpoint URL]
+
+ Calls the live Codex usage endpoint directly and prints only a redacted
+ summary of limit buckets, windows, plan type, and credits. Tokens,
+ account identifiers, emails, headers, and raw response bodies are never
+ printed.
+ """)
+ }
+}
+
+@main
+struct CodexRateLimitProbe {
+ static func main() async {
+ do {
+ let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments)
+ let connector = CodexRateLimitConnector(accounts: [
+ CodexAccountConfiguration(authPath: configuration.authPath, endpoint: configuration.endpoint)
+ ])
+ let result = await connector.refresh(now: Date())
+ printSummary(result: result, endpoint: configuration.endpoint, authPath: configuration.authPath)
+ } catch {
+ fputs("CodexRateLimitProbe failed: \(error.localizedDescription)\n", stderr)
+ Foundation.exit(1)
+ }
+ }
+
+ private static func printSummary(result: ConnectorRefreshResult, endpoint: URL, authPath: String) {
+ print("Codex live usage endpoint probe")
+ print("endpoint: \(endpoint.absoluteString)")
+ print("auth: \(redactedAuthPath(authPath))")
+ print("accounts: \(result.reports.count)")
+ print("limits: \(result.snapshot.limits.count)")
+ print("redacted: tokens, account identifiers, emails, headers, raw response bodies")
+ print("")
+
+ for report in result.reports {
+ print("- \(report.accountName): \(report.status.rawValue)")
+ if let errorMessage = report.errorMessage {
+ print(" error: \(errorMessage)")
+ }
+ for limit in report.limits {
+ print(" - \(limit.label): \(format(limit: limit))")
+ }
+ }
+ }
+
+ private static func format(limit: UsageLimit) -> String {
+ let used = limit.used.map { "\($0)% used" } ?? "unknown used"
+ let reset = limit.resetsAt.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown reset"
+ return "\(used) / resets \(reset)"
+ }
+
+ private static func redactedAuthPath(_ path: String) -> String {
+ let expanded = NSString(string: path).expandingTildeInPath
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ if expanded.hasPrefix(home) {
+ return "~" + expanded.dropFirst(home.count)
+ }
+ return URL(fileURLWithPath: expanded).lastPathComponent
+ }
+}
diff --git a/Sources/ContextPanelCore/AccountConfigurationStore.swift b/Sources/ContextPanelCore/AccountConfigurationStore.swift
new file mode 100644
index 0000000..45e0ba1
--- /dev/null
+++ b/Sources/ContextPanelCore/AccountConfigurationStore.swift
@@ -0,0 +1,214 @@
+import Foundation
+
+public enum AccountConnectorKind: String, Codable, Equatable, Sendable {
+ case codexRateLimits
+ case geminiCodeAssist
+ case claudeLocalStatus
+}
+
+public struct LocalProviderAccountConfiguration: Codable, Equatable, Identifiable, Sendable {
+ public let id: String
+ public let provider: Provider
+ public let connectorKind: AccountConnectorKind
+ public var displayName: String
+ public var isEnabled: Bool
+ public var authPath: String?
+ public var commandPath: String?
+ public var statsPath: String?
+ public var oauthClientIDEnvironmentName: String?
+ public var oauthClientSecretEnvironmentName: String?
+
+ public init(
+ id: String,
+ provider: Provider,
+ connectorKind: AccountConnectorKind,
+ displayName: String,
+ isEnabled: Bool = true,
+ authPath: String? = nil,
+ commandPath: String? = nil,
+ statsPath: String? = nil,
+ oauthClientIDEnvironmentName: String? = nil,
+ oauthClientSecretEnvironmentName: String? = nil
+ ) {
+ self.id = id
+ self.provider = provider
+ self.connectorKind = connectorKind
+ self.displayName = displayName
+ self.isEnabled = isEnabled
+ self.authPath = authPath
+ self.commandPath = commandPath
+ self.statsPath = statsPath
+ self.oauthClientIDEnvironmentName = oauthClientIDEnvironmentName
+ self.oauthClientSecretEnvironmentName = oauthClientSecretEnvironmentName
+ }
+}
+
+public struct AccountConfigurationDocument: Codable, Equatable, Sendable {
+ public let schemaVersion: Int
+ public var updatedAt: Date
+ public var accounts: [LocalProviderAccountConfiguration]
+
+ public init(updatedAt: Date, accounts: [LocalProviderAccountConfiguration]) {
+ schemaVersion = 1
+ self.updatedAt = updatedAt
+ self.accounts = accounts
+ }
+}
+
+public struct AccountConfigurationLoadResult: Equatable, Sendable {
+ public let document: AccountConfigurationDocument
+ public let status: UsageStatus
+ public let errorMessage: String?
+
+ public init(document: AccountConfigurationDocument, status: UsageStatus, errorMessage: String? = nil) {
+ self.document = document
+ self.status = status
+ self.errorMessage = errorMessage.map(ConnectorRedactor.redact)
+ }
+}
+
+public struct AccountConfigurationStore: Sendable {
+ public let configurationURL: URL
+
+ public init(configurationURL: URL) {
+ self.configurationURL = configurationURL
+ }
+
+ public func load(now: Date = Date()) -> AccountConfigurationLoadResult {
+ guard FileManager.default.fileExists(atPath: configurationURL.path) else {
+ return AccountConfigurationLoadResult(document: Self.defaultDocument(now: now), status: .unknown)
+ }
+
+ do {
+ let document = try Self.makeDecoder().decode(
+ AccountConfigurationDocument.self,
+ from: try Data(contentsOf: configurationURL)
+ )
+ guard document.schemaVersion == 1 else {
+ throw SnapshotStoreError.unsupportedSchema(version: document.schemaVersion)
+ }
+ return AccountConfigurationLoadResult(document: document, status: .healthy)
+ } catch {
+ return AccountConfigurationLoadResult(
+ document: Self.defaultDocument(now: now),
+ status: .failure,
+ errorMessage: error.localizedDescription
+ )
+ }
+ }
+
+ public func save(_ document: AccountConfigurationDocument) throws {
+ let directory = configurationURL.deletingLastPathComponent()
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let data = try Self.makeEncoder().encode(document)
+ try data.write(to: configurationURL, options: [.atomic])
+ }
+
+ public static func defaultDocument(now: Date = Date()) -> AccountConfigurationDocument {
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ return AccountConfigurationDocument(updatedAt: now, accounts: [
+ LocalProviderAccountConfiguration(
+ id: "openai-codex-default",
+ provider: .openAI,
+ connectorKind: .codexRateLimits,
+ displayName: "Codex",
+ authPath: "\(home)/.codex/auth.json"
+ ),
+ LocalProviderAccountConfiguration(
+ id: "claude-local-default",
+ provider: .anthropic,
+ connectorKind: .claudeLocalStatus,
+ displayName: "Claude",
+ commandPath: "claude",
+ statsPath: "\(home)/.claude/stats-cache.json"
+ ),
+ LocalProviderAccountConfiguration(
+ id: "gemini-code-assist-default",
+ provider: .google,
+ connectorKind: .geminiCodeAssist,
+ displayName: "Gemini",
+ isEnabled: false,
+ authPath: "\(home)/.gemini/oauth_creds.json",
+ oauthClientIDEnvironmentName: "GEMINI_OAUTH_CLIENT_ID",
+ oauthClientSecretEnvironmentName: "GEMINI_OAUTH_CLIENT_SECRET"
+ ),
+ ])
+ }
+
+ private static func makeEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ encoder.dateEncodingStrategy = .iso8601
+ return encoder
+ }
+
+ private static func makeDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
+
+public enum AccountConnectorFactory {
+ public static func connectors(
+ from document: AccountConfigurationDocument,
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ geminiMetadataFileLoader: @escaping @Sendable (String) throws -> String = { path in
+ try String(contentsOfFile: NSString(string: path).expandingTildeInPath, encoding: .utf8)
+ },
+ geminiMetadataFileExists: @escaping @Sendable (String) -> Bool = { path in
+ FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath)
+ },
+ geminiMetadataDirectoryLister: @escaping @Sendable (String) -> [String] = { path in
+ let expanded = NSString(string: path).expandingTildeInPath
+ return (try? FileManager.default.contentsOfDirectory(atPath: expanded).map { "\(expanded)/\($0)" }) ?? []
+ }
+ ) -> [any ProviderConnector] {
+ document.accounts.compactMap { account in
+ guard account.isEnabled else { return nil }
+ switch account.connectorKind {
+ case .codexRateLimits:
+ guard let authPath = account.authPath else { return nil }
+ return CodexRateLimitConnector(accounts: [CodexAccountConfiguration(
+ authPath: authPath,
+ accountName: account.displayName
+ )])
+ case .geminiCodeAssist:
+ guard let authPath = account.authPath else { return nil }
+ let configuredMetadata = geminiMetadata(account: account, environment: environment)
+ let discoveredMetadata = GeminiOAuthClientMetadataDiscovery.discover(
+ environment: environment,
+ fileLoader: geminiMetadataFileLoader,
+ fileExists: geminiMetadataFileExists,
+ directoryLister: geminiMetadataDirectoryLister
+ )
+ guard let metadata = configuredMetadata ?? discoveredMetadata else { return nil }
+ return GeminiCodeAssistConnector(accounts: [GeminiAccountConfiguration(
+ authPath: authPath,
+ accountName: account.displayName,
+ clientID: metadata.clientID,
+ clientSecret: metadata.clientSecret
+ )])
+ case .claudeLocalStatus:
+ return ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration(
+ accountName: account.displayName,
+ claudeBinary: account.commandPath ?? "claude",
+ statsPath: account.statsPath
+ )])
+ }
+ }
+ }
+
+ private static func geminiMetadata(
+ account: LocalProviderAccountConfiguration,
+ environment: [String: String]
+ ) -> GeminiOAuthClientMetadata? {
+ guard
+ let clientIDName = account.oauthClientIDEnvironmentName,
+ let clientSecretName = account.oauthClientSecretEnvironmentName,
+ let clientID = environment[clientIDName], !clientID.isEmpty,
+ let clientSecret = environment[clientSecretName], !clientSecret.isEmpty
+ else { return nil }
+ return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret)
+ }
+}
diff --git a/Sources/ContextPanelCore/ClaudeLocalStatus.swift b/Sources/ContextPanelCore/ClaudeLocalStatus.swift
new file mode 100644
index 0000000..4d484ce
--- /dev/null
+++ b/Sources/ContextPanelCore/ClaudeLocalStatus.swift
@@ -0,0 +1,567 @@
+import Foundation
+
+public struct ClaudeAuthStatus: Codable, Equatable, Sendable {
+ public let loggedIn: Bool
+ public let authMethod: String
+ public let apiProvider: String?
+ public let subscriptionType: String?
+
+ public init(loggedIn: Bool, authMethod: String, apiProvider: String?, subscriptionType: String?) {
+ self.loggedIn = loggedIn
+ self.authMethod = authMethod
+ self.apiProvider = apiProvider
+ self.subscriptionType = subscriptionType
+ }
+
+ public var subscriptionDisplayName: String {
+ guard let subscriptionType, !subscriptionType.isEmpty else { return "Claude" }
+ return "Claude \(subscriptionType.capitalized)"
+ }
+}
+
+public struct ClaudeStatsCacheSummary: Codable, Equatable, Sendable {
+ public let version: Int?
+ public let lastComputedDate: Date?
+ public let totalSessions: Int?
+ public let totalMessages: Int?
+ public let firstSessionDate: Date?
+ public let modelUsageCount: Int
+ public let dailyActivityCount: Int
+ public let rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot?
+
+ public init(
+ version: Int?,
+ lastComputedDate: Date?,
+ totalSessions: Int?,
+ totalMessages: Int?,
+ firstSessionDate: Date?,
+ modelUsageCount: Int,
+ dailyActivityCount: Int,
+ rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? = nil
+ ) {
+ self.version = version
+ self.lastComputedDate = lastComputedDate
+ self.totalSessions = totalSessions
+ self.totalMessages = totalMessages
+ self.firstSessionDate = firstSessionDate
+ self.modelUsageCount = modelUsageCount
+ self.dailyActivityCount = dailyActivityCount
+ self.rateLimitSnapshot = rateLimitSnapshot
+ }
+}
+
+public struct ClaudeUsageBlock: Codable, Equatable, Sendable {
+ public let isActive: Bool
+ public let totalTokens: Int?
+ public let endTime: Date?
+ public let projectedTotalTokens: Int?
+ public let remainingMinutes: Int?
+ public let modelCount: Int
+
+ public init(
+ isActive: Bool,
+ totalTokens: Int?,
+ endTime: Date?,
+ projectedTotalTokens: Int?,
+ remainingMinutes: Int?,
+ modelCount: Int
+ ) {
+ self.isActive = isActive
+ self.totalTokens = totalTokens
+ self.endTime = endTime
+ self.projectedTotalTokens = projectedTotalTokens
+ self.remainingMinutes = remainingMinutes
+ self.modelCount = modelCount
+ }
+}
+
+public struct ClaudeUsageBlocksSummary: Codable, Equatable, Sendable {
+ public let activeBlock: ClaudeUsageBlock?
+ public let completedBlockTokenLimitEstimate: Int?
+
+ public init(activeBlock: ClaudeUsageBlock?, completedBlockTokenLimitEstimate: Int?) {
+ self.activeBlock = activeBlock
+ self.completedBlockTokenLimitEstimate = completedBlockTokenLimitEstimate
+ }
+}
+
+public enum ClaudeUsageBlocksParser {
+ public static func summary(from data: Data) throws -> ClaudeUsageBlocksSummary {
+ let payload = try JSONDecoder.contextPanelFlexibleDates.decode(ClaudeUsageBlocksPayload.self, from: data)
+ let active = payload.blocks.first { $0.isActive }?.usageBlock
+ let completedTokens = payload.blocks
+ .filter { !$0.isActive }
+ .compactMap(\.totalTokens)
+ .filter { $0 > 0 }
+ .sorted()
+ let estimate = completedTokens.isEmpty ? nil : completedTokens[Int(Double(completedTokens.count - 1) * 0.95)]
+ return ClaudeUsageBlocksSummary(activeBlock: active, completedBlockTokenLimitEstimate: estimate)
+ }
+}
+
+public struct ClaudeSubscriptionRateLimitWindow: Codable, Equatable, Sendable {
+ public let label: String
+ public let usedPercent: Double
+ public let resetsAt: Date?
+
+ public init(label: String, usedPercent: Double, resetsAt: Date?) {
+ self.label = label
+ self.usedPercent = max(0, min(usedPercent, 100))
+ self.resetsAt = resetsAt
+ }
+}
+
+public struct ClaudeSubscriptionRateLimitSnapshot: Codable, Equatable, Sendable {
+ public let observedAt: Date
+ public let windows: [ClaudeSubscriptionRateLimitWindow]
+
+ public init(observedAt: Date, windows: [ClaudeSubscriptionRateLimitWindow]) {
+ self.observedAt = observedAt
+ self.windows = windows
+ }
+}
+
+public enum ClaudeAuthStatusParser {
+ public static func status(from data: Data) throws -> ClaudeAuthStatus {
+ let payload = try JSONDecoder().decode(ClaudeAuthStatusPayload.self, from: data)
+ return ClaudeAuthStatus(
+ loggedIn: payload.loggedIn,
+ authMethod: payload.authMethod,
+ apiProvider: payload.apiProvider,
+ subscriptionType: payload.subscriptionType
+ )
+ }
+}
+
+public enum ClaudeStatsCacheParser {
+ public static func summary(from data: Data) throws -> ClaudeStatsCacheSummary {
+ let payload = try JSONDecoder.contextPanelFlexibleDates.decode(ClaudeStatsCachePayload.self, from: data)
+ return ClaudeStatsCacheSummary(
+ version: payload.version,
+ lastComputedDate: payload.lastComputedDate,
+ totalSessions: payload.totalSessions,
+ totalMessages: payload.totalMessages,
+ firstSessionDate: payload.firstSessionDate,
+ modelUsageCount: payload.modelUsage?.count ?? 0,
+ dailyActivityCount: payload.dailyActivity?.count ?? 0,
+ rateLimitSnapshot: payload.rateLimits?.snapshot(
+ observedAt: payload.lastComputedDate ?? Date(timeIntervalSince1970: 0)
+ )
+ )
+ }
+}
+
+public enum ClaudeSubscriptionRateLimitCacheParser {
+ public static func snapshot(from data: Data) throws -> ClaudeSubscriptionRateLimitSnapshot {
+ let payload = try JSONDecoder().decode(ClaudeStatuslineRateLimitPayload.self, from: data)
+ var windows: [ClaudeSubscriptionRateLimitWindow] = []
+ if let fiveHour = payload.rateLimits.fiveHour {
+ windows.append(fiveHour.window(label: "5-hour"))
+ }
+ if let sevenDay = payload.rateLimits.sevenDay {
+ windows.append(sevenDay.window(label: "Weekly"))
+ }
+ return ClaudeSubscriptionRateLimitSnapshot(
+ observedAt: Date(timeIntervalSince1970: TimeInterval(payload.observedAt)),
+ windows: windows
+ )
+ }
+}
+
+public struct ClaudeAccountConfiguration: Equatable, Sendable {
+ public let accountName: String
+ public let claudeBinary: String
+ public let statsPath: String
+ public let rateLimitSnapshotPath: String
+ public let rateLimitSnapshotMaximumAge: TimeInterval
+ public let usageBlocksPath: String?
+
+ public init(
+ accountName: String = "Claude",
+ claudeBinary: String = "claude",
+ statsPath: String? = nil,
+ rateLimitSnapshotPath: String? = nil,
+ rateLimitSnapshotMaximumAge: TimeInterval = 30 * 60,
+ usageBlocksPath: String? = nil
+ ) {
+ self.accountName = accountName
+ self.claudeBinary = claudeBinary
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ self.statsPath = statsPath ?? "\(home)/.claude/stats-cache.json"
+ self.rateLimitSnapshotPath = rateLimitSnapshotPath
+ ?? "\(home)/Library/Application Support/Context Panel/ClaudeRateLimits/statusline-cache.json"
+ self.rateLimitSnapshotMaximumAge = rateLimitSnapshotMaximumAge
+ self.usageBlocksPath = usageBlocksPath
+ ?? "\(home)/Library/Application Support/Context Panel/ClaudeRateLimits/ccusage-blocks-cache.json"
+ }
+}
+
+public struct ClaudeLocalStatusConnector: ProviderConnector {
+ public let provider: Provider = .anthropic
+
+ private let accounts: [ClaudeAccountConfiguration]
+ private let processClient: any ConnectorProcessClient
+ private let fileLoader: @Sendable (String) throws -> Data
+ private let fileExists: @Sendable (String) -> Bool
+
+ public init(
+ accounts: [ClaudeAccountConfiguration],
+ processClient: any ConnectorProcessClient = DefaultConnectorProcessClient(),
+ fileLoader: @escaping @Sendable (String) throws -> Data = { path in
+ try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath))
+ },
+ fileExists: @escaping @Sendable (String) -> Bool = { path in
+ FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath)
+ }
+ ) {
+ self.accounts = accounts
+ self.processClient = processClient
+ self.fileLoader = fileLoader
+ self.fileExists = fileExists
+ }
+
+ public func refresh(now: Date) async -> ConnectorRefreshResult {
+ var reports: [ProviderConnectorReport] = []
+ reports.reserveCapacity(accounts.count)
+ for account in accounts {
+ reports.append(refresh(account: account, now: now))
+ }
+ return ConnectorRefreshResult(generatedAt: now, reports: reports)
+ }
+
+ private func refresh(account: ClaudeAccountConfiguration, now: Date) -> ProviderConnectorReport {
+ let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.rateLimitSnapshotPath)
+
+ do {
+ let authStatus = try loadAuthStatus(claudeBinary: account.claudeBinary)
+ let statsSummary = try loadStatsSummary(path: account.statsPath)
+ let rateLimitSnapshot = try loadRateLimitSnapshot(path: account.rateLimitSnapshotPath)
+ ?? statsSummary?.rateLimitSnapshot
+ let usageBlocksSummary = try loadUsageBlocksSummary(path: account.usageBlocksPath)
+ let limits = claudeLocalStatusLimits(
+ authStatus: authStatus,
+ statsSummary: statsSummary,
+ rateLimitSnapshot: rateLimitSnapshot,
+ rateLimitSnapshotMaximumAge: account.rateLimitSnapshotMaximumAge,
+ usageBlocksSummary: usageBlocksSummary,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ observedAt: now
+ )
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: limits,
+ status: authStatus.loggedIn ? limits.map(\.status).contextPanelWorstStatus : .failure,
+ errorMessage: authStatus.loggedIn ? nil : "Claude CLI is not logged in"
+ )
+ } catch {
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: [],
+ status: .failure,
+ errorMessage: error.localizedDescription
+ )
+ }
+ }
+
+ private func loadAuthStatus(claudeBinary: String) throws -> ClaudeAuthStatus {
+ let result = try processClient.run(executable: claudeBinary, arguments: ["auth", "status", "--json"])
+ guard result.exitCode == 0 else {
+ throw ConnectorError.processFailure(operation: "claude auth status", exitCode: result.exitCode)
+ }
+ return try ClaudeAuthStatusParser.status(from: result.stdout)
+ }
+
+ private func loadStatsSummary(path: String) throws -> ClaudeStatsCacheSummary? {
+ guard fileExists(path) else { return nil }
+ return try ClaudeStatsCacheParser.summary(from: try fileLoader(path))
+ }
+
+ private func loadRateLimitSnapshot(path: String) throws -> ClaudeSubscriptionRateLimitSnapshot? {
+ guard fileExists(path) else { return nil }
+ return try ClaudeSubscriptionRateLimitCacheParser.snapshot(from: try fileLoader(path))
+ }
+
+ private func loadUsageBlocksSummary(path: String?) throws -> ClaudeUsageBlocksSummary? {
+ guard let path, fileExists(path) else { return nil }
+ return try ClaudeUsageBlocksParser.summary(from: try fileLoader(path))
+ }
+}
+
+public func claudeLocalStatusLimits(
+ authStatus: ClaudeAuthStatus,
+ statsSummary: ClaudeStatsCacheSummary?,
+ rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? = nil,
+ rateLimitSnapshotMaximumAge: TimeInterval = 30 * 60,
+ usageBlocksSummary: ClaudeUsageBlocksSummary? = nil,
+ accountID: String,
+ accountName: String,
+ observedAt: Date
+) -> [UsageLimit] {
+ if let estimatedLimit = claudeUsageBlockEstimate(
+ usageBlocksSummary: usageBlocksSummary,
+ authStatus: authStatus,
+ accountID: accountID,
+ accountName: accountName,
+ observedAt: observedAt
+ ) {
+ return [estimatedLimit]
+ }
+
+ if authStatus.loggedIn, let rateLimitSnapshot, !rateLimitSnapshot.windows.isEmpty {
+ let isStale = observedAt.timeIntervalSince(rateLimitSnapshot.observedAt) > rateLimitSnapshotMaximumAge
+ let sourceNote = isStale ? "source: stale Claude Code statusline" : "source: Claude Code statusline"
+ return rateLimitSnapshot.windows.map { window in
+ UsageLimit(
+ provider: .anthropic,
+ accountID: accountID,
+ accountName: accountName,
+ label: "Claude \(window.label)",
+ windowLabel: window.label,
+ modelLabel: authStatus.subscriptionDisplayName,
+ unit: .percent,
+ used: Int(window.usedPercent.rounded()),
+ limit: 100,
+ resetsAt: window.resetsAt,
+ lastUpdatedAt: rateLimitSnapshot.observedAt,
+ confidence: .observed,
+ statusOverride: isStale ? .stale : nil,
+ note: "\(sourceNote); subscription: \(authStatus.subscriptionType ?? "unknown")"
+ )
+ }
+ }
+
+ var noteParts = [
+ "auth: \(authStatus.authMethod)",
+ "provider: \(authStatus.apiProvider ?? "unknown")",
+ "subscription: \(authStatus.subscriptionType ?? "unknown")",
+ "allowance: not exposed by Claude Code",
+ ]
+ if let statsSummary {
+ noteParts.append("sessions: \(statsSummary.totalSessions.map(String.init) ?? "unknown")")
+ noteParts.append("messages: \(statsSummary.totalMessages.map(String.init) ?? "unknown")")
+ } else {
+ noteParts.append("stats cache: absent")
+ }
+
+ return [UsageLimit(
+ provider: .anthropic,
+ accountID: accountID,
+ accountName: accountName,
+ label: "\(authStatus.subscriptionDisplayName) status",
+ modelLabel: "Claude Code",
+ unit: .unknown,
+ used: nil,
+ limit: nil,
+ resetsAt: nil,
+ lastUpdatedAt: statsSummary?.lastComputedDate ?? observedAt,
+ confidence: .observed,
+ statusOverride: authStatus.loggedIn ? .unknown : .failure,
+ note: noteParts.joined(separator: "; ")
+ )]
+}
+
+private func claudeUsageBlockEstimate(
+ usageBlocksSummary: ClaudeUsageBlocksSummary?,
+ authStatus: ClaudeAuthStatus,
+ accountID: String,
+ accountName: String,
+ observedAt: Date
+) -> UsageLimit? {
+ guard
+ authStatus.loggedIn,
+ let activeBlock = usageBlocksSummary?.activeBlock,
+ activeBlock.isActive,
+ let totalTokens = activeBlock.totalTokens,
+ totalTokens > 0
+ else { return nil }
+
+ let estimatedLimit = usageBlocksSummary?.completedBlockTokenLimitEstimate
+ ?? activeBlock.projectedTotalTokens
+ let used: Int?
+ let limit: Int?
+ if let estimatedLimit, estimatedLimit > 0 {
+ used = min(totalTokens, estimatedLimit)
+ limit = estimatedLimit
+ } else {
+ used = nil
+ limit = nil
+ }
+
+ let resetsAt = activeBlock.remainingMinutes.map {
+ observedAt.addingTimeInterval(TimeInterval($0 * 60))
+ } ?? activeBlock.endTime
+ let modelMode = activeBlock.modelCount > 1 ? "mixed models" : "single model"
+
+ return UsageLimit(
+ provider: .anthropic,
+ accountID: accountID,
+ accountName: accountName,
+ label: "Claude 5-hour estimate",
+ windowLabel: "5-hour estimated",
+ modelLabel: authStatus.subscriptionDisplayName,
+ unit: .tokens,
+ used: used,
+ limit: limit,
+ resetsAt: resetsAt,
+ lastUpdatedAt: observedAt,
+ confidence: .estimated,
+ statusOverride: limit == nil ? .unknown : nil,
+ note: "source: ccusage local block estimate from Every Code/Claude sessions; \(modelMode); official subscription percentage unavailable in claude -p"
+ )
+}
+
+private struct ClaudeStatuslineRateLimitPayload: Decodable {
+ let observedAt: Int
+ let rateLimits: ClaudeStatuslineRateLimits
+
+ enum CodingKeys: String, CodingKey {
+ case observedAt = "observed_at"
+ case rateLimits = "rate_limits"
+ }
+}
+
+private struct ClaudeStatuslineRateLimits: Decodable {
+ let fiveHour: ClaudeStatuslineRateLimitWindow?
+ let sevenDay: ClaudeStatuslineRateLimitWindow?
+
+ enum CodingKeys: String, CodingKey {
+ case fiveHour = "five_hour"
+ case sevenDay = "seven_day"
+ }
+
+ func snapshot(observedAt: Date) -> ClaudeSubscriptionRateLimitSnapshot? {
+ var windows: [ClaudeSubscriptionRateLimitWindow] = []
+ if let fiveHour {
+ windows.append(fiveHour.window(label: "5-hour"))
+ }
+ if let sevenDay {
+ windows.append(sevenDay.window(label: "Weekly"))
+ }
+ guard !windows.isEmpty else { return nil }
+ return ClaudeSubscriptionRateLimitSnapshot(observedAt: observedAt, windows: windows)
+ }
+}
+
+private struct ClaudeStatuslineRateLimitWindow: Decodable {
+ let usedPercentage: Double
+ let resetsAt: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case usedPercentage = "used_percentage"
+ case resetsAt = "resets_at"
+ }
+
+ func window(label: String) -> ClaudeSubscriptionRateLimitWindow {
+ ClaudeSubscriptionRateLimitWindow(
+ label: label,
+ usedPercent: usedPercentage,
+ resetsAt: resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }
+ )
+ }
+}
+
+private struct ClaudeAuthStatusPayload: Decodable {
+ let loggedIn: Bool
+ let authMethod: String
+ let apiProvider: String?
+ let subscriptionType: String?
+}
+
+private struct ClaudeStatsCachePayload: Decodable {
+ let version: Int?
+ let lastComputedDate: Date?
+ let dailyActivity: ClaudeCountedCollection?
+ let modelUsage: [String: ClaudeDiscardedValue]?
+ let totalSessions: Int?
+ let totalMessages: Int?
+ let firstSessionDate: Date?
+ let rateLimits: ClaudeStatuslineRateLimits?
+
+ enum CodingKeys: String, CodingKey {
+ case version
+ case lastComputedDate
+ case dailyActivity
+ case modelUsage
+ case totalSessions
+ case totalMessages
+ case firstSessionDate
+ case rateLimits = "rate_limits"
+ }
+}
+
+private struct ClaudeUsageBlocksPayload: Decodable {
+ let blocks: [ClaudeUsageBlockPayload]
+}
+
+private struct ClaudeUsageBlockPayload: Decodable {
+ let isActive: Bool
+ let totalTokens: Int?
+ let endTime: Date?
+ let projection: ClaudeUsageBlockProjection?
+ let models: [String]?
+
+ var usageBlock: ClaudeUsageBlock {
+ ClaudeUsageBlock(
+ isActive: isActive,
+ totalTokens: totalTokens,
+ endTime: endTime,
+ projectedTotalTokens: projection?.totalTokens,
+ remainingMinutes: projection?.remainingMinutes,
+ modelCount: models?.filter { $0 != "" }.count ?? 0
+ )
+ }
+}
+
+private struct ClaudeUsageBlockProjection: Decodable {
+ let totalTokens: Int?
+ let remainingMinutes: Int?
+}
+
+private enum ClaudeCountedCollection: Decodable {
+ case dictionary([String: ClaudeDiscardedValue])
+ case array([ClaudeDiscardedValue])
+
+ var count: Int {
+ switch self {
+ case let .dictionary(values):
+ values.count
+ case let .array(values):
+ values.count
+ }
+ }
+
+ init(from decoder: Decoder) throws {
+ if let dictionary = try? [String: ClaudeDiscardedValue](from: decoder) {
+ self = .dictionary(dictionary)
+ return
+ }
+ self = .array(try [ClaudeDiscardedValue](from: decoder))
+ }
+}
+
+private struct ClaudeDiscardedValue: Decodable {}
+
+extension JSONDecoder {
+ static var contextPanelFlexibleDates: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .custom { decoder in
+ let container = try decoder.singleValueContainer()
+ let value = try container.decode(String.self)
+ if let date = ContextPanelDateFormatting.date(from: value) {
+ return date
+ }
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Expected ISO 8601 date string"
+ )
+ }
+ return decoder
+ }
+}
diff --git a/Sources/ContextPanelCore/ClaudeWebUsage.swift b/Sources/ContextPanelCore/ClaudeWebUsage.swift
new file mode 100644
index 0000000..6a46f04
--- /dev/null
+++ b/Sources/ContextPanelCore/ClaudeWebUsage.swift
@@ -0,0 +1,140 @@
+import Foundation
+
+public enum ClaudeWebUsageParser {
+ public static func usageLimits(
+ from data: Data,
+ accountID: String,
+ accountName: String,
+ observedAt: Date
+ ) throws -> [UsageLimit] {
+ let payload = try JSONSerialization.jsonObject(with: data)
+ guard let root = payload as? [String: Any] else { return [] }
+
+ let windows: [(key: String, label: String, model: String?)] = [
+ ("five_hour", "5-hour", nil),
+ ("seven_day", "7-day", nil),
+ ("seven_day_opus", "7-day", "Opus"),
+ ("seven_day_sonnet", "7-day", "Sonnet"),
+ ("seven_day_oauth_apps", "7-day", "OAuth apps"),
+ ]
+
+ return windows.compactMap { window in
+ guard let object = findObject(named: window.key, in: root) else { return nil }
+ let usedPercentage = percentValue(for: ["used_percentage", "utilization"], in: object)
+ let remainingPercentage = percentValue(for: ["remaining_percentage"], in: object)
+ let used = usedPercentage ?? remainingPercentage.map { max(0, 100 - $0) }
+ guard let used else { return nil }
+
+ let roundedUsed = min(max(Int(used.rounded()), 0), 100)
+ return UsageLimit(
+ id: "anthropic:\(accountID):claude-web:\(window.key)",
+ provider: .anthropic,
+ accountID: accountID,
+ accountName: accountName,
+ label: "Claude \(window.label)",
+ windowLabel: window.label,
+ modelLabel: window.model ?? "Claude subscription",
+ unit: .percent,
+ used: roundedUsed,
+ limit: 100,
+ resetsAt: resetDate(in: object),
+ lastUpdatedAt: observedAt,
+ confidence: .observed,
+ note: "source: Claude web usage endpoint; authenticated web session required"
+ )
+ }
+ }
+
+ public static func sanitizedUsageFields(from data: Data) throws -> [String] {
+ let payload = try JSONSerialization.jsonObject(with: data)
+ return Array(collectUsageFields(payload).sorted())
+ }
+
+ private static func findObject(named key: String, in value: Any) -> [String: Any]? {
+ if let dictionary = value as? [String: Any] {
+ if let found = dictionary[key] as? [String: Any] {
+ return found
+ }
+ for child in dictionary.values {
+ if let found = findObject(named: key, in: child) {
+ return found
+ }
+ }
+ } else if let array = value as? [Any] {
+ for child in array {
+ if let found = findObject(named: key, in: child) {
+ return found
+ }
+ }
+ }
+ return nil
+ }
+
+ private static func percentValue(for keys: [String], in object: [String: Any]) -> Double? {
+ for key in keys {
+ guard let raw = numericValue(object[key]) else { continue }
+ return raw <= 1 ? raw * 100 : raw
+ }
+ return nil
+ }
+
+ private static func resetDate(in object: [String: Any]) -> Date? {
+ guard let raw = object["resets_at"] ?? object["reset_at"] else { return nil }
+ if let number = numericValue(raw) {
+ return Date(timeIntervalSince1970: number > 10_000_000_000 ? number / 1000 : number)
+ }
+ if let string = raw as? String {
+ return ISO8601DateFormatter().date(from: string)
+ }
+ return nil
+ }
+
+ private static func numericValue(_ value: Any?) -> Double? {
+ switch value {
+ case let value as Double:
+ value
+ case let value as Int:
+ Double(value)
+ case let value as NSNumber:
+ value.doubleValue
+ case let value as String:
+ Double(value)
+ default:
+ nil
+ }
+ }
+
+ private static func collectUsageFields(_ value: Any, prefix: String = "", output: Set = []) -> Set {
+ var output = output
+ let allowed = [
+ "five_hour",
+ "seven_day",
+ "seven_day_opus",
+ "seven_day_sonnet",
+ "seven_day_oauth_apps",
+ "used_percentage",
+ "remaining_percentage",
+ "utilization",
+ "resets_at",
+ "reset_at",
+ "rate_limits",
+ "usage",
+ ]
+
+ if let dictionary = value as? [String: Any] {
+ for (key, child) in dictionary {
+ let path = prefix.isEmpty ? key : "\(prefix).\(key)"
+ if allowed.contains(key) {
+ output.insert(path)
+ }
+ output = collectUsageFields(child, prefix: path, output: output)
+ }
+ } else if let array = value as? [Any] {
+ for child in array.prefix(3) {
+ output = collectUsageFields(child, prefix: prefix, output: output)
+ }
+ }
+
+ return output
+ }
+}
diff --git a/Sources/ContextPanelCore/CodexRateLimits.swift b/Sources/ContextPanelCore/CodexRateLimits.swift
new file mode 100644
index 0000000..5c7b06e
--- /dev/null
+++ b/Sources/ContextPanelCore/CodexRateLimits.swift
@@ -0,0 +1,436 @@
+import Foundation
+
+public enum CodexRateLimitReachedType: String, Codable, Equatable, Sendable {
+ case rateLimitReached = "rate_limit_reached"
+ case workspaceOwnerCreditsDepleted = "workspace_owner_credits_depleted"
+ case workspaceMemberCreditsDepleted = "workspace_member_credits_depleted"
+ case workspaceOwnerUsageLimitReached = "workspace_owner_usage_limit_reached"
+ case workspaceMemberUsageLimitReached = "workspace_member_usage_limit_reached"
+ case unknown
+}
+
+public struct CodexRateLimitWindow: Codable, Equatable, Sendable {
+ public let usedPercent: Double
+ public let windowMinutes: Int?
+ public let resetsAt: Date?
+
+ public init(usedPercent: Double, windowMinutes: Int?, resetsAt: Date?) {
+ self.usedPercent = max(0, min(usedPercent, 100))
+ self.windowMinutes = windowMinutes
+ self.resetsAt = resetsAt
+ }
+}
+
+public struct CodexCreditsSnapshot: Codable, Equatable, Sendable {
+ public let hasCredits: Bool
+ public let unlimited: Bool
+ public let balance: String?
+
+ public init(hasCredits: Bool, unlimited: Bool, balance: String?) {
+ self.hasCredits = hasCredits
+ self.unlimited = unlimited
+ self.balance = balance
+ }
+}
+
+public struct CodexRateLimitSnapshot: Codable, Equatable, Identifiable, Sendable {
+ public let id: String
+ public let limitName: String?
+ public let planType: String
+ public let primary: CodexRateLimitWindow?
+ public let secondary: CodexRateLimitWindow?
+ public let credits: CodexCreditsSnapshot?
+ public let rateLimitReachedType: CodexRateLimitReachedType?
+
+ public init(
+ id: String,
+ limitName: String?,
+ planType: String,
+ primary: CodexRateLimitWindow?,
+ secondary: CodexRateLimitWindow?,
+ credits: CodexCreditsSnapshot?,
+ rateLimitReachedType: CodexRateLimitReachedType?
+ ) {
+ self.id = id
+ self.limitName = limitName
+ self.planType = planType
+ self.primary = primary
+ self.secondary = secondary
+ self.credits = credits
+ self.rateLimitReachedType = rateLimitReachedType
+ }
+
+ public var displayName: String {
+ limitName ?? (id == "codex" ? "Codex" : id)
+ }
+}
+
+public struct CodexAuthTokens: Codable, Equatable, Sendable {
+ public let accessToken: String
+ public let accountID: String?
+ public let idToken: String?
+
+ public init(accessToken: String, accountID: String?, idToken: String?) {
+ self.accessToken = accessToken
+ self.accountID = accountID
+ self.idToken = idToken
+ }
+}
+
+public enum CodexAuthFileParser {
+ public static func tokens(from data: Data) throws -> CodexAuthTokens {
+ let payload = try JSONDecoder().decode(CodexAuthFilePayload.self, from: data)
+ guard let tokens = payload.tokens, !tokens.accessToken.isEmpty else {
+ throw ConnectorError.invalidAuth("auth file does not contain ChatGPT token auth")
+ }
+ return CodexAuthTokens(
+ accessToken: tokens.accessToken,
+ accountID: tokens.accountID,
+ idToken: tokens.idToken
+ )
+ }
+}
+
+public enum CodexAccountIDExtractor {
+ public static func accountID(fromIDToken token: String?) -> String? {
+ guard let token else { return nil }
+ let parts = token.split(separator: ".")
+ guard parts.count >= 2 else { return nil }
+ var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
+ while payload.count % 4 != 0 {
+ payload.append("=")
+ }
+ guard
+ let data = Data(base64Encoded: payload),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let auth = object["https://api.openai.com/auth"] as? [String: Any]
+ else {
+ return nil
+ }
+ return auth["chatgpt_account_id"] as? String
+ }
+}
+
+public struct CodexAccountConfiguration: Equatable, Sendable {
+ public let authPath: String
+ public let accountName: String
+ public let endpoint: URL
+
+ public init(
+ authPath: String,
+ accountName: String? = nil,
+ endpoint: URL = URL(string: "https://chatgpt.com/backend-api/wham/usage")!
+ ) {
+ self.authPath = authPath
+ self.accountName = accountName ?? ConnectorRedactor.redactedPath(authPath)
+ self.endpoint = endpoint
+ }
+}
+
+public struct CodexRateLimitConnector: ProviderConnector {
+ public let provider: Provider = .openAI
+
+ private let accounts: [CodexAccountConfiguration]
+ private let httpClient: any ConnectorHTTPClient
+ private let fileLoader: @Sendable (String) throws -> Data
+
+ public init(
+ accounts: [CodexAccountConfiguration],
+ httpClient: any ConnectorHTTPClient = URLSessionConnectorHTTPClient(),
+ fileLoader: @escaping @Sendable (String) throws -> Data = { path in
+ try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath))
+ }
+ ) {
+ self.accounts = accounts
+ self.httpClient = httpClient
+ self.fileLoader = fileLoader
+ }
+
+ public func refresh(now: Date) async -> ConnectorRefreshResult {
+ var reports: [ProviderConnectorReport] = []
+ reports.reserveCapacity(accounts.count)
+ for account in accounts {
+ reports.append(await refresh(account: account, now: now))
+ }
+ return ConnectorRefreshResult(generatedAt: now, reports: reports)
+ }
+
+ private func refresh(account: CodexAccountConfiguration, now: Date) async -> ProviderConnectorReport {
+ let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.authPath)
+
+ do {
+ let auth = try CodexAuthFileParser.tokens(from: try fileLoader(account.authPath))
+ let data = try await fetchUsage(endpoint: account.endpoint, auth: auth)
+ let snapshots = try CodexUsagePayloadParser.snapshots(from: data)
+ let limits = snapshots.flatMap { snapshot in
+ codexUsageLimits(
+ from: snapshot,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ observedAt: now
+ )
+ }
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: limits
+ )
+ } catch {
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: [],
+ status: .failure,
+ errorMessage: error.localizedDescription
+ )
+ }
+ }
+
+ private func fetchUsage(endpoint: URL, auth: CodexAuthTokens) async throws -> Data {
+ var headers = [
+ "Authorization": "Bearer \(auth.accessToken)",
+ "User-Agent": "context-panel",
+ "Accept": "application/json",
+ ]
+ if let accountID = auth.accountID ?? CodexAccountIDExtractor.accountID(fromIDToken: auth.idToken) {
+ headers["ChatGPT-Account-Id"] = accountID
+ }
+
+ let response = try await httpClient.data(for: ConnectorHTTPRequest(url: endpoint, method: "GET", headers: headers))
+ guard (200..<300).contains(response.statusCode) else {
+ throw ConnectorError.httpFailure(operation: "Codex usage endpoint", statusCode: response.statusCode)
+ }
+ return response.data
+ }
+}
+
+public func codexUsageLimits(
+ from snapshot: CodexRateLimitSnapshot,
+ accountID: String,
+ accountName: String,
+ observedAt: Date
+) -> [UsageLimit] {
+ var limits: [UsageLimit] = []
+ if let primary = snapshot.primary {
+ limits.append(codexUsageLimit(
+ snapshot: snapshot,
+ window: primary,
+ accountID: accountID,
+ accountName: accountName,
+ observedAt: observedAt
+ ))
+ }
+ if let secondary = snapshot.secondary {
+ limits.append(codexUsageLimit(
+ snapshot: snapshot,
+ window: secondary,
+ accountID: accountID,
+ accountName: accountName,
+ observedAt: observedAt
+ ))
+ }
+ return limits
+}
+
+public enum CodexUsagePayloadParser {
+ public static func snapshots(from data: Data) throws -> [CodexRateLimitSnapshot] {
+ let payload = try JSONDecoder().decode(CodexUsagePayload.self, from: data)
+ return snapshots(from: payload)
+ }
+
+ private static func snapshots(from payload: CodexUsagePayload) -> [CodexRateLimitSnapshot] {
+ var snapshots = [
+ CodexRateLimitSnapshot(
+ id: "codex",
+ limitName: nil,
+ planType: payload.planType,
+ primary: payload.rateLimit?.primaryWindow?.normalizedWindow,
+ secondary: payload.rateLimit?.secondaryWindow?.normalizedWindow,
+ credits: payload.credits?.normalizedCredits,
+ rateLimitReachedType: payload.rateLimitReachedType?.normalizedKind
+ )
+ ]
+
+ snapshots.append(contentsOf: payload.additionalRateLimits.map { additional in
+ CodexRateLimitSnapshot(
+ id: additional.meteredFeature,
+ limitName: additional.limitName,
+ planType: payload.planType,
+ primary: additional.rateLimit?.primaryWindow?.normalizedWindow,
+ secondary: additional.rateLimit?.secondaryWindow?.normalizedWindow,
+ credits: nil,
+ rateLimitReachedType: nil
+ )
+ })
+
+ return snapshots
+ }
+}
+
+private struct CodexUsagePayload: Decodable {
+ let planType: String
+ let rateLimit: CodexRateLimitDetails?
+ let credits: CodexCreditsDetails?
+ let additionalRateLimits: [CodexAdditionalRateLimitDetails]
+ let rateLimitReachedType: CodexReachedType?
+
+ enum CodingKeys: String, CodingKey {
+ case planType = "plan_type"
+ case rateLimit = "rate_limit"
+ case credits
+ case additionalRateLimits = "additional_rate_limits"
+ case rateLimitReachedType = "rate_limit_reached_type"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ planType = try container.decodeIfPresent(String.self, forKey: .planType) ?? "unknown"
+ rateLimit = try container.decodeIfPresent(CodexRateLimitDetails.self, forKey: .rateLimit)
+ credits = try container.decodeIfPresent(CodexCreditsDetails.self, forKey: .credits)
+ additionalRateLimits = try container.decodeIfPresent([CodexAdditionalRateLimitDetails].self, forKey: .additionalRateLimits) ?? []
+ rateLimitReachedType = try container.decodeIfPresent(CodexReachedType.self, forKey: .rateLimitReachedType)
+ }
+}
+
+private struct CodexAuthFilePayload: Decodable {
+ let tokens: CodexAuthTokenPayload?
+}
+
+private struct CodexAuthTokenPayload: Decodable {
+ let accessToken: String
+ let accountID: String?
+ let idToken: String?
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ case accountID = "account_id"
+ case idToken = "id_token"
+ }
+}
+
+private struct CodexRateLimitDetails: Decodable {
+ let primaryWindow: CodexWindowSnapshot?
+ let secondaryWindow: CodexWindowSnapshot?
+
+ enum CodingKeys: String, CodingKey {
+ case primaryWindow = "primary_window"
+ case secondaryWindow = "secondary_window"
+ }
+}
+
+private struct CodexWindowSnapshot: Decodable {
+ let usedPercent: Double
+ let limitWindowSeconds: Int?
+ let resetAt: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case usedPercent = "used_percent"
+ case limitWindowSeconds = "limit_window_seconds"
+ case resetAt = "reset_at"
+ }
+
+ var normalizedWindow: CodexRateLimitWindow {
+ CodexRateLimitWindow(
+ usedPercent: usedPercent,
+ windowMinutes: limitWindowSeconds.map { max(($0 + 59) / 60, 0) },
+ resetsAt: resetAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }
+ )
+ }
+}
+
+private struct CodexCreditsDetails: Decodable {
+ let hasCredits: Bool
+ let unlimited: Bool
+ let balance: String?
+
+ enum CodingKeys: String, CodingKey {
+ case hasCredits = "has_credits"
+ case unlimited
+ case balance
+ }
+
+ var normalizedCredits: CodexCreditsSnapshot {
+ CodexCreditsSnapshot(hasCredits: hasCredits, unlimited: unlimited, balance: balance)
+ }
+}
+
+private struct CodexAdditionalRateLimitDetails: Decodable {
+ let limitName: String
+ let meteredFeature: String
+ let rateLimit: CodexRateLimitDetails?
+
+ enum CodingKeys: String, CodingKey {
+ case limitName = "limit_name"
+ case meteredFeature = "metered_feature"
+ case rateLimit = "rate_limit"
+ }
+}
+
+private struct CodexReachedType: Decodable {
+ let kind: CodexRateLimitReachedType
+
+ enum CodingKeys: String, CodingKey {
+ case kind = "type"
+ }
+
+ var normalizedKind: CodexRateLimitReachedType {
+ kind
+ }
+}
+
+private func codexUsageLimit(
+ snapshot: CodexRateLimitSnapshot,
+ window: CodexRateLimitWindow,
+ accountID: String,
+ accountName: String,
+ observedAt: Date
+) -> UsageLimit {
+ let windowLabel = window.windowMinutes.map(codexWindowLabel(minutes:)) ?? "Rolling"
+ return UsageLimit(
+ provider: .openAI,
+ accountID: accountID,
+ accountName: accountName,
+ label: "\(snapshot.displayName) \(windowLabel)",
+ windowLabel: windowLabel,
+ modelLabel: snapshot.displayName,
+ unit: .percent,
+ used: Int(window.usedPercent.rounded()),
+ limit: 100,
+ resetsAt: window.resetsAt,
+ lastUpdatedAt: observedAt,
+ confidence: .observed,
+ note: "plan: \(snapshot.planType)"
+ )
+}
+
+private func codexWindowLabel(minutes: Int) -> String {
+ switch minutes {
+ case 0..<60:
+ return "\(minutes)m"
+ case 60:
+ return "Hourly"
+ case 300:
+ return "5-hour"
+ case 1_440:
+ return "Daily"
+ case 7_200:
+ return "5-day"
+ case 10_080:
+ return "Weekly"
+ default:
+ if minutes.isMultiple(of: 10_080) {
+ return "\(minutes / 10_080)-week"
+ }
+ if minutes.isMultiple(of: 1_440) {
+ return "\(minutes / 1_440)-day"
+ }
+ if minutes.isMultiple(of: 60) {
+ return "\(minutes / 60)-hour"
+ }
+ return "\(minutes)m"
+ }
+}
diff --git a/Sources/ContextPanelCore/ContextPanelLocations.swift b/Sources/ContextPanelCore/ContextPanelLocations.swift
new file mode 100644
index 0000000..85709f3
--- /dev/null
+++ b/Sources/ContextPanelCore/ContextPanelLocations.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+public enum ContextPanelLocations {
+ public static let appGroupID = "group.com.shinycomputers.contextpanel"
+
+ public static func applicationSupportDirectory() -> URL {
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support")
+ return base.appending(path: "Context Panel", directoryHint: .isDirectory)
+ }
+
+ public static func snapshotDirectory(appGroupID: String? = nil) -> URL {
+ if
+ let appGroupID,
+ let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
+ {
+ return containerURL
+ .appending(path: "Context Panel", directoryHint: .isDirectory)
+ .appending(path: "Snapshots", directoryHint: .isDirectory)
+ }
+
+ return applicationSupportDirectory()
+ .appending(path: "Snapshots", directoryHint: .isDirectory)
+ }
+
+ public static func accountConfigurationURL() -> URL {
+ applicationSupportDirectory().appending(path: "accounts.json")
+ }
+}
diff --git a/Sources/ContextPanelCore/FastModeForecast.swift b/Sources/ContextPanelCore/FastModeForecast.swift
new file mode 100644
index 0000000..1b52901
--- /dev/null
+++ b/Sources/ContextPanelCore/FastModeForecast.swift
@@ -0,0 +1,187 @@
+import Foundation
+
+public enum UsageMode: String, Codable, Equatable, Sendable {
+ case standard
+ case fast
+}
+
+public struct BurnRate: Codable, Equatable, Sendable {
+ public let mode: UsageMode
+ public let unitsPerHour: Double
+
+ public init(mode: UsageMode, unitsPerHour: Double) {
+ precondition(unitsPerHour >= 0, "unitsPerHour must not be negative")
+
+ self.mode = mode
+ self.unitsPerHour = unitsPerHour
+ }
+}
+
+public struct FastModeForecastInput: Codable, Equatable, Sendable {
+ public let limit: UsageLimit
+ public let now: Date
+ public let standardBurnRate: BurnRate?
+ public let fastBurnRate: BurnRate?
+ public let reserveUnits: Double
+ public let minimumSafeHours: Double
+
+ public init(
+ limit: UsageLimit,
+ now: Date,
+ standardBurnRate: BurnRate?,
+ fastBurnRate: BurnRate?,
+ reserveUnits: Double = 5,
+ minimumSafeHours: Double = 1
+ ) {
+ precondition(reserveUnits >= 0, "reserveUnits must not be negative")
+ precondition(minimumSafeHours >= 0, "minimumSafeHours must not be negative")
+
+ self.limit = limit
+ self.now = now
+ self.standardBurnRate = standardBurnRate
+ self.fastBurnRate = fastBurnRate
+ self.reserveUnits = reserveUnits
+ self.minimumSafeHours = minimumSafeHours
+ }
+}
+
+public enum FastModeRecommendation: String, Codable, Equatable, Sendable {
+ case safeThroughReset
+ case safeForLimitedTime
+ case saveFastMode
+ case needsCalibration
+ case limited
+}
+
+public struct FastModeForecast: Codable, Equatable, Sendable {
+ public let limitID: UsageLimit.ID
+ public let accountName: String
+ public let recommendation: FastModeRecommendation
+ public let confidence: UsageConfidence
+ public let remainingUnits: Double?
+ public let hoursUntilReset: Double?
+ public let fastModeRunwayHours: Double?
+ public let projectedFastUseUntilReset: Double?
+ public let reserveUnits: Double
+
+ public var copy: String {
+ switch recommendation {
+ case .safeThroughReset:
+ "Fast mode looks safe through reset."
+ case .safeForLimitedTime:
+ if let fastModeRunwayHours {
+ "Fast mode safe for about \(Self.format(hours: fastModeRunwayHours))."
+ } else {
+ "Fast mode safe for a limited time."
+ }
+ case .saveFastMode:
+ "Save fast mode before reset."
+ case .needsCalibration:
+ "Needs calibration before fast mode."
+ case .limited:
+ "Limited until reset."
+ }
+ }
+
+ public init(input: FastModeForecastInput) {
+ limitID = input.limit.id
+ accountName = input.limit.accountName
+ confidence = input.limit.confidence
+ reserveUnits = input.reserveUnits
+
+ let remaining = input.limit.remaining.map(Double.init)
+ remainingUnits = remaining
+ if let resetsAt = input.limit.resetsAt {
+ hoursUntilReset = max(resetsAt.timeIntervalSince(input.now) / 3_600, 0)
+ } else {
+ hoursUntilReset = nil
+ }
+
+ guard input.limit.status != .limited else {
+ recommendation = .limited
+ fastModeRunwayHours = 0
+ projectedFastUseUntilReset = 0
+ return
+ }
+
+ guard
+ let remaining,
+ let fastRate = input.fastBurnRate?.unitsPerHour,
+ fastRate > 0,
+ let hoursUntilReset
+ else {
+ recommendation = .needsCalibration
+ fastModeRunwayHours = nil
+ projectedFastUseUntilReset = nil
+ return
+ }
+
+ let usableRemaining = max(remaining - input.reserveUnits, 0)
+ let runway = usableRemaining / fastRate
+ let projected = fastRate * hoursUntilReset
+
+ fastModeRunwayHours = runway
+ projectedFastUseUntilReset = projected
+
+ if usableRemaining <= 0 {
+ recommendation = .saveFastMode
+ } else if projected <= usableRemaining {
+ recommendation = .safeThroughReset
+ } else if runway >= input.minimumSafeHours {
+ recommendation = .safeForLimitedTime
+ } else {
+ recommendation = .saveFastMode
+ }
+ }
+
+ private static func format(hours: Double) -> String {
+ if hours < 1 {
+ let minutes = max(Int((hours * 60).rounded()), 1)
+ return "\(minutes)m"
+ }
+ if hours < 10 {
+ let rounded = (hours * 2).rounded() / 2
+ if rounded.rounded() == rounded {
+ return "\(Int(rounded))h"
+ }
+ return "\(rounded)h"
+ }
+ return "\(Int(hours.rounded()))h"
+ }
+}
+
+public struct FastModePortfolioForecast: Codable, Equatable, Sendable {
+ public let forecasts: [FastModeForecast]
+
+ public init(forecasts: [FastModeForecast]) {
+ self.forecasts = forecasts
+ }
+
+ public var bestForecast: FastModeForecast? {
+ forecasts.sorted { lhs, rhs in
+ lhs.rank > rhs.rank
+ }.first
+ }
+
+ public var copy: String {
+ bestForecast?.copy ?? "Add an OpenAI account to forecast fast mode."
+ }
+}
+
+extension FastModeForecast {
+ fileprivate var rank: Double {
+ let runway = fastModeRunwayHours ?? -1
+ switch recommendation {
+ case .safeThroughReset:
+ return 1_000 + runway
+ case .safeForLimitedTime:
+ return 500 + runway
+ case .saveFastMode:
+ return 100 + runway
+ case .needsCalibration:
+ return 10
+ case .limited:
+ return 0
+ }
+ }
+}
diff --git a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift
new file mode 100644
index 0000000..e134c98
--- /dev/null
+++ b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift
@@ -0,0 +1,446 @@
+import Foundation
+
+public struct GeminiQuotaBucket: Codable, Equatable, Identifiable, Sendable {
+ public let id: String
+ public let modelID: String
+ public let remainingFraction: Double?
+ public let remainingAmount: Int?
+ public let resetsAt: Date?
+
+ public init(modelID: String, remainingFraction: Double?, remainingAmount: Int?, resetsAt: Date?) {
+ self.id = modelID
+ self.modelID = modelID
+ self.remainingFraction = remainingFraction.map { max(0, min($0, 1)) }
+ self.remainingAmount = remainingAmount
+ self.resetsAt = resetsAt
+ }
+
+ public var usedPercent: Double? {
+ remainingFraction.map { max(0, min((1 - $0) * 100, 100)) }
+ }
+
+ public func usageLimit(accountID: String, accountName: String, observedAt: Date) -> UsageLimit {
+ UsageLimit(
+ provider: .google,
+ accountID: accountID,
+ accountName: accountName,
+ label: modelID,
+ windowLabel: resetWindowLabel,
+ modelLabel: modelID,
+ unit: .percent,
+ used: usedPercent.map { Int($0.rounded()) },
+ limit: usedPercent == nil ? nil : 100,
+ resetsAt: resetsAt,
+ lastUpdatedAt: observedAt,
+ confidence: .observed,
+ note: remainingAmount.map { "remaining amount: \($0)" }
+ )
+ }
+
+ private var resetWindowLabel: String? {
+ guard let resetsAt else { return nil }
+ let seconds = Int(resetsAt.timeIntervalSince(Date()))
+ if seconds <= 0 { return nil }
+ let hours = max(Int((Double(seconds) / 3_600).rounded()), 1)
+ if hours <= 2 { return "Hourly" }
+ if hours <= 7 { return "5-hour" }
+ if hours <= 30 { return "Daily" }
+ if hours <= 132 { return "5-day" }
+ if hours <= 180 { return "Weekly" }
+ return nil
+ }
+}
+
+public enum GeminiQuotaPayloadParser {
+ public static func buckets(from data: Data) throws -> [GeminiQuotaBucket] {
+ let payload = try JSONDecoder.contextPanelISO8601.decode(GeminiQuotaPayload.self, from: data)
+ return payload.buckets.map(\.normalizedBucket)
+ }
+}
+
+public struct GeminiOAuthCredentials: Codable, Equatable, Sendable {
+ public let accessToken: String?
+ public let refreshToken: String?
+
+ public init(accessToken: String?, refreshToken: String?) {
+ self.accessToken = accessToken
+ self.refreshToken = refreshToken
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ case refreshToken = "refresh_token"
+ }
+}
+
+public struct GeminiRefreshResponse: Decodable, Equatable, Sendable {
+ public let accessToken: String
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ }
+}
+
+public struct GeminiCodeAssistTier: Decodable, Equatable, Sendable {
+ public let name: String?
+}
+
+public struct GeminiLoadCodeAssistResponse: Decodable, Equatable, Sendable {
+ public let cloudaicompanionProject: String?
+ public let currentTier: GeminiCodeAssistTier?
+ public let paidTier: GeminiCodeAssistTier?
+}
+
+public struct GeminiAccountConfiguration: Equatable, Sendable {
+ public let authPath: String
+ public let accountName: String
+ public let tokenEndpoint: URL
+ public let codeAssistEndpoint: URL
+ public let clientID: String
+ public let clientSecret: String
+
+ public init(
+ authPath: String,
+ accountName: String? = nil,
+ tokenEndpoint: URL = URL(string: "https://oauth2.googleapis.com/token")!,
+ codeAssistEndpoint: URL = URL(string: "https://cloudcode-pa.googleapis.com/v1internal")!,
+ clientID: String,
+ clientSecret: String
+ ) {
+ self.authPath = authPath
+ self.accountName = accountName ?? ConnectorRedactor.redactedPath(authPath)
+ self.tokenEndpoint = tokenEndpoint
+ self.codeAssistEndpoint = codeAssistEndpoint
+ self.clientID = clientID
+ self.clientSecret = clientSecret
+ }
+}
+
+public struct GeminiOAuthClientMetadata: Equatable, Sendable {
+ public let clientID: String
+ public let clientSecret: String
+
+ public init(clientID: String, clientSecret: String) {
+ self.clientID = clientID
+ self.clientSecret = clientSecret
+ }
+}
+
+public enum GeminiOAuthClientMetadataDiscovery {
+ public static func discover(
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ fileLoader: @escaping @Sendable (String) throws -> String = { path in
+ try String(contentsOfFile: NSString(string: path).expandingTildeInPath, encoding: .utf8)
+ },
+ fileExists: @escaping @Sendable (String) -> Bool = { path in
+ FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath)
+ },
+ directoryLister: @escaping @Sendable (String) -> [String] = { path in
+ let expanded = NSString(string: path).expandingTildeInPath
+ return (try? FileManager.default.contentsOfDirectory(atPath: expanded).map { "\(expanded)/\($0)" }) ?? []
+ }
+ ) -> GeminiOAuthClientMetadata? {
+ if
+ let clientID = environment["GEMINI_OAUTH_CLIENT_ID"], !clientID.isEmpty,
+ let clientSecret = environment["GEMINI_OAUTH_CLIENT_SECRET"], !clientSecret.isEmpty
+ {
+ return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret)
+ }
+
+ for path in candidateBundlePaths(
+ environment: environment,
+ directoryLister: directoryLister
+ ) where fileExists(path) {
+ guard
+ let source = try? fileLoader(path),
+ let metadata = parseClientMetadata(from: source)
+ else { continue }
+ return metadata
+ }
+ return nil
+ }
+
+ static func parseClientMetadata(from source: String) -> GeminiOAuthClientMetadata? {
+ guard
+ let clientID = stringLiteral(named: "OAUTH_CLIENT_ID", in: source),
+ let clientSecret = stringLiteral(named: "OAUTH_CLIENT_SECRET", in: source)
+ else { return nil }
+ return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret)
+ }
+
+ private static func stringLiteral(named variableName: String, in source: String) -> String? {
+ let pattern = #"var\s+\#(variableName)\s*=\s*\"([^\"]+)\""#
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
+ let range = NSRange(source.startIndex..= 2,
+ let valueRange = Range(match.range(at: 1), in: source)
+ else { return nil }
+ return String(source[valueRange])
+ }
+
+ private static func candidateBundlePaths(
+ environment: [String: String],
+ directoryLister: @Sendable (String) -> [String]
+ ) -> [String] {
+ var paths: [String] = []
+ if let path = environment["GEMINI_CLI_BUNDLE_PATH"], !path.isEmpty {
+ paths.append(path)
+ }
+ paths.append(contentsOf: bundleChunkPaths(
+ root: "/opt/homebrew/lib/node_modules/@google/gemini-cli/bundle",
+ directoryLister: directoryLister
+ ))
+ paths.append(contentsOf: bundleChunkPaths(
+ root: "/usr/local/lib/node_modules/@google/gemini-cli/bundle",
+ directoryLister: directoryLister
+ ))
+ return paths
+ }
+
+ private static func bundleChunkPaths(
+ root: String,
+ directoryLister: @Sendable (String) -> [String]
+ ) -> [String] {
+ directoryLister(root)
+ .filter { $0.hasSuffix(".js") }
+ .sorted()
+ }
+}
+
+public struct GeminiCodeAssistConnector: ProviderConnector {
+ public let provider: Provider = .google
+
+ private let accounts: [GeminiAccountConfiguration]
+ private let httpClient: any ConnectorHTTPClient
+ private let fileLoader: @Sendable (String) throws -> Data
+
+ public init(
+ accounts: [GeminiAccountConfiguration],
+ httpClient: any ConnectorHTTPClient = URLSessionConnectorHTTPClient(),
+ fileLoader: @escaping @Sendable (String) throws -> Data = { path in
+ try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath))
+ }
+ ) {
+ self.accounts = accounts
+ self.httpClient = httpClient
+ self.fileLoader = fileLoader
+ }
+
+ public func refresh(now: Date) async -> ConnectorRefreshResult {
+ var reports: [ProviderConnectorReport] = []
+ reports.reserveCapacity(accounts.count)
+ for account in accounts {
+ reports.append(await refresh(account: account, now: now))
+ }
+ return ConnectorRefreshResult(generatedAt: now, reports: reports)
+ }
+
+ private func refresh(account: GeminiAccountConfiguration, now: Date) async -> ProviderConnectorReport {
+ let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.authPath)
+
+ do {
+ let credentials = try JSONDecoder().decode(GeminiOAuthCredentials.self, from: try fileLoader(account.authPath))
+ let accessToken = try await refreshedAccessToken(credentials: credentials, account: account)
+ let loadResponse = try await loadCodeAssist(accessToken: accessToken, endpoint: account.codeAssistEndpoint)
+ guard let project = loadResponse.cloudaicompanionProject, !project.isEmpty else {
+ throw ConnectorError.decodingFailure("Code Assist did not return an active project; raw body redacted")
+ }
+ let quotaData = try await retrieveUserQuota(
+ accessToken: accessToken,
+ project: project,
+ endpoint: account.codeAssistEndpoint
+ )
+ let buckets = try GeminiQuotaPayloadParser.buckets(from: quotaData)
+ let limits = buckets.map {
+ $0.usageLimit(accountID: localAccountID, accountName: account.accountName, observedAt: now)
+ }
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: limits
+ )
+ } catch {
+ return ProviderConnectorReport(
+ provider: provider,
+ accountID: localAccountID,
+ accountName: account.accountName,
+ generatedAt: now,
+ limits: [],
+ status: .failure,
+ errorMessage: error.localizedDescription
+ )
+ }
+ }
+
+ private func refreshedAccessToken(credentials: GeminiOAuthCredentials, account: GeminiAccountConfiguration) async throws -> String {
+ guard let refreshToken = credentials.refreshToken, !refreshToken.isEmpty else {
+ throw ConnectorError.invalidAuth("Gemini OAuth file does not contain a refresh token")
+ }
+ let body = formEncoded([
+ "client_id": account.clientID,
+ "client_secret": account.clientSecret,
+ "refresh_token": refreshToken,
+ "grant_type": "refresh_token",
+ ])
+ let response = try await httpClient.data(for: ConnectorHTTPRequest(
+ url: account.tokenEndpoint,
+ method: "POST",
+ headers: [
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "application/json",
+ ],
+ body: body
+ ))
+ guard (200..<300).contains(response.statusCode) else {
+ throw ConnectorError.httpFailure(operation: "Gemini OAuth refresh", statusCode: response.statusCode)
+ }
+ return try JSONDecoder().decode(GeminiRefreshResponse.self, from: response.data).accessToken
+ }
+
+ private func loadCodeAssist(accessToken: String, endpoint: URL) async throws -> GeminiLoadCodeAssistResponse {
+ let body = try JSONSerialization.data(withJSONObject: [
+ "cloudaicompanionProject": NSNull(),
+ "metadata": [
+ "ideType": "IDE_UNSPECIFIED",
+ "platform": "PLATFORM_UNSPECIFIED",
+ "pluginType": "GEMINI",
+ "duetProject": NSNull(),
+ ],
+ ])
+ let response = try await httpClient.data(for: ConnectorHTTPRequest(
+ url: endpoint.appending(path: ":loadCodeAssist"),
+ method: "POST",
+ headers: jsonHeaders(accessToken: accessToken),
+ body: body
+ ))
+ guard (200..<300).contains(response.statusCode) else {
+ throw ConnectorError.httpFailure(operation: "Gemini Code Assist load", statusCode: response.statusCode)
+ }
+ return try JSONDecoder().decode(GeminiLoadCodeAssistResponse.self, from: response.data)
+ }
+
+ private func retrieveUserQuota(accessToken: String, project: String, endpoint: URL) async throws -> Data {
+ let body = try JSONSerialization.data(withJSONObject: ["project": project])
+ let response = try await httpClient.data(for: ConnectorHTTPRequest(
+ url: endpoint.appending(path: ":retrieveUserQuota"),
+ method: "POST",
+ headers: jsonHeaders(accessToken: accessToken),
+ body: body
+ ))
+ guard (200..<300).contains(response.statusCode) else {
+ throw ConnectorError.httpFailure(operation: "Gemini Code Assist quota", statusCode: response.statusCode)
+ }
+ return response.data
+ }
+}
+
+private struct GeminiQuotaPayload: Decodable {
+ let buckets: [GeminiQuotaBucketPayload]
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ buckets = try container.decodeIfPresent([GeminiQuotaBucketPayload].self, forKey: .buckets) ?? []
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case buckets
+ }
+}
+
+private struct GeminiQuotaBucketPayload: Decodable {
+ let modelID: String
+ let remainingFraction: Double?
+ let remainingAmount: Int?
+ let resetTime: Date?
+
+ enum CodingKeys: String, CodingKey {
+ case modelID = "modelId"
+ case remainingFraction
+ case remainingAmount
+ case resetTime
+ }
+
+ var normalizedBucket: GeminiQuotaBucket {
+ GeminiQuotaBucket(
+ modelID: modelID,
+ remainingFraction: remainingFraction,
+ remainingAmount: remainingAmount,
+ resetsAt: resetTime
+ )
+ }
+}
+
+extension JSONDecoder {
+ static var contextPanelISO8601: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .custom { decoder in
+ let container = try decoder.singleValueContainer()
+ let value = try container.decode(String.self)
+ if let date = ContextPanelDateFormatting.date(from: value) {
+ return date
+ }
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Expected ISO 8601 date string"
+ )
+ }
+ return decoder
+ }
+}
+
+public enum ContextPanelDateFormatting {
+ public static func string(from date: Date) -> String {
+ internetDateFormatter().string(from: date)
+ }
+
+ public static func date(from value: String) -> Date? {
+ internetDateFormatterWithFractionalSeconds().date(from: value)
+ ?? internetDateFormatter().date(from: value)
+ ?? dateOnlyFormatter().date(from: value)
+ }
+
+ private static func internetDateFormatter() -> ISO8601DateFormatter {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter
+ }
+
+ private static func internetDateFormatterWithFractionalSeconds() -> ISO8601DateFormatter {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }
+
+ private static func dateOnlyFormatter() -> ISO8601DateFormatter {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withFullDate]
+ return formatter
+ }
+}
+
+func formEncoded(_ values: [String: String]) -> Data {
+ values
+ .map { key, value in
+ "\(urlFormEscape(key))=\(urlFormEscape(value))"
+ }
+ .joined(separator: "&")
+ .data(using: .utf8) ?? Data()
+}
+
+func urlFormEscape(_ value: String) -> String {
+ var allowed = CharacterSet.urlQueryAllowed
+ allowed.remove(charactersIn: "&+=")
+ return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
+}
+
+private func jsonHeaders(accessToken: String) -> [String: String] {
+ [
+ "Authorization": "Bearer \(accessToken)",
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ ]
+}
diff --git a/Sources/ContextPanelCore/LimitProbe.swift b/Sources/ContextPanelCore/LimitProbe.swift
new file mode 100644
index 0000000..144c9e0
--- /dev/null
+++ b/Sources/ContextPanelCore/LimitProbe.swift
@@ -0,0 +1,260 @@
+import Foundation
+
+public enum ProbeSource: String, Codable, Equatable, Sendable {
+ case visibleText
+ case networkMetadata
+ case redactedResponseShape
+ case manualUserEntry
+}
+
+public enum ProbeSignalKind: String, Codable, Equatable, Sendable {
+ case resetLanguage
+ case relativeDuration
+ case usagePressure
+ case limitReached
+ case modelAvailability
+ case planLanguage
+ case candidateFieldName
+}
+
+public struct LimitProbeObservation: Codable, Equatable, Identifiable, Sendable {
+ public let id: UUID
+ public let provider: Provider
+ public let observedAt: Date
+ public let source: ProbeSource
+ public let signalKind: ProbeSignalKind
+ public let confidence: UsageConfidence
+ public let sanitizedEvidence: String
+
+ public init(
+ id: UUID = UUID(),
+ provider: Provider,
+ observedAt: Date,
+ source: ProbeSource,
+ signalKind: ProbeSignalKind,
+ confidence: UsageConfidence,
+ sanitizedEvidence: String
+ ) {
+ self.id = id
+ self.provider = provider
+ self.observedAt = observedAt
+ self.source = source
+ self.signalKind = signalKind
+ self.confidence = confidence
+ self.sanitizedEvidence = EvidenceRedactor.redact(sanitizedEvidence)
+ }
+}
+
+public struct LimitProbeReport: Codable, Equatable, Sendable {
+ public let schemaVersion: Int
+ public let provider: Provider
+ public let capturedAt: Date
+ public let observations: [LimitProbeObservation]
+ public let networkEvents: [NetworkProbeEvent]
+ public let redactions: [String]
+
+ public init(
+ provider: Provider,
+ capturedAt: Date,
+ observations: [LimitProbeObservation],
+ networkEvents: [NetworkProbeEvent] = []
+ ) {
+ self.schemaVersion = 1
+ self.provider = provider
+ self.capturedAt = capturedAt
+ self.observations = observations
+ self.networkEvents = networkEvents
+ self.redactions = [
+ "cookies",
+ "authorization headers",
+ "bearer tokens",
+ "session identifiers",
+ "emails",
+ "account identifiers",
+ "raw response bodies"
+ ]
+ }
+
+ public var markdownSummary: String {
+ var lines = [
+ "# Limit Probe Report",
+ "",
+ "- Provider: \(provider.displayName)",
+ "- Captured: \(capturedAt.ISO8601Format())",
+ "- Observations: \(observations.count)",
+ "",
+ "## Observations"
+ ]
+
+ if observations.isEmpty {
+ lines.append("- No candidate limit signals found.")
+ } else {
+ for observation in observations {
+ lines.append("- `\(observation.signalKind.rawValue)` from `\(observation.source.rawValue)`: \(observation.sanitizedEvidence)")
+ }
+ }
+
+ lines.append(contentsOf: [
+ "",
+ "## Network Candidates"
+ ])
+
+ if networkEvents.isEmpty {
+ lines.append("- No candidate response shapes found.")
+ } else {
+ for event in networkEvents {
+ let status = event.status.map(String.init) ?? "?"
+ let bodySize = event.bodySize.map { "\($0)b" } ?? "unknown size"
+ let fields = event.matchedFields.joined(separator: ", ")
+ lines.append("- `\(event.method) \(event.pathHint)` status `\(status)`, `\(bodySize)`: \(fields)")
+ }
+ }
+
+ lines.append(contentsOf: [
+ "",
+ "## Redactions",
+ redactions.map { "- \($0)" }.joined(separator: "\n")
+ ])
+
+ return lines.joined(separator: "\n")
+ }
+}
+
+public struct NetworkProbeEvent: Codable, Equatable, Identifiable, Sendable {
+ public let id: UUID
+ public let observedAt: Date
+ public let method: String
+ public let pathHint: String
+ public let status: Int?
+ public let contentType: String?
+ public let bodySize: Int?
+ public let matchedFields: [String]
+
+ public init(
+ id: UUID = UUID(),
+ observedAt: Date,
+ method: String,
+ pathHint: String,
+ status: Int?,
+ contentType: String?,
+ bodySize: Int?,
+ matchedFields: [String]
+ ) {
+ self.id = id
+ self.observedAt = observedAt
+ self.method = EvidenceRedactor.redact(method)
+ self.pathHint = EvidenceRedactor.redactPath(pathHint)
+ self.status = status
+ self.contentType = EvidenceRedactor.redact(contentType ?? "")
+ self.bodySize = bodySize
+ self.matchedFields = matchedFields.map(EvidenceRedactor.redact).sorted()
+ }
+}
+
+public enum LimitProbeScanner {
+ private static let patterns: [(ProbeSignalKind, NSRegularExpression)] = [
+ (.resetLanguage, regex(#"(?i)\b(reset|resets|refresh|refreshes|available again)\b.{0,80}"#)),
+ (.relativeDuration, regex(#"(?i)\b(in\s+)?\d+\s*(m|min|mins|minutes|h|hr|hrs|hour|hours|day|days|week|weeks)\b"#)),
+ (.usagePressure, regex(#"(?i)\b\d+[\d,]*\s*(%|percent|tokens?)\s*(used|every|per|/)?\s*\d*\s*(hours?|days?|weeks?)?\b"#)),
+ (.limitReached, regex(#"(?i)\b(limit reached|reached your limit|you.ve reached|unavailable|try again)\b.{0,80}"#)),
+ (.modelAvailability, regex(#"(?i)\b(GPT|Thinking|fast mode|model picker|available|unavailable)\b.{0,80}"#)),
+ (.planLanguage, regex(#"(?i)\b(Free|Plus|Pro|Team|Business|Enterprise|Go)\b.{0,80}"#))
+ ]
+
+ public static func scanVisibleText(
+ _ text: String,
+ provider: Provider,
+ observedAt: Date = Date()
+ ) -> [LimitProbeObservation] {
+ let normalized = text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ let range = NSRange(normalized.startIndex..()
+
+ for (kind, pattern) in patterns {
+ for match in pattern.matches(in: normalized, range: range).prefix(8) {
+ guard let matchRange = Range(match.range, in: normalized) else { continue }
+ let evidence = String(normalized[matchRange]).trimmingCharacters(in: .whitespacesAndNewlines)
+ let key = "\(kind.rawValue):\(evidence.lowercased())"
+ guard !seen.contains(key) else { continue }
+ seen.insert(key)
+ observations.append(
+ LimitProbeObservation(
+ provider: provider,
+ observedAt: observedAt,
+ source: .visibleText,
+ signalKind: kind,
+ confidence: .observed,
+ sanitizedEvidence: evidence
+ )
+ )
+ }
+ }
+
+ return observations
+ }
+
+ public static func scanResponseShape(
+ fieldNames: [String],
+ provider: Provider,
+ observedAt: Date = Date()
+ ) -> [LimitProbeObservation] {
+ let candidates = fieldNames.filter { field in
+ let lower = field.lowercased()
+ return ["limit", "usage", "percent", "token", "remaining", "reset", "quota", "model", "plan", "cap"].contains { lower.contains($0) }
+ }
+
+ return Array(Set(candidates)).sorted().map { field in
+ LimitProbeObservation(
+ provider: provider,
+ observedAt: observedAt,
+ source: .redactedResponseShape,
+ signalKind: .candidateFieldName,
+ confidence: .observed,
+ sanitizedEvidence: field
+ )
+ }
+ }
+
+ private static func regex(_ pattern: String) -> NSRegularExpression {
+ do {
+ return try NSRegularExpression(pattern: pattern)
+ } catch {
+ preconditionFailure("Invalid probe regex: \(pattern)")
+ }
+ }
+}
+
+public enum EvidenceRedactor {
+ private static let redactionPatterns: [(String, String)] = [
+ (#"(?i)bearer\s+[a-z0-9._\-]+"#, "bearer [redacted]"),
+ (#"(?i)(authorization|cookie|set-cookie|csrf|session|token)[:=]\s*[^\s,;]+"#, "$1=[redacted]"),
+ (#"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, "[email redacted]"),
+ (#"\bsk-[A-Za-z0-9_\-]{12,}\b"#, "[api key redacted]"),
+ (#"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"#, "[id]"),
+ (#"(?<=\.)[A-Za-z0-9_-]{20,}(?=\.)"#, "[id]")
+ ]
+
+ public static func redact(_ value: String) -> String {
+ var redacted = value
+ for (pattern, replacement) in redactionPatterns {
+ redacted = redacted.replacingOccurrences(
+ of: pattern,
+ with: replacement,
+ options: [.regularExpression, .caseInsensitive]
+ )
+ }
+ return redacted
+ }
+
+ public static func redactPath(_ value: String) -> String {
+ guard let components = URLComponents(string: value) else {
+ return redact(value)
+ }
+ let path = components.path
+ .replacingOccurrences(of: #"/[0-9a-fA-F-]{16,}"#, with: "/[id]", options: .regularExpression)
+ .replacingOccurrences(of: #"/[A-Za-z0-9_-]{24,}"#, with: "/[id]", options: .regularExpression)
+ return redact(path.isEmpty ? value : path)
+ }
+}
diff --git a/Sources/ContextPanelCore/ProviderConnector.swift b/Sources/ContextPanelCore/ProviderConnector.swift
new file mode 100644
index 0000000..b8b8978
--- /dev/null
+++ b/Sources/ContextPanelCore/ProviderConnector.swift
@@ -0,0 +1,198 @@
+import Foundation
+
+public enum ConnectorError: LocalizedError, Equatable, Sendable {
+ case missingAuth(String)
+ case invalidAuth(String)
+ case httpFailure(operation: String, statusCode: Int)
+ case nonHTTPResponse(String)
+ case processFailure(operation: String, exitCode: Int32)
+ case decodingFailure(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .missingAuth(message), let .invalidAuth(message), let .nonHTTPResponse(message), let .decodingFailure(message):
+ message
+ case let .httpFailure(operation, statusCode):
+ "\(operation) returned HTTP \(statusCode); raw body redacted"
+ case let .processFailure(operation, exitCode):
+ "\(operation) failed with exit code \(exitCode); stderr redacted"
+ }
+ }
+}
+
+public struct ProviderConnectorReport: Equatable, Sendable {
+ public let provider: Provider
+ public let accountID: String
+ public let accountName: String
+ public let generatedAt: Date
+ public let limits: [UsageLimit]
+ public let status: UsageStatus
+ public let errorMessage: String?
+
+ public init(
+ provider: Provider,
+ accountID: String,
+ accountName: String,
+ generatedAt: Date,
+ limits: [UsageLimit],
+ status: UsageStatus? = nil,
+ errorMessage: String? = nil
+ ) {
+ self.provider = provider
+ self.accountID = accountID
+ self.accountName = accountName
+ self.generatedAt = generatedAt
+ self.limits = limits
+ self.status = status ?? UsageSnapshot(generatedAt: generatedAt, limits: limits).aggregateStatus
+ self.errorMessage = errorMessage.map(ConnectorRedactor.redact)
+ }
+}
+
+public struct ConnectorRefreshResult: Equatable, Sendable {
+ public let generatedAt: Date
+ public let reports: [ProviderConnectorReport]
+
+ public init(generatedAt: Date, reports: [ProviderConnectorReport]) {
+ self.generatedAt = generatedAt
+ self.reports = reports
+ }
+
+ public var snapshot: UsageSnapshot {
+ UsageSnapshot(generatedAt: generatedAt, limits: reports.flatMap(\.limits))
+ }
+}
+
+public protocol ProviderConnector: Sendable {
+ var provider: Provider { get }
+
+ func refresh(now: Date) async -> ConnectorRefreshResult
+}
+
+public struct ProviderConnectorRuntime: Sendable {
+ private let connectors: [any ProviderConnector]
+
+ public init(connectors: [any ProviderConnector]) {
+ self.connectors = connectors
+ }
+
+ public func refreshAll(now: Date = Date()) async -> ConnectorRefreshResult {
+ var reports: [ProviderConnectorReport] = []
+ for connector in connectors {
+ let result = await connector.refresh(now: now)
+ reports.append(contentsOf: result.reports)
+ }
+ return ConnectorRefreshResult(generatedAt: now, reports: reports)
+ }
+}
+
+public struct ConnectorHTTPRequest: Sendable {
+ public let url: URL
+ public let method: String
+ public let headers: [String: String]
+ public let body: Data?
+
+ public init(url: URL, method: String, headers: [String: String] = [:], body: Data? = nil) {
+ self.url = url
+ self.method = method
+ self.headers = headers
+ self.body = body
+ }
+}
+
+public struct ConnectorHTTPResponse: Sendable {
+ public let statusCode: Int
+ public let data: Data
+
+ public init(statusCode: Int, data: Data) {
+ self.statusCode = statusCode
+ self.data = data
+ }
+}
+
+public protocol ConnectorHTTPClient: Sendable {
+ func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse
+}
+
+public struct URLSessionConnectorHTTPClient: ConnectorHTTPClient {
+ public init() {}
+
+ public func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse {
+ var urlRequest = URLRequest(url: request.url)
+ urlRequest.httpMethod = request.method
+ urlRequest.httpBody = request.body
+ for (key, value) in request.headers {
+ urlRequest.setValue(value, forHTTPHeaderField: key)
+ }
+
+ let (data, response) = try await URLSession.shared.data(for: urlRequest)
+ guard let http = response as? HTTPURLResponse else {
+ throw ConnectorError.nonHTTPResponse("provider request returned a non-HTTP response")
+ }
+ return ConnectorHTTPResponse(statusCode: http.statusCode, data: data)
+ }
+}
+
+public struct ConnectorProcessResult: Sendable {
+ public let exitCode: Int32
+ public let stdout: Data
+
+ public init(exitCode: Int32, stdout: Data) {
+ self.exitCode = exitCode
+ self.stdout = stdout
+ }
+}
+
+public protocol ConnectorProcessClient: Sendable {
+ func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult
+}
+
+public struct DefaultConnectorProcessClient: ConnectorProcessClient {
+ public init() {}
+
+ public func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+ process.arguments = [executable] + arguments
+
+ let output = Pipe()
+ let error = Pipe()
+ process.standardOutput = output
+ process.standardError = error
+
+ try process.run()
+ process.waitUntilExit()
+
+ return ConnectorProcessResult(
+ exitCode: process.terminationStatus,
+ stdout: output.fileHandleForReading.readDataToEndOfFile()
+ )
+ }
+}
+
+public enum ConnectorRedactor {
+ public static func redact(_ value: String) -> String {
+ EvidenceRedactor.redact(value)
+ }
+
+ public static func redactedPath(_ path: String) -> String {
+ let expanded = NSString(string: path).expandingTildeInPath
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ if expanded.hasPrefix(home) {
+ return "~" + expanded.dropFirst(home.count)
+ }
+ return URL(fileURLWithPath: expanded).lastPathComponent
+ }
+
+ public static func localAccountID(provider: Provider, path: String) -> String {
+ "\(provider.rawValue)-\(fnv1a(path))"
+ }
+
+ private static func fnv1a(_ value: String) -> String {
+ var hash: UInt64 = 14_695_981_039_346_656_037
+ for byte in value.utf8 {
+ hash ^= UInt64(byte)
+ hash &*= 1_099_511_628_211
+ }
+ return String(hash, radix: 16)
+ }
+}
diff --git a/Sources/ContextPanelCore/SnapshotStore.swift b/Sources/ContextPanelCore/SnapshotStore.swift
new file mode 100644
index 0000000..d76dd06
--- /dev/null
+++ b/Sources/ContextPanelCore/SnapshotStore.swift
@@ -0,0 +1,263 @@
+import Foundation
+
+public struct StoredUsageSnapshot: Codable, Equatable, Sendable {
+ public let schemaVersion: Int
+ public let savedAt: Date
+ public let snapshot: UsageSnapshot
+ public let reports: [StoredProviderReport]
+
+ public init(savedAt: Date, snapshot: UsageSnapshot, reports: [StoredProviderReport] = []) {
+ self.schemaVersion = 1
+ self.savedAt = savedAt
+ self.snapshot = snapshot
+ self.reports = reports
+ }
+
+ public init(savedAt: Date, refreshResult: ConnectorRefreshResult) {
+ self.init(
+ savedAt: savedAt,
+ snapshot: refreshResult.snapshot,
+ reports: refreshResult.reports.map(StoredProviderReport.init(report:))
+ )
+ }
+}
+
+public struct StoredProviderReport: Codable, Equatable, Sendable {
+ public let provider: Provider
+ public let accountID: String
+ public let accountName: String
+ public let generatedAt: Date
+ public let status: UsageStatus
+ public let errorMessage: String?
+
+ public init(
+ provider: Provider,
+ accountID: String,
+ accountName: String,
+ generatedAt: Date,
+ status: UsageStatus,
+ errorMessage: String?
+ ) {
+ self.provider = provider
+ self.accountID = accountID
+ self.accountName = accountName
+ self.generatedAt = generatedAt
+ self.status = status
+ self.errorMessage = errorMessage.map(ConnectorRedactor.redact)
+ }
+
+ public init(report: ProviderConnectorReport) {
+ self.init(
+ provider: report.provider,
+ accountID: report.accountID,
+ accountName: report.accountName,
+ generatedAt: report.generatedAt,
+ status: report.status,
+ errorMessage: report.errorMessage
+ )
+ }
+}
+
+public enum SnapshotStoreError: LocalizedError, Equatable, Sendable {
+ case unsupportedSchema(version: Int)
+ case corruptStore(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .unsupportedSchema(version):
+ "Unsupported snapshot schema version \(version)"
+ case let .corruptStore(message):
+ message
+ }
+ }
+}
+
+public struct SnapshotStoreLoadResult: Equatable, Sendable {
+ public let snapshot: StoredUsageSnapshot?
+ public let status: UsageStatus
+ public let errorMessage: String?
+
+ public init(snapshot: StoredUsageSnapshot?, status: UsageStatus, errorMessage: String? = nil) {
+ self.snapshot = snapshot
+ self.status = status
+ self.errorMessage = errorMessage.map(ConnectorRedactor.redact)
+ }
+}
+
+public struct SnapshotStoreQuery: Equatable, Sendable {
+ public let provider: Provider?
+ public let accountID: String?
+ public let since: Date?
+ public let limit: Int?
+
+ public init(provider: Provider? = nil, accountID: String? = nil, since: Date? = nil, limit: Int? = nil) {
+ self.provider = provider
+ self.accountID = accountID
+ self.since = since
+ self.limit = limit
+ }
+}
+
+public struct SnapshotStoreStalenessPolicy: Equatable, Sendable {
+ public let maximumAge: TimeInterval
+
+ public init(maximumAge: TimeInterval = 15 * 60) {
+ precondition(maximumAge >= 0, "maximumAge must not be negative")
+ self.maximumAge = maximumAge
+ }
+
+ public func status(for storedSnapshot: StoredUsageSnapshot?, now: Date) -> UsageStatus {
+ guard let storedSnapshot else { return .unknown }
+ if now.timeIntervalSince(storedSnapshot.savedAt) > maximumAge {
+ return .stale
+ }
+ return storedSnapshot.snapshot.aggregateStatus
+ }
+}
+
+public struct JSONSnapshotStore: Sendable {
+ public let rootDirectory: URL
+
+ public init(rootDirectory: URL) {
+ self.rootDirectory = rootDirectory
+ }
+
+ public var currentSnapshotURL: URL {
+ rootDirectory.appending(path: "current-snapshot.json")
+ }
+
+ public var historyDirectoryURL: URL {
+ rootDirectory.appending(path: "history", directoryHint: .isDirectory)
+ }
+
+ public func save(_ storedSnapshot: StoredUsageSnapshot) throws {
+ try ensureDirectories()
+ let data = try Self.makeEncoder().encode(storedSnapshot)
+ try data.write(to: currentSnapshotURL, options: [.atomic])
+ let historyURL = historyURL(for: storedSnapshot.savedAt)
+ try data.write(to: historyURL, options: [.atomic])
+ }
+
+ public func saveMerged(refreshResult: ConnectorRefreshResult, savedAt: Date) throws {
+ let replacementAccounts = Set(
+ refreshResult.reports.map { ProviderAccountKey(provider: $0.provider, accountID: $0.accountID) }
+ )
+ let current = loadCurrent().snapshot
+ let preservedLimits = current?.snapshot.limits.filter { limit in
+ !replacementAccounts.contains(ProviderAccountKey(provider: limit.provider, accountID: limit.accountID))
+ } ?? []
+ let preservedReports = current?.reports.filter { report in
+ !replacementAccounts.contains(ProviderAccountKey(provider: report.provider, accountID: report.accountID))
+ } ?? []
+
+ let mergedSnapshot = UsageSnapshot(
+ generatedAt: refreshResult.generatedAt,
+ limits: preservedLimits + refreshResult.snapshot.limits
+ )
+ let mergedReports = preservedReports + refreshResult.reports.map(StoredProviderReport.init(report:))
+
+ try save(StoredUsageSnapshot(savedAt: savedAt, snapshot: mergedSnapshot, reports: mergedReports))
+ }
+
+ public func loadCurrent() -> SnapshotStoreLoadResult {
+ guard FileManager.default.fileExists(atPath: currentSnapshotURL.path) else {
+ return SnapshotStoreLoadResult(snapshot: nil, status: .unknown)
+ }
+
+ do {
+ let snapshot = try loadSnapshot(from: currentSnapshotURL)
+ return SnapshotStoreLoadResult(snapshot: snapshot, status: snapshot.snapshot.aggregateStatus)
+ } catch {
+ return SnapshotStoreLoadResult(
+ snapshot: nil,
+ status: .failure,
+ errorMessage: error.localizedDescription
+ )
+ }
+ }
+
+ public func loadCurrent(policy: SnapshotStoreStalenessPolicy, now: Date = Date()) -> SnapshotStoreLoadResult {
+ let result = loadCurrent()
+ guard result.status != .failure else { return result }
+ return SnapshotStoreLoadResult(
+ snapshot: result.snapshot,
+ status: policy.status(for: result.snapshot, now: now),
+ errorMessage: result.errorMessage
+ )
+ }
+
+ public func loadHistory(query: SnapshotStoreQuery = SnapshotStoreQuery()) -> [StoredUsageSnapshot] {
+ guard let urls = try? FileManager.default.contentsOfDirectory(
+ at: historyDirectoryURL,
+ includingPropertiesForKeys: nil
+ ) else {
+ return []
+ }
+
+ let snapshots = urls
+ .filter { $0.pathExtension == "json" }
+ .compactMap { try? loadSnapshot(from: $0) }
+ .filter { snapshot in
+ if let since = query.since, snapshot.savedAt < since { return false }
+ if let provider = query.provider, !snapshot.snapshot.limits.contains(where: { $0.provider == provider }) {
+ return false
+ }
+ if let accountID = query.accountID, !snapshot.snapshot.limits.contains(where: { $0.accountID == accountID }) {
+ return false
+ }
+ return true
+ }
+ .sorted { $0.savedAt > $1.savedAt }
+
+ if let limit = query.limit {
+ return Array(snapshots.prefix(max(limit, 0)))
+ }
+ return snapshots
+ }
+
+ private func ensureDirectories() throws {
+ try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: historyDirectoryURL, withIntermediateDirectories: true)
+ }
+
+ private func loadSnapshot(from url: URL) throws -> StoredUsageSnapshot {
+ let data = try Data(contentsOf: url)
+ let snapshot = try Self.makeDecoder().decode(StoredUsageSnapshot.self, from: data)
+ guard snapshot.schemaVersion == 1 else {
+ throw SnapshotStoreError.unsupportedSchema(version: snapshot.schemaVersion)
+ }
+ return snapshot
+ }
+
+ private func historyURL(for date: Date) -> URL {
+ let timestamp = ContextPanelDateFormatting.historyFileTimestamp(from: date)
+ return historyDirectoryURL.appending(path: "\(timestamp).json")
+ }
+
+ private static func makeEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
+ encoder.dateEncodingStrategy = .iso8601
+ return encoder
+ }
+
+ private static func makeDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
+
+private struct ProviderAccountKey: Hashable {
+ let provider: Provider
+ let accountID: String
+}
+
+extension ContextPanelDateFormatting {
+ static func historyFileTimestamp(from date: Date) -> String {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.string(from: date)
+ .replacingOccurrences(of: ":", with: "-")
+ }
+}
diff --git a/Sources/ContextPanelCore/UsageLimit.swift b/Sources/ContextPanelCore/UsageLimit.swift
new file mode 100644
index 0000000..21cc432
--- /dev/null
+++ b/Sources/ContextPanelCore/UsageLimit.swift
@@ -0,0 +1,279 @@
+import Foundation
+
+public enum Provider: String, CaseIterable, Codable, Equatable, Identifiable, Sendable {
+ case openAI = "openai"
+ case anthropic
+ case google
+
+ public var id: String { rawValue }
+
+ public var displayName: String {
+ switch self {
+ case .openAI:
+ "OpenAI"
+ case .anthropic:
+ "Anthropic"
+ case .google:
+ "Google"
+ }
+ }
+
+ public var shortName: String {
+ switch self {
+ case .openAI:
+ "OAI"
+ case .anthropic:
+ "ANT"
+ case .google:
+ "GOO"
+ }
+ }
+}
+
+public enum UsageStatus: String, Codable, Equatable, Sendable {
+ case healthy
+ case close
+ case limited
+ case stale
+ case unknown
+ case failure
+ case loading
+}
+
+public enum UsageConfidence: String, Codable, Equatable, Sendable {
+ case official
+ case observed
+ case manual
+ case estimated
+ case unknown
+}
+
+public enum UsageUnit: String, Codable, Equatable, Sendable {
+ case percent
+ case tokens
+ case requests
+ case credits
+ case units
+ case unknown
+}
+
+public struct ProviderAccount: Codable, Equatable, Identifiable, Sendable {
+ public let id: String
+ public let provider: Provider
+ public let name: String
+ public let isEnabled: Bool
+
+ public init(id: String, provider: Provider, name: String, isEnabled: Bool = true) {
+ self.id = id
+ self.provider = provider
+ self.name = name
+ self.isEnabled = isEnabled
+ }
+}
+
+public struct UsageLimit: Codable, Equatable, Identifiable, Sendable {
+ public let id: String
+ public let provider: Provider
+ public let accountID: String
+ public let accountName: String
+ public let label: String
+ public let windowLabel: String?
+ public let modelLabel: String?
+ public let unit: UsageUnit
+ public let used: Int?
+ public let limit: Int?
+ public let resetsAt: Date?
+ public let lastUpdatedAt: Date?
+ public let confidence: UsageConfidence
+ public let statusOverride: UsageStatus?
+ public let note: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case provider
+ case accountID
+ case accountName
+ case label
+ case windowLabel
+ case modelLabel
+ case unit
+ case used
+ case limit
+ case resetsAt
+ case lastUpdatedAt
+ case confidence
+ case statusOverride
+ case note
+ }
+
+ public init(
+ id: String? = nil,
+ provider: Provider,
+ accountID: String,
+ accountName: String,
+ label: String,
+ windowLabel: String? = nil,
+ modelLabel: String? = nil,
+ unit: UsageUnit = .units,
+ used: Int?,
+ limit: Int?,
+ resetsAt: Date? = nil,
+ lastUpdatedAt: Date? = nil,
+ confidence: UsageConfidence = .official,
+ statusOverride: UsageStatus? = nil,
+ note: String? = nil
+ ) {
+ if let used {
+ precondition(used >= 0, "used must not be negative")
+ }
+ if let limit {
+ precondition(limit > 0, "limit must be positive")
+ }
+
+ self.id = id ?? "\(provider.rawValue):\(accountID):\(label)"
+ self.provider = provider
+ self.accountID = accountID
+ self.accountName = accountName
+ self.label = label
+ self.windowLabel = windowLabel
+ self.modelLabel = modelLabel
+ self.unit = unit
+ self.used = used
+ self.limit = limit
+ self.resetsAt = resetsAt
+ self.lastUpdatedAt = lastUpdatedAt
+ self.confidence = confidence
+ self.statusOverride = statusOverride
+ self.note = note
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(String.self, forKey: .id)
+ provider = try container.decode(Provider.self, forKey: .provider)
+ accountID = try container.decode(String.self, forKey: .accountID)
+ accountName = try container.decode(String.self, forKey: .accountName)
+ label = try container.decode(String.self, forKey: .label)
+ windowLabel = try container.decodeIfPresent(String.self, forKey: .windowLabel)
+ modelLabel = try container.decodeIfPresent(String.self, forKey: .modelLabel)
+ unit = try container.decode(UsageUnit.self, forKey: .unit)
+ used = try container.decodeIfPresent(Int.self, forKey: .used)
+ limit = try container.decodeIfPresent(Int.self, forKey: .limit)
+ resetsAt = try container.decodeIfPresent(Date.self, forKey: .resetsAt)
+ lastUpdatedAt = try container.decodeIfPresent(Date.self, forKey: .lastUpdatedAt)
+ confidence = try container.decode(UsageConfidence.self, forKey: .confidence)
+ statusOverride = try container.decodeIfPresent(UsageStatus.self, forKey: .statusOverride)
+ note = try container.decodeIfPresent(String.self, forKey: .note)
+ }
+
+ public init(provider: Provider, label: String, used: Int, limit: Int, resetsAt: Date? = nil) {
+ self.init(
+ provider: provider,
+ accountID: "default",
+ accountName: provider.displayName,
+ label: label,
+ unit: .units,
+ used: used,
+ limit: limit,
+ resetsAt: resetsAt,
+ confidence: .official
+ )
+ }
+
+ public var remaining: Int? {
+ guard let used, let limit else { return nil }
+ return max(limit - used, 0)
+ }
+
+ public var usageRatio: Double? {
+ guard let used, let limit else { return nil }
+ return min(Double(used) / Double(limit), 1)
+ }
+
+ public var displayLabel: String {
+ windowLabel ?? label
+ }
+
+ public var contextLabel: String {
+ [modelLabel, accountName]
+ .compactMap { value in
+ guard let value, !value.isEmpty else { return nil }
+ return value
+ }
+ .joined(separator: " ยท ")
+ }
+
+ public var status: UsageStatus {
+ if let statusOverride {
+ return statusOverride
+ }
+ guard let ratio = usageRatio else {
+ return .unknown
+ }
+ if ratio >= 1 {
+ return .limited
+ }
+ if ratio >= 0.8 {
+ return .close
+ }
+ return .healthy
+ }
+}
+
+public struct UsageSnapshot: Codable, Equatable, Sendable {
+ public let generatedAt: Date
+ public let limits: [UsageLimit]
+
+ public init(generatedAt: Date, limits: [UsageLimit]) {
+ self.generatedAt = generatedAt
+ self.limits = limits
+ }
+
+ public var mostConstrainedLimits: [UsageLimit] {
+ limits.sorted { lhs, rhs in
+ let lhsScore = lhs.constraintScore
+ let rhsScore = rhs.constraintScore
+ if lhsScore != rhsScore {
+ return lhsScore > rhsScore
+ }
+ return (lhs.usageRatio ?? -1) > (rhs.usageRatio ?? -1)
+ }
+ }
+
+ public var aggregateCapacityRatio: Double {
+ let ratios = limits.compactMap(\.usageRatio)
+ guard !ratios.isEmpty else { return 0 }
+ return max(1 - (ratios.max() ?? 0), 0)
+ }
+
+ public var aggregateStatus: UsageStatus {
+ mostConstrainedLimits.first?.status ?? .unknown
+ }
+}
+
+extension UsageStatus {
+ fileprivate var sortRank: Double {
+ switch self {
+ case .limited:
+ 2
+ case .failure:
+ 1.3
+ case .close:
+ 1
+ case .stale:
+ 0.4
+ case .unknown:
+ 0.3
+ case .loading:
+ 0.2
+ case .healthy:
+ 0
+ }
+ }
+}
+
+extension UsageLimit {
+ fileprivate var constraintScore: Double {
+ status.sortRank + (usageRatio ?? 0)
+ }
+}
diff --git a/Sources/ContextPanelCore/WidgetSnapshot.swift b/Sources/ContextPanelCore/WidgetSnapshot.swift
new file mode 100644
index 0000000..5f0cd8d
--- /dev/null
+++ b/Sources/ContextPanelCore/WidgetSnapshot.swift
@@ -0,0 +1,139 @@
+import Foundation
+
+public enum WidgetSnapshotState: String, Codable, Equatable, Sendable {
+ case ready
+ case setupNeeded
+ case stale
+ case failure
+}
+
+public struct WidgetSnapshot: Codable, Equatable, Sendable {
+ public let state: WidgetSnapshotState
+ public let generatedAt: Date
+ public let limits: [UsageLimit]
+ public let reports: [StoredProviderReport]
+ public let status: UsageStatus
+ public let message: String
+
+ public init(
+ state: WidgetSnapshotState,
+ generatedAt: Date,
+ limits: [UsageLimit],
+ reports: [StoredProviderReport] = [],
+ status: UsageStatus,
+ message: String
+ ) {
+ self.state = state
+ self.generatedAt = generatedAt
+ self.limits = limits
+ self.reports = reports
+ self.status = status
+ self.message = message
+ }
+
+ public var usageSnapshot: UsageSnapshot {
+ UsageSnapshot(generatedAt: generatedAt, limits: limits)
+ }
+
+ public var mostConstrainedLimits: [UsageLimit] {
+ usageSnapshot.mostConstrainedLimits
+ }
+
+ public var aggregateCapacityRatio: Double {
+ usageSnapshot.aggregateCapacityRatio
+ }
+
+ public var providerSummaries: [ProviderSummary] {
+ Provider.allCases.map { provider in
+ let providerLimits = limits.filter { $0.provider == provider }
+ return ProviderSummary(
+ provider: provider,
+ limitCount: providerLimits.count,
+ status: providerLimits.map(\.status).contextPanelWorstStatus,
+ capacityRatio: capacityRatio(for: providerLimits)
+ )
+ }
+ }
+
+ public static func fromStore(
+ _ result: SnapshotStoreLoadResult,
+ now: Date = Date()
+ ) -> WidgetSnapshot {
+ guard let stored = result.snapshot else {
+ return WidgetSnapshot(
+ state: result.status == .failure ? .failure : .setupNeeded,
+ generatedAt: now,
+ limits: [],
+ status: result.status,
+ message: result.errorMessage ?? "Set up Context Panel in the app."
+ )
+ }
+
+ let state: WidgetSnapshotState = switch result.status {
+ case .failure:
+ .failure
+ case .stale:
+ .stale
+ default:
+ .ready
+ }
+
+ return WidgetSnapshot(
+ state: state,
+ generatedAt: stored.snapshot.generatedAt,
+ limits: stored.snapshot.limits,
+ reports: stored.reports,
+ status: result.status,
+ message: message(state: state, stored: stored)
+ )
+ }
+
+ private static func message(state: WidgetSnapshotState, stored: StoredUsageSnapshot) -> String {
+ switch state {
+ case .ready:
+ let limitedCount = stored.snapshot.limits.filter { $0.status == .limited }.count
+ if limitedCount > 0 {
+ return "\(limitedCount) limit needs attention."
+ }
+ return "You're good to keep working."
+ case .setupNeeded:
+ return "Set up Context Panel in the app."
+ case .stale:
+ return "Last snapshot is stale."
+ case .failure:
+ return "Refresh failed."
+ }
+ }
+
+ private func capacityRatio(for limits: [UsageLimit]) -> Double {
+ let ratios = limits.compactMap(\.usageRatio)
+ guard !ratios.isEmpty else { return 0 }
+ return max(1 - (ratios.max() ?? 0), 0)
+ }
+}
+
+public struct ProviderSummary: Codable, Equatable, Sendable {
+ public let provider: Provider
+ public let limitCount: Int
+ public let status: UsageStatus
+ public let capacityRatio: Double
+
+ public init(provider: Provider, limitCount: Int, status: UsageStatus, capacityRatio: Double) {
+ self.provider = provider
+ self.limitCount = limitCount
+ self.status = status
+ self.capacityRatio = capacityRatio
+ }
+}
+
+extension Array where Element == UsageStatus {
+ public var contextPanelWorstStatus: UsageStatus {
+ if contains(.limited) { return .limited }
+ if contains(.failure) { return .failure }
+ if contains(.close) { return .close }
+ if contains(.stale) { return .stale }
+ if contains(.unknown) { return .unknown }
+ if contains(.loading) { return .loading }
+ return .healthy
+ }
+}
diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift
new file mode 100644
index 0000000..5ea5bb5
--- /dev/null
+++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift
@@ -0,0 +1,1374 @@
+import ContextPanelCore
+import SwiftUI
+import WebKit
+
+@main
+struct ContextPanelPreviewApp: App {
+ var body: some Scene {
+ WindowGroup {
+ AppRoot()
+ .frame(minWidth: 1280, minHeight: 720)
+ }
+ }
+}
+
+struct AppRoot: View {
+ @StateObject private var model = ContextPanelAppModel()
+ @State private var selectedID: UsageLimit.ID?
+
+ private var snapshot: UsageSnapshot {
+ model.currentSnapshot
+ }
+
+ private var selectedLimit: UsageLimit {
+ if let selectedID, let match = snapshot.limits.first(where: { $0.id == selectedID }) {
+ return match
+ }
+ return snapshot.mostConstrainedLimits.first ?? SampleUsageData.snapshot.mostConstrainedLimits[0]
+ }
+
+ var body: some View {
+ HStack(spacing: 0) {
+ AccountsSidebar(model: model, snapshot: snapshot, selectedID: $selectedID)
+ .frame(width: 210)
+ Divider()
+ InstrumentDashboard(model: model, snapshot: snapshot)
+ .frame(minWidth: 740)
+ Divider()
+ AccountDetail(model: model, limit: selectedLimit, generatedAt: snapshot.generatedAt)
+ .frame(width: 320)
+ }
+ .tint(CPTheme.accent)
+ .onAppear {
+ model.loadSnapshot()
+ selectedID = selectedID ?? snapshot.mostConstrainedLimits.first?.id
+ }
+ .sheet(isPresented: $model.isClaudeWebCapturePresented) {
+ ClaudeWebCaptureSheet(model: model)
+ .frame(minWidth: 980, minHeight: 680)
+ }
+ }
+}
+
+struct AccountsSidebar: View {
+ @ObservedObject var model: ContextPanelAppModel
+ let snapshot: UsageSnapshot
+ @Binding var selectedID: UsageLimit.ID?
+
+ var body: some View {
+ List(selection: $selectedID) {
+ Section("Accounts") {
+ ForEach(Provider.allCases) { provider in
+ let limits = snapshot.limits.filter { $0.provider == provider }
+ if !limits.isEmpty {
+ ProviderSidebarRow(provider: provider, limits: limits)
+ ForEach(limits) { limit in
+ SidebarLimitRow(limit: limit)
+ .tag(limit.id)
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle("Context Panel")
+ .safeAreaInset(edge: .bottom) {
+ VStack(spacing: 8) {
+ Button {
+ Task { await model.refreshLocalConnectors() }
+ } label: {
+ Label(model.isRefreshing ? "Refreshing" : "Refresh", systemImage: "arrow.clockwise")
+ .frame(maxWidth: .infinity)
+ }
+ .disabled(model.isRefreshing)
+
+ Button {
+ model.openClaudeWebCapture()
+ } label: {
+ Label("Claude Web", systemImage: "gauge.with.dots.needle.67percent")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.large)
+ .padding(12)
+ }
+ }
+}
+
+struct ProviderSidebarRow: View {
+ let provider: Provider
+ let limits: [UsageLimit]
+
+ var body: some View {
+ HStack(spacing: 8) {
+ ProviderBadge(provider: provider)
+ Text(provider.displayName)
+ .font(.system(size: 12, weight: .semibold))
+ .textCase(.uppercase)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(limits.count)")
+ .font(.system(.caption, design: .monospaced, weight: .medium))
+ .foregroundStyle(.tertiary)
+ }
+ }
+}
+
+struct SidebarLimitRow: View {
+ let limit: UsageLimit
+
+ var body: some View {
+ HStack(spacing: 10) {
+ StatusMark(status: limit.status, size: 7)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(limit.accountName)
+ .font(.system(size: 13, weight: .medium))
+ Text(limit.displayLabel)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Text(limit.compactUsageText)
+ .font(.system(.caption2, design: .monospaced, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 3)
+ }
+}
+
+struct InstrumentDashboard: View {
+ @ObservedObject var model: ContextPanelAppModel
+ let snapshot: UsageSnapshot
+
+ private var constrained: [UsageLimit] {
+ Array(snapshot.mostConstrainedLimits.prefix(4))
+ }
+
+ var body: some View {
+ ScrollView([.vertical, .horizontal]) {
+ VStack(alignment: .leading, spacing: 18) {
+ HeaderCard(model: model, snapshot: snapshot)
+ SetupStatusStrip(model: model)
+ WidgetPreviewGrid(snapshot: snapshot)
+ SectionHeader(title: "Most Constrained", trailing: "\(snapshot.limits.count) accounts")
+ VStack(spacing: 10) {
+ ForEach(constrained) { limit in
+ AccountRow(limit: limit)
+ }
+ }
+ SectionHeader(title: "Provider Groups", trailing: "Last update 2m ago")
+ ProviderGroupGrid(snapshot: snapshot)
+ }
+ .padding(24)
+ .frame(minWidth: 720, alignment: .topLeading)
+ }
+ .background(CPTheme.background)
+ .navigationTitle("Glance")
+ }
+}
+
+struct HeaderCard: View {
+ @ObservedObject var model: ContextPanelAppModel
+ let snapshot: UsageSnapshot
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 22) {
+ VStack(alignment: .leading, spacing: 10) {
+ CPLabel("Context Panel")
+ Text(snapshot.headline)
+ .font(.system(size: 28, weight: .semibold))
+ .foregroundStyle(CPTheme.primaryText)
+ .lineLimit(2)
+ Text(snapshot.subheadline)
+ .font(.system(size: 13))
+ .foregroundStyle(CPTheme.secondaryText)
+ Text(model.fastModeForecast.copy)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(CPTheme.accent)
+ HStack(spacing: 8) {
+ TagLabel("SwiftUI")
+ TagLabel("WidgetKit")
+ TagLabel(model.storeStatus.rawValue)
+ }
+ }
+ Spacer(minLength: 16)
+ CapacityDial(
+ value: snapshot.tightestCapacityRatio,
+ status: snapshot.aggregateStatus,
+ label: "\(Int(snapshot.tightestCapacityRatio * 100))",
+ sublabel: "tightest",
+ size: 116
+ )
+ }
+ .padding(22)
+ .background(CPTheme.surface)
+ .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .overlay(CPTheme.stroke(cornerRadius: 12))
+ .shadow(color: .black.opacity(0.05), radius: 14, x: 0, y: 8)
+ }
+}
+
+struct SetupStatusStrip: View {
+ @ObservedObject var model: ContextPanelAppModel
+
+ var body: some View {
+ HStack(spacing: 12) {
+ SetupStatusItem(
+ title: "Snapshot cache",
+ value: model.storeStatus == .healthy ? "Ready" : model.storeStatus.rawValue,
+ status: model.storeStatus
+ )
+ SetupStatusItem(
+ title: "History",
+ value: "\(model.historyCount) entries",
+ status: model.historyCount > 0 ? .healthy : .unknown
+ )
+ SetupStatusItem(
+ title: "Last refresh",
+ value: model.lastRefreshText,
+ status: model.isRefreshing ? .loading : .healthy
+ )
+ Spacer(minLength: 12)
+ if let errorMessage = model.errorMessage {
+ Text(errorMessage)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(CPTheme.statusColor(.failure))
+ .lineLimit(1)
+ }
+ }
+ .padding(14)
+ .background(CPTheme.surface)
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .overlay(CPTheme.stroke(cornerRadius: 10))
+ }
+}
+
+struct SetupStatusItem: View {
+ let title: String
+ let value: String
+ let status: UsageStatus
+
+ var body: some View {
+ HStack(spacing: 8) {
+ StatusMark(status: status, size: 8)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(CPTheme.tertiaryText)
+ .textCase(.uppercase)
+ Text(value)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(CPTheme.secondaryText)
+ .lineLimit(1)
+ }
+ }
+ }
+}
+
+struct WidgetPreviewGrid: View {
+ let snapshot: UsageSnapshot
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ SectionHeader(title: "Concept A ยท Instrument", trailing: "Native preview")
+ HStack(alignment: .top, spacing: 12) {
+ SmallWidgetPreview(snapshot: snapshot)
+ MediumWidgetPreview(snapshot: snapshot)
+ }
+ LargeWidgetPreview(snapshot: snapshot)
+ }
+ }
+}
+
+struct SmallWidgetPreview: View {
+ let snapshot: UsageSnapshot
+
+ var body: some View {
+ WidgetShell(width: 220, height: 220) {
+ VStack(alignment: .leading, spacing: 10) {
+ WidgetHeader(status: snapshot.aggregateStatus)
+ Spacer()
+ Text(snapshot.fastModeForecast.copy)
+ .font(.system(size: 26, weight: .semibold))
+ .foregroundStyle(CPTheme.primaryText)
+ .lineLimit(2)
+ .minimumScaleFactor(0.75)
+ Text(snapshot.tightestSupportText)
+ .font(.system(size: 12))
+ .foregroundStyle(CPTheme.secondaryText)
+ .lineLimit(2)
+ Spacer()
+ ProviderMiniStatus(snapshot: snapshot)
+ }
+ }
+ }
+}
+
+struct MediumWidgetPreview: View {
+ let snapshot: UsageSnapshot
+
+ var body: some View {
+ WidgetShell(width: 460, height: 220) {
+ HStack(spacing: 18) {
+ VStack(alignment: .leading) {
+ WidgetHeader(status: snapshot.aggregateStatus)
+ Spacer()
+ CapacityDial(
+ value: snapshot.tightestCapacityRatio,
+ status: snapshot.aggregateStatus,
+ label: "\(Int(snapshot.tightestCapacityRatio * 100))",
+ sublabel: "tightest",
+ size: 94
+ )
+ VStack(alignment: .leading, spacing: 2) {
+ Text(snapshot.fastModeForecast.copy)
+ .font(.system(size: 18, weight: .semibold))
+ Text(snapshot.providerPressureText)
+ .font(.system(size: 11))
+ .foregroundStyle(CPTheme.tertiaryText)
+ }
+ Spacer()
+ Text(snapshot.nearestResetText)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(CPTheme.tertiaryText)
+ }
+ .frame(width: 150, alignment: .leading)
+
+ Divider()
+
+ VStack(alignment: .leading, spacing: 8) {
+ SectionHeader(title: "Most Constrained", trailing: "4 accounts")
+ ForEach(snapshot.mostConstrainedLimits.prefix(4)) { limit in
+ AccountRow(limit: limit, compact: true)
+ }
+ }
+ }
+ }
+ }
+}
+
+struct LargeWidgetPreview: View {
+ let snapshot: UsageSnapshot
+
+ var body: some View {
+ WidgetShell(width: 460, height: 460) {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 6) {
+ CPLabel("Context Panel")
+ Text(snapshot.fastModeForecast.copy)
+ .font(.system(size: 25, weight: .semibold))
+ .foregroundStyle(CPTheme.primaryText)
+ Text(snapshot.tightestSupportText)
+ .font(.system(size: 12))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+ Spacer()
+ CapacityDial(
+ value: snapshot.tightestCapacityRatio,
+ status: snapshot.aggregateStatus,
+ label: "\(Int(snapshot.tightestCapacityRatio * 100))",
+ sublabel: "tightest",
+ size: 84
+ )
+ }
+
+ ProviderGroupGrid(snapshot: snapshot, compact: true)
+
+ Spacer(minLength: 0)
+ Divider()
+ HStack {
+ Sparkline(values: [0.72, 0.68, 0.7, 0.64, 0.62, 0.58, 0.64])
+ .frame(width: 120, height: 20)
+ Text("pressure trend")
+ .font(.system(size: 10))
+ .foregroundStyle(CPTheme.tertiaryText)
+ Spacer()
+ Text(snapshot.nearestResetText)
+ .font(.system(size: 10))
+ .foregroundStyle(CPTheme.tertiaryText)
+ }
+ }
+ }
+ }
+}
+
+struct ProviderGroupGrid: View {
+ let snapshot: UsageSnapshot
+ var compact = false
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 14) {
+ ForEach(Provider.allCases) { provider in
+ let limits = snapshot.limits.filter { $0.provider == provider }
+ if !limits.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 6) {
+ ProviderBadge(provider: provider, compact: true)
+ Text(provider.displayName)
+ .font(.system(size: 11, weight: .semibold))
+ .textCase(.uppercase)
+ Spacer()
+ StatusMark(status: limits.map(\.status).worstStatus, size: 7)
+ }
+ .foregroundStyle(CPTheme.secondaryText)
+ Divider()
+ ForEach(limits.prefix(compact ? 3 : 4)) { limit in
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(limit.accountName)
+ .font(.system(size: compact ? 11 : 12, weight: .medium))
+ .lineLimit(1)
+ Spacer()
+ Text(limit.percentText)
+ .font(.system(size: 10, weight: .medium, design: .monospaced))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+ Text(limit.displayLabel)
+ .font(.system(size: 10))
+ .foregroundStyle(CPTheme.tertiaryText)
+ .lineLimit(1)
+ CapacityBar(value: limit.usageRatio ?? 0, status: limit.status)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+ }
+ }
+ }
+}
+
+struct AccountDetail: View {
+ @ObservedObject var model: ContextPanelAppModel
+ let limit: UsageLimit
+ let generatedAt: Date
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ HStack(spacing: 10) {
+ ProviderBadge(provider: limit.provider)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(limit.accountName)
+ .font(.system(size: 22, weight: .semibold))
+ Text("\(limit.provider.displayName) ยท \(limit.displayLabel) ยท \(limit.contextLabel)")
+ .font(.system(size: 13))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+ Spacer()
+ StatusMark(status: limit.status, size: 10)
+ }
+
+ CapacityDial(
+ value: 1 - (limit.usageRatio ?? 0),
+ status: limit.status,
+ label: limit.remaining.map(String.init) ?? "?",
+ sublabel: "left",
+ size: 140
+ )
+ .frame(maxWidth: .infinity)
+
+ DetailCard(title: "Forecast") {
+ Text(forecastCopy)
+ .font(.system(size: 15, weight: .medium))
+ Text("Confidence: \(limit.confidence.rawValue)")
+ .font(.system(size: 12))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+
+ DetailCard(title: "Normalized limit") {
+ DetailRow(label: "Used", value: limit.used.map(String.init) ?? "unknown")
+ DetailRow(label: "Limit", value: limit.limit.map(String.init) ?? "unknown")
+ DetailRow(label: "Remaining", value: limit.remaining.map(String.init) ?? "unknown")
+ DetailRow(label: "Status", value: limit.status.rawValue)
+ DetailRow(label: "Updated", value: limit.lastUpdatedAt.map(model.relativeTime) ?? "unknown")
+ }
+
+ DetailCard(title: "Refresh history") {
+ Sparkline(values: [0.72, 0.68, 0.70, 0.64, 0.62, 0.58, 0.64])
+ .frame(height: 42)
+ Text("\(model.historyCount) cached snapshots. Last good snapshot is preserved for stale and failure states.")
+ .font(.system(size: 12))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+
+ DetailCard(title: "Setup") {
+ DetailRow(label: "Store", value: ConnectorRedactor.redactedPath(model.store.rootDirectory.path))
+ DetailRow(label: "Accounts", value: "\(model.configuredAccounts.filter(\.isEnabled).count) enabled")
+ ForEach(model.configuredAccounts) { account in
+ DetailRow(
+ label: account.displayName,
+ value: account.isEnabled ? account.connectorKind.rawValue : "disabled"
+ )
+ }
+ }
+ }
+ .padding(22)
+ }
+ .background(CPTheme.background)
+ .navigationTitle("Details")
+ }
+
+ private var forecastCopy: String {
+ if limit.provider == .openAI, limit.unit == .percent {
+ return FastModeForecast(
+ input: FastModeForecastInput(
+ limit: limit,
+ now: model.now,
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12),
+ reserveUnits: 6,
+ minimumSafeHours: 1
+ )
+ ).copy
+ }
+ return limit.note ?? "No fast-mode forecast for this limit yet."
+ }
+}
+
+struct DetailCard: View {
+ let title: String
+ @ViewBuilder let content: Content
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ CPLabel(title)
+ content
+ }
+ .padding(14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(CPTheme.surface)
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .overlay(CPTheme.stroke(cornerRadius: 10))
+ }
+}
+
+struct DetailRow: View {
+ let label: String
+ let value: String
+
+ var body: some View {
+ HStack {
+ Text(label)
+ .foregroundStyle(CPTheme.secondaryText)
+ Spacer()
+ Text(value)
+ .font(.system(.caption, design: .monospaced, weight: .medium))
+ }
+ .font(.system(size: 13))
+ }
+}
+
+struct WidgetShell: View {
+ let width: CGFloat
+ let height: CGFloat
+ @ViewBuilder let content: Content
+
+ var body: some View {
+ content
+ .padding(16)
+ .frame(width: width, height: height)
+ .background(CPTheme.surface)
+ .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
+ .overlay(CPTheme.stroke(cornerRadius: 20))
+ .shadow(color: .black.opacity(0.05), radius: 12, x: 0, y: 6)
+ }
+}
+
+struct WidgetHeader: View {
+ let status: UsageStatus
+
+ var body: some View {
+ HStack {
+ CPLabel("Context Panel")
+ Spacer()
+ StatusMark(status: status, size: 9)
+ }
+ }
+}
+
+struct ProviderMiniStatus: View {
+ let snapshot: UsageSnapshot
+
+ var body: some View {
+ HStack(spacing: 14) {
+ ForEach(Provider.allCases) { provider in
+ let limits = snapshot.limits.filter { $0.provider == provider }
+ HStack(spacing: 5) {
+ ProviderBadge(provider: provider, compact: true)
+ }
+ .opacity(limits.isEmpty ? 0.35 : 1)
+ }
+ }
+ }
+}
+
+struct AccountRow: View {
+ let limit: UsageLimit
+ var compact = false
+
+ var body: some View {
+ HStack(spacing: 10) {
+ ProviderBadge(provider: limit.provider, compact: true)
+ .frame(width: 16)
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(limit.displayLabel)
+ .font(.system(size: compact ? 12 : 13, weight: .medium))
+ .lineLimit(1)
+ Text("ยท \(limit.contextLabel)")
+ .font(.system(size: compact ? 12 : 13))
+ .foregroundStyle(CPTheme.tertiaryText)
+ .lineLimit(1)
+ Spacer()
+ Text(limit.compactUsageText)
+ .font(.system(size: compact ? 10 : 11, weight: .medium, design: .monospaced))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+ HStack(spacing: 8) {
+ CapacityBar(value: limit.usageRatio ?? 0, status: limit.status)
+ Text(limit.resetText)
+ .font(.system(size: 10))
+ .foregroundStyle(limit.status == .stale ? CPTheme.statusColor(.stale) : CPTheme.tertiaryText)
+ .lineLimit(1)
+ }
+ }
+ }
+ .padding(compact ? 0 : 10)
+ .background(compact ? Color.clear : CPTheme.surface)
+ .clipShape(RoundedRectangle(cornerRadius: compact ? 0 : 8, style: .continuous))
+ .overlay {
+ if !compact {
+ CPTheme.stroke(cornerRadius: 8)
+ }
+ }
+ }
+}
+
+@MainActor
+final class ContextPanelAppModel: ObservableObject {
+ @Published private(set) var storedSnapshot: StoredUsageSnapshot?
+ @Published private(set) var storeStatus: UsageStatus = .unknown
+ @Published private(set) var historyCount: Int = 0
+ @Published private(set) var configuredAccounts: [LocalProviderAccountConfiguration] = []
+ @Published private(set) var isRefreshing = false
+ @Published var isClaudeWebCapturePresented = false
+ @Published private(set) var errorMessage: String?
+ @Published private(set) var lastRefreshAt: Date?
+
+ let now = Date()
+ let store: JSONSnapshotStore
+ let accountStore: AccountConfigurationStore
+
+ var currentSnapshot: UsageSnapshot {
+ storedSnapshot?.snapshot ?? SampleUsageData.snapshot
+ }
+
+ var fastModeForecast: FastModePortfolioForecast {
+ let forecasts = currentSnapshot.limits
+ .filter { $0.provider == .openAI && $0.unit == .percent }
+ .map { limit in
+ FastModeForecast(input: FastModeForecastInput(
+ limit: limit,
+ now: Date(),
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12),
+ reserveUnits: 6,
+ minimumSafeHours: 1
+ ))
+ }
+ return FastModePortfolioForecast(forecasts: forecasts)
+ }
+
+ var lastRefreshText: String {
+ lastRefreshAt.map(relativeTime) ?? "not yet"
+ }
+
+ init() {
+ store = JSONSnapshotStore(
+ rootDirectory: ContextPanelLocations.snapshotDirectory(appGroupID: ContextPanelLocations.appGroupID)
+ )
+ accountStore = AccountConfigurationStore(configurationURL: ContextPanelLocations.accountConfigurationURL())
+ }
+
+ func loadSnapshot() {
+ let accounts = accountStore.load().document.accounts
+ configuredAccounts = accounts
+ let result = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 15 * 60), now: Date())
+ storedSnapshot = result.snapshot
+ storeStatus = result.status
+ errorMessage = result.errorMessage
+ historyCount = store.loadHistory().count
+ }
+
+ func refreshLocalConnectors() async {
+ isRefreshing = true
+ defer { isRefreshing = false }
+
+ let accountDocument = accountStore.load().document
+ configuredAccounts = accountDocument.accounts
+ let connectors = AccountConnectorFactory.connectors(from: accountDocument)
+ let refreshResult = await ProviderConnectorRuntime(connectors: connectors).refreshAll()
+ let savedAt = Date()
+
+ do {
+ try store.saveMerged(refreshResult: refreshResult, savedAt: savedAt)
+ lastRefreshAt = savedAt
+ loadSnapshot()
+ } catch {
+ storeStatus = .failure
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func openClaudeWebCapture() {
+ isClaudeWebCapturePresented = true
+ }
+
+ func closeClaudeWebCapture() {
+ isClaudeWebCapturePresented = false
+ }
+
+ func saveClaudeWebLimits(_ limits: [UsageLimit]) {
+ guard !limits.isEmpty else { return }
+ let savedAt = Date()
+ let report = ProviderConnectorReport(
+ provider: .anthropic,
+ accountID: "claude-web",
+ accountName: "Claude Web",
+ generatedAt: savedAt,
+ limits: limits,
+ status: .healthy
+ )
+ do {
+ try store.saveMerged(
+ refreshResult: ConnectorRefreshResult(generatedAt: savedAt, reports: [report]),
+ savedAt: savedAt
+ )
+ lastRefreshAt = savedAt
+ loadSnapshot()
+ } catch {
+ storeStatus = .failure
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func relativeTime(_ date: Date) -> String {
+ let seconds = max(Int(Date().timeIntervalSince(date)), 0)
+ if seconds < 60 { return "just now" }
+ let minutes = seconds / 60
+ if minutes < 60 { return "\(minutes)m ago" }
+ let hours = minutes / 60
+ if hours < 24 { return "\(hours)h ago" }
+ return "\(hours / 24)d ago"
+ }
+
+}
+
+struct CapacityDial: View {
+ let value: Double
+ let status: UsageStatus
+ let label: String
+ let sublabel: String
+ var size: CGFloat = 96
+ var thickness: CGFloat = 6
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .stroke(CPTheme.line, lineWidth: thickness)
+ Circle()
+ .trim(from: 0, to: min(max(value, 0), 1))
+ .stroke(
+ CPTheme.statusColor(status),
+ style: StrokeStyle(lineWidth: thickness, lineCap: .round)
+ )
+ .rotationEffect(.degrees(-90))
+ VStack(spacing: 0) {
+ Text(label)
+ .font(.system(size: size > 100 ? 30 : 22, weight: .semibold, design: .monospaced))
+ .foregroundStyle(CPTheme.primaryText)
+ Text(sublabel)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(CPTheme.tertiaryText)
+ .textCase(.uppercase)
+ }
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+struct CapacityBar: View {
+ let value: Double
+ let status: UsageStatus
+ var height: CGFloat = 4
+
+ var body: some View {
+ GeometryReader { proxy in
+ ZStack(alignment: .leading) {
+ Capsule()
+ .fill(CPTheme.line)
+ Capsule()
+ .fill(CPTheme.statusColor(status))
+ .frame(width: proxy.size.width * min(max(value, 0), 1))
+ }
+ }
+ .frame(height: height)
+ }
+}
+
+struct ProviderBadge: View {
+ let provider: Provider
+ var compact = false
+
+ var body: some View {
+ Text(provider.shortName)
+ .font(.system(size: compact ? 10 : 11, weight: .semibold, design: .monospaced))
+ .foregroundStyle(CPTheme.providerColor(provider))
+ .lineLimit(1)
+ }
+}
+
+struct StatusMark: View {
+ let status: UsageStatus
+ var size: CGFloat = 8
+
+ var body: some View {
+ Group {
+ switch status {
+ case .healthy:
+ Circle().fill(CPTheme.statusColor(status))
+ case .close:
+ Circle().trim(from: 0, to: 0.75).stroke(CPTheme.statusColor(status), lineWidth: 2)
+ case .limited:
+ RoundedRectangle(cornerRadius: 1).fill(CPTheme.statusColor(status))
+ case .stale:
+ Circle().stroke(CPTheme.statusColor(status), style: StrokeStyle(lineWidth: 1.4, dash: [2, 2]))
+ case .unknown:
+ Text("?").font(.system(size: size + 3, weight: .semibold)).foregroundStyle(CPTheme.statusColor(status))
+ case .failure:
+ Image(systemName: "xmark").font(.system(size: size, weight: .bold)).foregroundStyle(CPTheme.statusColor(status))
+ case .loading:
+ Circle().stroke(CPTheme.statusColor(status), lineWidth: 1.4)
+ }
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+struct ClaudeWebCaptureSheet: View {
+ @ObservedObject var model: ContextPanelAppModel
+ @StateObject private var captureModel = ClaudeWebCaptureModel()
+
+ var body: some View {
+ HStack(spacing: 0) {
+ VStack(alignment: .leading, spacing: 14) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Claude Web")
+ .font(.system(size: 22, weight: .semibold))
+ Text("Complete Claude verification here. The app captures only official usage windows from the Usage page.")
+ .font(.system(size: 13))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+
+ HStack {
+ Button("Open Usage") { captureModel.openUsagePage() }
+ Button("Reload") { captureModel.reload() }
+ Spacer()
+ Button("Done") { model.closeClaudeWebCapture() }
+ }
+
+ Label(captureModel.statusText, systemImage: captureModel.statusIcon)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(captureModel.limits.isEmpty ? CPTheme.secondaryText : CPTheme.primaryText)
+
+ Divider()
+
+ Text("Captured windows")
+ .font(.system(size: 11, weight: .semibold))
+ .textCase(.uppercase)
+ .foregroundStyle(CPTheme.secondaryText)
+
+ if captureModel.limits.isEmpty {
+ ContentUnavailableView(
+ "Waiting for Claude usage",
+ systemImage: "network",
+ description: Text("The sheet auto-saves when Claude's usage endpoint returns percent windows.")
+ )
+ .frame(maxHeight: 220)
+ } else {
+ ScrollView {
+ VStack(spacing: 8) {
+ ForEach(captureModel.limits) { limit in
+ ClaudeWebCaptureLimitRow(limit: limit)
+ }
+ }
+ }
+ }
+
+ Spacer()
+
+ VStack(alignment: .leading, spacing: 6) {
+ Label("No cookies, tokens, headers, IDs, emails, local storage, or raw bodies are stored.", systemImage: "lock.shield")
+ Label("Saved rows are merged with OpenAI and Gemini instead of replacing them.", systemImage: "square.stack.3d.up")
+ }
+ .font(.system(size: 11))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+ .frame(width: 330)
+ .padding(18)
+ .background(CPTheme.surface)
+
+ Divider()
+
+ ClaudeWebCaptureWebView(model: captureModel)
+ }
+ .onReceive(captureModel.$limits) { limits in
+ guard !limits.isEmpty else { return }
+ model.saveClaudeWebLimits(limits)
+ }
+ }
+}
+
+struct ClaudeWebCaptureLimitRow: View {
+ let limit: UsageLimit
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(limit.displayLabel)
+ .font(.system(size: 13, weight: .semibold))
+ Text(limit.contextLabel)
+ .font(.system(size: 11))
+ .foregroundStyle(CPTheme.secondaryText)
+ }
+ Spacer()
+ Text(limit.compactUsageText)
+ .font(.system(size: 18, weight: .semibold, design: .rounded))
+ }
+ CapacityBar(value: limit.usageRatio ?? 0, status: limit.status)
+ Text(limit.resetText)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(CPTheme.tertiaryText)
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(CPTheme.background)
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .overlay(CPTheme.stroke(cornerRadius: 8))
+ }
+}
+
+@MainActor
+final class ClaudeWebCaptureModel: ObservableObject {
+ @Published var limits: [UsageLimit] = []
+ @Published var statusText = "Opening Claude usage page"
+ @Published var statusIcon = "safari"
+
+ private lazy var navigationDelegate = ClaudeWebCaptureNavigationDelegate(owner: self)
+
+ lazy var webView: WKWebView = {
+ let configuration = WKWebViewConfiguration()
+ configuration.websiteDataStore = .default()
+ configuration.userContentController.add(ClaudeWebCaptureScriptHandler(owner: self), name: "claudeUsageCapture")
+ configuration.userContentController.addUserScript(
+ WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false)
+ )
+ let view = WKWebView(frame: .zero, configuration: configuration)
+ view.navigationDelegate = navigationDelegate
+ return view
+ }()
+
+ init() {
+ openUsagePage()
+ }
+
+ func openUsagePage() {
+ statusText = "Opening Claude usage page"
+ statusIcon = "safari"
+ webView.load(URLRequest(url: URL(string: "https://claude.ai/settings/usage")!))
+ }
+
+ func reload() {
+ statusText = "Reloading Claude usage page"
+ statusIcon = "arrow.clockwise"
+ webView.reload()
+ }
+
+ fileprivate func record(payload: [String: Any]) {
+ let windows = payload["windows"] as? [String: Any] ?? [:]
+ let wrapped = ["rate_limits": windows]
+ do {
+ let data = try JSONSerialization.data(withJSONObject: wrapped)
+ let parsed = try ClaudeWebUsageParser.usageLimits(
+ from: data,
+ accountID: "claude-web",
+ accountName: "Claude Web",
+ observedAt: Date()
+ )
+ guard !parsed.isEmpty else { return }
+ limits = parsed
+ statusText = "Captured and saved Claude web usage"
+ statusIcon = "checkmark.circle.fill"
+ } catch {
+ statusText = "Capture failed: \(error.localizedDescription)"
+ statusIcon = "exclamationmark.triangle"
+ }
+ }
+
+ fileprivate func didFinishNavigation(url: URL?) {
+ if let host = url?.host, host.contains("claude.ai"), limits.isEmpty {
+ statusText = "Claude page loaded; waiting for usage API"
+ statusIcon = "network"
+ }
+ }
+
+ private static let networkProbeScript = #"""
+ (() => {
+ if (window.__contextPanelClaudeUsageCaptureInstalled) return;
+ window.__contextPanelClaudeUsageCaptureInstalled = true;
+
+ const windowKeys = new Set(['five_hour', 'seven_day', 'seven_day_opus', 'seven_day_sonnet', 'seven_day_oauth_apps']);
+ const fieldKeys = new Set(['used_percentage', 'remaining_percentage', 'utilization', 'resets_at', 'reset_at']);
+
+ function isUsageURL(rawUrl) {
+ try { return /^\/api\/organizations\/[^/]+\/usage$/.test(new URL(rawUrl, window.location.href).pathname); }
+ catch (_) { return false; }
+ }
+
+ function sanitizeWindow(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ const sanitized = {};
+ for (const key of fieldKeys) {
+ const raw = value[key];
+ if (typeof raw === 'number' || typeof raw === 'string') sanitized[key] = raw;
+ }
+ return Object.keys(sanitized).length ? sanitized : null;
+ }
+
+ function collectWindows(value, out = {}) {
+ if (!value || typeof value !== 'object') return out;
+ if (Array.isArray(value)) {
+ value.slice(0, 3).forEach(item => collectWindows(item, out));
+ return out;
+ }
+ for (const [key, child] of Object.entries(value)) {
+ if (windowKeys.has(key)) {
+ const sanitized = sanitizeWindow(child);
+ if (sanitized) out[key] = sanitized;
+ }
+ collectWindows(child, out);
+ }
+ return out;
+ }
+
+ function post(payload) {
+ try { window.webkit.messageHandlers.claudeUsageCapture.postMessage(payload); }
+ catch (_) {}
+ }
+
+ function inspect(url, contentType, text) {
+ if (!isUsageURL(url) || !/json/i.test(contentType || '')) return;
+ try {
+ const windows = collectWindows(JSON.parse(String(text || '')));
+ if (Object.keys(windows).length) post({ windows });
+ } catch (_) {}
+ }
+
+ const originalFetch = window.fetch;
+ if (originalFetch) {
+ window.fetch = async function(input, init) {
+ const response = await originalFetch.apply(this, arguments);
+ try {
+ const clone = response.clone();
+ const url = typeof input === 'string' ? input : (input && input.url) || '';
+ clone.text().then(text => inspect(url, clone.headers.get('content-type') || '', text)).catch(() => {});
+ } catch (_) {}
+ return response;
+ };
+ }
+
+ const originalOpen = XMLHttpRequest.prototype.open;
+ const originalSend = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function(method, url) {
+ this.__cpClaudeUsageUrl = url;
+ return originalOpen.apply(this, arguments);
+ };
+ XMLHttpRequest.prototype.send = function() {
+ this.addEventListener('load', function() {
+ try { inspect(this.__cpClaudeUsageUrl || '', this.getResponseHeader('content-type') || '', this.responseText || ''); }
+ catch (_) {}
+ });
+ return originalSend.apply(this, arguments);
+ };
+ })();
+ """#
+}
+
+final class ClaudeWebCaptureScriptHandler: NSObject, WKScriptMessageHandler {
+ weak var owner: ClaudeWebCaptureModel?
+
+ init(owner: ClaudeWebCaptureModel) {
+ self.owner = owner
+ }
+
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let payload = message.body as? [String: Any] else { return }
+ Task { @MainActor [weak owner] in owner?.record(payload: payload) }
+ }
+}
+
+final class ClaudeWebCaptureNavigationDelegate: NSObject, WKNavigationDelegate {
+ weak var owner: ClaudeWebCaptureModel?
+
+ init(owner: ClaudeWebCaptureModel) {
+ self.owner = owner
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ Task { @MainActor [weak webView, weak owner] in owner?.didFinishNavigation(url: webView?.url) }
+ }
+}
+
+struct ClaudeWebCaptureWebView: NSViewRepresentable {
+ @ObservedObject var model: ClaudeWebCaptureModel
+
+ func makeNSView(context: Context) -> WKWebView { model.webView }
+
+ func updateNSView(_ nsView: WKWebView, context: Context) {}
+}
+
+struct Sparkline: View {
+ let values: [Double]
+
+ var body: some View {
+ GeometryReader { proxy in
+ Path { path in
+ guard let first = values.first else { return }
+ let points = values.enumerated().map { index, value in
+ CGPoint(
+ x: proxy.size.width * CGFloat(index) / CGFloat(max(values.count - 1, 1)),
+ y: proxy.size.height * CGFloat(1 - min(max(value, 0), 1))
+ )
+ }
+ path.move(to: CGPoint(x: 0, y: proxy.size.height * CGFloat(1 - first)))
+ points.dropFirst().forEach { path.addLine(to: $0) }
+ }
+ .stroke(CPTheme.accent, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
+ }
+ }
+}
+
+struct SectionHeader: View {
+ let title: String
+ var trailing: String? = nil
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline) {
+ Text(title)
+ .font(.system(size: 10, weight: .semibold))
+ .tracking(0.9)
+ .textCase(.uppercase)
+ .foregroundStyle(CPTheme.tertiaryText)
+ Spacer()
+ if let trailing {
+ Text(trailing)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(CPTheme.tertiaryText)
+ }
+ }
+ }
+}
+
+struct CPLabel: View {
+ let text: String
+
+ init(_ text: String) {
+ self.text = text
+ }
+
+ var body: some View {
+ HStack(spacing: 6) {
+ RoundedRectangle(cornerRadius: 1)
+ .fill(CPTheme.accent)
+ .rotationEffect(.degrees(45))
+ .frame(width: 6, height: 6)
+ Text(text)
+ .font(.system(size: 10, weight: .semibold))
+ .tracking(0.8)
+ .textCase(.uppercase)
+ .foregroundStyle(CPTheme.tertiaryText)
+ }
+ }
+}
+
+struct TagLabel: View {
+ let text: String
+
+ init(_ text: String) {
+ self.text = text
+ }
+
+ var body: some View {
+ Text(text)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(CPTheme.secondaryText)
+ .padding(.horizontal, 9)
+ .padding(.vertical, 5)
+ .background(CPTheme.accent.opacity(0.08))
+ .clipShape(Capsule())
+ }
+}
+
+enum CPTheme {
+ static let background = Color(red: 244 / 255, green: 244 / 255, blue: 245 / 255)
+ static let surface = Color.white
+ static let surface2 = Color(red: 250 / 255, green: 250 / 255, blue: 250 / 255)
+ static let line = Color.black.opacity(0.07)
+ static let primaryText = Color(red: 10 / 255, green: 10 / 255, blue: 11 / 255)
+ static let secondaryText = primaryText.opacity(0.66)
+ static let tertiaryText = primaryText.opacity(0.46)
+ static let accent = Color(red: 74 / 255, green: 91 / 255, blue: 122 / 255)
+
+ static func providerColor(_ provider: Provider) -> Color {
+ switch provider {
+ case .openAI:
+ Color(red: 56 / 255, green: 92 / 255, blue: 126 / 255)
+ case .anthropic:
+ Color(red: 139 / 255, green: 102 / 255, blue: 51 / 255)
+ case .google:
+ Color(red: 35 / 255, green: 116 / 255, blue: 106 / 255)
+ }
+ }
+
+ static func statusColor(_ status: UsageStatus) -> Color {
+ switch status {
+ case .healthy:
+ Color(red: 74 / 255, green: 122 / 255, blue: 91 / 255)
+ case .close:
+ Color(red: 138 / 255, green: 106 / 255, blue: 42 / 255)
+ case .limited, .failure:
+ Color(red: 138 / 255, green: 74 / 255, blue: 74 / 255)
+ case .stale, .unknown, .loading:
+ Color(red: 106 / 255, green: 106 / 255, blue: 114 / 255)
+ }
+ }
+
+ static func stroke(cornerRadius: CGFloat) -> some View {
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
+ .stroke(line, lineWidth: 1)
+ }
+}
+
+extension UsageSnapshot {
+ var headline: String {
+ "Know whether you can keep working."
+ }
+
+ var subheadline: String {
+ "OpenAI, Anthropic, and Google capacity with local confidence."
+ }
+
+ var tightestLimit: UsageLimit? {
+ mostConstrainedLimits.first
+ }
+
+ var tightestUsageText: String {
+ guard let tightestLimit else { return "Set up accounts" }
+ return tightestLimit.compactUsageText == "?" ? "Unknown limit" : "\(tightestLimit.compactUsageText) left"
+ }
+
+ var tightestSupportText: String {
+ guard let tightestLimit else { return "Add OpenAI, Anthropic, or Google." }
+ return "\(tightestLimit.provider.shortName) ยท \(tightestLimit.displayLabel) ยท \(tightestLimit.contextLabel)"
+ }
+
+ var tightestCapacityRatio: Double {
+ guard let ratio = tightestLimit?.usageRatio else { return 0 }
+ return max(1 - ratio, 0)
+ }
+
+ var fastModeForecast: FastModePortfolioForecast {
+ let forecasts = limits
+ .filter { $0.provider == .openAI && $0.unit == .percent }
+ .map { limit in
+ FastModeForecast(input: FastModeForecastInput(
+ limit: limit,
+ now: Date(),
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12),
+ reserveUnits: 6,
+ minimumSafeHours: 1
+ ))
+ }
+ return FastModePortfolioForecast(forecasts: forecasts)
+ }
+
+ var providerPressureText: String {
+ let limited = limits.filter { $0.status == .limited }.count
+ let close = limits.filter { $0.status == .close }.count
+ if limited > 0 || close > 0 {
+ return "\(limited) limited ยท \(close) close"
+ }
+ return "all tracked windows healthy"
+ }
+
+ var nearestResetText: String {
+ let futureResets = limits.compactMap(\.resetsAt).filter { $0 > Date() }.sorted()
+ guard let reset = futureResets.first else { return "reset unknown" }
+ return "nearest reset \(reset.widgetRelativeText)"
+ }
+}
+
+extension UsageLimit {
+ var compactUsageText: String {
+ if provider == .anthropic, unit == .unknown, status == .unknown {
+ return "unknown"
+ }
+ guard let used, let limit else { return status == .failure ? "โ" : "?" }
+ return "\(used)/\(limit)"
+ }
+
+ var percentText: String {
+ if provider == .anthropic, unit == .unknown, status == .unknown {
+ return "unknown"
+ }
+ guard let usageRatio else { return status == .failure ? "โ" : "?" }
+ return "\(Int(usageRatio * 100))%"
+ }
+
+ var resetText: String {
+ if status == .failure { return "refresh failed" }
+ guard let resetsAt else { return "unknown reset" }
+ if resetsAt < Date().addingTimeInterval(-60) { return "reset passed" }
+ return "resets \(resetsAt.widgetRelativeText)"
+ }
+}
+
+extension Date {
+ var widgetRelativeText: String {
+ let seconds = Int(timeIntervalSince(Date()))
+ if abs(seconds) < 60 { return "now" }
+ if seconds >= 0 {
+ let minutes = seconds / 60
+ if minutes < 60 { return "in \(minutes)m" }
+ let hours = minutes / 60
+ if hours < 24 { return "in \(hours)h" }
+ return "in \(hours / 24)d"
+ }
+ let elapsed = abs(seconds)
+ let minutes = elapsed / 60
+ if minutes < 60 { return "\(minutes)m ago" }
+ let hours = minutes / 60
+ if hours < 24 { return "\(hours)h ago" }
+ return "\(hours / 24)d ago"
+ }
+}
+
+extension [UsageStatus] {
+ var worstStatus: UsageStatus {
+ contextPanelWorstStatus
+ }
+}
diff --git a/Sources/ContextPanelPreview/SampleUsageData.swift b/Sources/ContextPanelPreview/SampleUsageData.swift
new file mode 100644
index 0000000..42fd6ac
--- /dev/null
+++ b/Sources/ContextPanelPreview/SampleUsageData.swift
@@ -0,0 +1,127 @@
+import ContextPanelCore
+import Foundation
+
+enum SampleUsageData {
+ static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000)
+
+ static var snapshot: UsageSnapshot {
+ UsageSnapshot(
+ generatedAt: referenceNow,
+ limits: [
+ UsageLimit(
+ provider: .openAI,
+ accountID: "openai-personal",
+ accountName: "Personal",
+ label: "GPT-5 5-hour",
+ windowLabel: "5-hour",
+ modelLabel: "GPT-5",
+ unit: .percent,
+ used: 72,
+ limit: 100,
+ resetsAt: referenceNow.addingTimeInterval(12_000),
+ lastUpdatedAt: referenceNow.addingTimeInterval(-120),
+ confidence: .manual
+ ),
+ UsageLimit(
+ provider: .openAI,
+ accountID: "openai-work",
+ accountName: "Work",
+ label: "GPT-5 Thinking Weekly",
+ windowLabel: "Weekly",
+ modelLabel: "GPT-5 Thinking",
+ unit: .percent,
+ used: 18,
+ limit: 100,
+ resetsAt: referenceNow.addingTimeInterval(86_400),
+ lastUpdatedAt: referenceNow.addingTimeInterval(-120),
+ confidence: .estimated,
+ note: "Fast mode looks safe for about 2h."
+ ),
+ UsageLimit(
+ provider: .openAI,
+ accountID: "openai-team",
+ accountName: "Team",
+ label: "Image generation Hourly",
+ windowLabel: "Hourly",
+ modelLabel: "Image generation",
+ unit: .percent,
+ used: 49,
+ limit: 100,
+ resetsAt: referenceNow.addingTimeInterval(2_520),
+ lastUpdatedAt: referenceNow.addingTimeInterval(-120),
+ confidence: .observed
+ ),
+ UsageLimit(
+ provider: .anthropic,
+ accountID: "anthropic-personal",
+ accountName: "Personal",
+ label: "Claude Opus 5-hour",
+ windowLabel: "5-hour",
+ modelLabel: "Claude Opus",
+ used: 38,
+ limit: 45,
+ resetsAt: referenceNow.addingTimeInterval(4_500),
+ lastUpdatedAt: referenceNow.addingTimeInterval(-120),
+ confidence: .official
+ ),
+ UsageLimit(
+ provider: .anthropic,
+ accountID: "anthropic-work",
+ accountName: "Work",
+ label: "Claude Sonnet Daily",
+ windowLabel: "Daily",
+ modelLabel: "Claude Sonnet",
+ used: 12,
+ limit: 100,
+ resetsAt: referenceNow.addingTimeInterval(21_600),
+ lastUpdatedAt: referenceNow.addingTimeInterval(-120),
+ confidence: .official
+ ),
+ UsageLimit(
+ provider: .google,
+ accountID: "google-personal",
+ accountName: "Personal",
+ label: "Gemini Pro",
+ modelLabel: "Gemini Pro",
+ used: nil,
+ limit: nil,
+ lastUpdatedAt: referenceNow.addingTimeInterval(-120),
+ confidence: .unknown,
+ statusOverride: .unknown,
+ note: "Provider does not expose this limit."
+ ),
+ UsageLimit(
+ provider: .google,
+ accountID: "google-work",
+ accountName: "Work",
+ label: "Gemini Deep Research",
+ modelLabel: "Gemini Deep Research",
+ used: nil,
+ limit: nil,
+ lastUpdatedAt: referenceNow.addingTimeInterval(-21_600),
+ confidence: .unknown,
+ statusOverride: .failure,
+ note: "Last good snapshot 6h ago."
+ )
+ ]
+ )
+ }
+
+ static var fastModeForecast: FastModePortfolioForecast {
+ let forecasts = snapshot.limits
+ .filter { $0.provider == .openAI && $0.label.contains("GPT-5") }
+ .map { limit in
+ FastModeForecast(
+ input: FastModeForecastInput(
+ limit: limit,
+ now: referenceNow,
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12),
+ reserveUnits: 6,
+ minimumSafeHours: 1
+ )
+ )
+ }
+ return FastModePortfolioForecast(forecasts: forecasts)
+ }
+}
diff --git a/Sources/ContextPanelWidget/ContextPanelWidget.swift b/Sources/ContextPanelWidget/ContextPanelWidget.swift
new file mode 100644
index 0000000..101a103
--- /dev/null
+++ b/Sources/ContextPanelWidget/ContextPanelWidget.swift
@@ -0,0 +1,112 @@
+import ContextPanelCore
+import SwiftUI
+import WidgetKit
+
+struct ContextPanelWidgetEntry: TimelineEntry {
+ let date: Date
+ let snapshot: WidgetSnapshot
+}
+
+struct ContextPanelTimelineProvider: TimelineProvider {
+ let store: JSONSnapshotStore
+
+ init(
+ store: JSONSnapshotStore = JSONSnapshotStore(
+ rootDirectory: ContextPanelLocations.snapshotDirectory(appGroupID: ContextPanelLocations.appGroupID)
+ )
+ ) {
+ self.store = store
+ }
+
+ func placeholder(in context: Context) -> ContextPanelWidgetEntry {
+ ContextPanelWidgetEntry(date: Date(), snapshot: .placeholder)
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (ContextPanelWidgetEntry) -> Void) {
+ completion(entry(date: Date()))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let now = Date()
+ let nextRefresh = Calendar.current.date(byAdding: .minute, value: 10, to: now) ?? now.addingTimeInterval(600)
+ completion(Timeline(entries: [entry(date: now)], policy: .after(nextRefresh)))
+ }
+
+ private func entry(date: Date) -> ContextPanelWidgetEntry {
+ let result = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 20 * 60), now: date)
+ return ContextPanelWidgetEntry(date: date, snapshot: WidgetSnapshot.fromStore(result, now: date))
+ }
+}
+
+struct ContextPanelWidgetView: View {
+ @Environment(\.widgetFamily) private var family
+ let entry: ContextPanelWidgetEntry
+
+ var body: some View {
+ switch family {
+ case .systemSmall:
+ ContextPanelSmallWidget(snapshot: entry.snapshot)
+ case .systemLarge, .systemExtraLarge:
+ ContextPanelLargeWidget(snapshot: entry.snapshot)
+ default:
+ ContextPanelMediumWidget(snapshot: entry.snapshot)
+ }
+ }
+}
+
+@main
+struct ContextPanelWidget: Widget {
+ let kind = "ContextPanelWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: ContextPanelTimelineProvider()) { entry in
+ ContextPanelWidgetView(entry: entry)
+ .containerBackground(CPWTheme.surface, for: .widget)
+ }
+ .configurationDisplayName("Context Panel")
+ .description("AI account usage limits, reset timing, and fast-mode safety from local snapshots.")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
+
+extension WidgetSnapshot {
+ static var placeholder: WidgetSnapshot {
+ let now = Date()
+ return WidgetSnapshot(
+ state: .ready,
+ generatedAt: now,
+ limits: [
+ UsageLimit(
+ provider: .openAI,
+ accountID: "placeholder-openai",
+ accountName: "OpenAI",
+ label: "Codex Weekly",
+ windowLabel: "Weekly",
+ modelLabel: "Codex",
+ unit: .percent,
+ used: 52,
+ limit: 100,
+ resetsAt: now.addingTimeInterval(18_000),
+ lastUpdatedAt: now.addingTimeInterval(-90),
+ confidence: .observed
+ ),
+ UsageLimit(
+ provider: .google,
+ accountID: "placeholder-google",
+ accountName: "Gemini",
+ label: "gemini-3-pro-preview",
+ windowLabel: "Daily",
+ modelLabel: "gemini-3-pro-preview",
+ unit: .percent,
+ used: 12,
+ limit: 100,
+ resetsAt: now.addingTimeInterval(86_400),
+ lastUpdatedAt: now.addingTimeInterval(-90),
+ confidence: .observed
+ ),
+ ],
+ status: .healthy,
+ message: "Fast mode looks safe."
+ )
+ }
+}
diff --git a/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift
new file mode 100644
index 0000000..01a24f8
--- /dev/null
+++ b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift
@@ -0,0 +1,516 @@
+import ContextPanelCore
+import SwiftUI
+
+struct ContextPanelSmallWidget: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ CPWHeader(status: snapshot.status)
+ Spacer(minLength: 4)
+ if let tightest = snapshot.mostConstrainedLimits.first {
+ Text(snapshot.fastModeVerdict)
+ .font(.system(size: 26, weight: .semibold))
+ .foregroundStyle(CPWTheme.primaryText)
+ .minimumScaleFactor(0.75)
+ .lineLimit(2)
+ Text("\(tightest.provider.shortName) ยท \(tightest.displayLabel) ยท \(tightest.widgetUsageText)")
+ .font(.system(size: 11))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .lineLimit(2)
+ Text(tightest.widgetResetText)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(CPWTheme.tertiaryText)
+ .lineLimit(1)
+ } else {
+ Text("Set up accounts")
+ .font(.system(size: 22, weight: .semibold))
+ .foregroundStyle(CPWTheme.primaryText)
+ .lineLimit(2)
+ Text(snapshot.message)
+ .font(.system(size: 11))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .lineLimit(2)
+ }
+ Spacer(minLength: 4)
+ CPWProviderMiniStatus(snapshot: snapshot)
+ }
+ .padding(16)
+ }
+}
+
+struct ContextPanelMediumWidget: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ HStack(spacing: 16) {
+ VStack(alignment: .leading, spacing: 10) {
+ CPWHeader(status: snapshot.status)
+ Spacer(minLength: 0)
+ CPWCapacityDial(
+ value: snapshot.tightestCapacityRatio,
+ status: snapshot.status,
+ label: "\(Int(snapshot.tightestCapacityRatio * 100))",
+ sublabel: "tightest",
+ size: 86
+ )
+ Text(snapshot.fastModeVerdict)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .lineLimit(2)
+ Spacer(minLength: 0)
+ }
+ .frame(width: 142, alignment: .leading)
+
+ Divider()
+
+ VStack(alignment: .leading, spacing: 8) {
+ CPWSectionHeader(title: "Most Constrained", trailing: "\(snapshot.limits.count) limits")
+ ForEach(snapshot.mostConstrainedLimits.prefix(4)) { limit in
+ CPWLimitRow(limit: limit)
+ }
+ if snapshot.limits.isEmpty {
+ CPWEmptyRow(message: snapshot.message)
+ }
+ }
+ }
+ .padding(16)
+ }
+}
+
+struct ContextPanelLargeWidget: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(alignment: .top, spacing: 12) {
+ VStack(alignment: .leading, spacing: 6) {
+ CPWLabel("Context Panel")
+ Text(snapshot.fastModeVerdict)
+ .font(.system(size: 24, weight: .semibold))
+ .foregroundStyle(CPWTheme.primaryText)
+ .lineLimit(2)
+ Text(snapshot.state.rawValue)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .textCase(.uppercase)
+ }
+ Spacer()
+ CPWCapacityDial(
+ value: snapshot.tightestCapacityRatio,
+ status: snapshot.status,
+ label: "\(Int(snapshot.tightestCapacityRatio * 100))",
+ sublabel: "tightest",
+ size: 82
+ )
+ }
+
+ CPWProviderSummaryGrid(snapshot: snapshot)
+
+ Divider()
+
+ VStack(alignment: .leading, spacing: 8) {
+ CPWFastModeCard(snapshot: snapshot)
+
+ CPWSectionHeader(title: "Account Limits", trailing: snapshot.generatedAt.widgetRelativeText)
+ ForEach(snapshot.mostConstrainedLimits.prefix(6)) { limit in
+ CPWLimitRow(limit: limit)
+ }
+ if snapshot.limits.isEmpty {
+ CPWEmptyRow(message: snapshot.message)
+ }
+ }
+ }
+ .padding(16)
+ }
+}
+
+struct CPWHeader: View {
+ let status: UsageStatus
+
+ var body: some View {
+ HStack {
+ CPWLabel("Context Panel")
+ Spacer()
+ CPWStatusMark(status: status, size: 9)
+ }
+ }
+}
+
+struct CPWLimitRow: View {
+ let limit: UsageLimit
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ CPWProviderBadge(provider: limit.provider, compact: true)
+ Text(limit.displayLabel)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(CPWTheme.primaryText)
+ .lineLimit(1)
+ Text("ยท \(limit.contextLabel)")
+ .font(.system(size: 11))
+ .foregroundStyle(CPWTheme.tertiaryText)
+ .lineLimit(1)
+ Spacer(minLength: 6)
+ Text(limit.widgetUsageText)
+ .font(.system(size: 10, weight: .medium, design: .monospaced))
+ .foregroundStyle(CPWTheme.secondaryText)
+ }
+ HStack(spacing: 8) {
+ CPWCapacityBar(value: limit.usageRatio ?? 0, status: limit.status)
+ Text(limit.widgetResetText)
+ .font(.system(size: 10))
+ .foregroundStyle(CPWTheme.tertiaryText)
+ .lineLimit(1)
+ }
+ }
+ }
+}
+
+struct CPWProviderSummaryGrid: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ HStack(spacing: 12) {
+ ForEach(snapshot.providerSummaries, id: \.provider) { summary in
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 6) {
+ CPWProviderBadge(provider: summary.provider, compact: true)
+ Spacer()
+ CPWStatusMark(status: summary.status, size: 7)
+ }
+ Text(summary.limitCount == 0 ? "setup" : "\(Int(summary.capacityRatio * 100))% tightest room")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(CPWTheme.secondaryText)
+ CPWCapacityBar(value: 1 - summary.capacityRatio, status: summary.status)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+}
+
+struct CPWProviderMiniStatus: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ HStack(spacing: 12) {
+ ForEach(snapshot.providerSummaries, id: \.provider) { summary in
+ HStack(spacing: 5) {
+ CPWProviderBadge(provider: summary.provider, compact: true)
+ }
+ .foregroundStyle(CPWTheme.secondaryText)
+ .opacity(summary.limitCount == 0 ? 0.35 : 1)
+ }
+ }
+ }
+}
+
+struct CPWEmptyRow: View {
+ let message: String
+
+ var body: some View {
+ HStack(spacing: 8) {
+ CPWStatusMark(status: .unknown, size: 9)
+ Text(message)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .lineLimit(2)
+ }
+ }
+}
+
+struct CPWFastModeCard: View {
+ let snapshot: WidgetSnapshot
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "bolt.fill")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(CPWTheme.fastModeColor(snapshot.fastModeStatus))
+ .frame(width: 18)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(snapshot.fastModeVerdict)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(CPWTheme.primaryText)
+ .lineLimit(1)
+ Text(snapshot.fastModeDetail)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(CPWTheme.secondaryText)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 4)
+ }
+ .padding(.vertical, 8)
+ .padding(.horizontal, 10)
+ .background(CPWTheme.line.opacity(0.65))
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+}
+
+struct CPWCapacityDial: View {
+ let value: Double
+ let status: UsageStatus
+ let label: String
+ let sublabel: String
+ var size: CGFloat = 86
+
+ var body: some View {
+ ZStack {
+ Circle().stroke(CPWTheme.line, lineWidth: 6)
+ Circle()
+ .trim(from: 0, to: min(max(value, 0), 1))
+ .stroke(CPWTheme.statusColor(status), style: StrokeStyle(lineWidth: 6, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ VStack(spacing: 0) {
+ Text(label)
+ .font(.system(size: 22, weight: .semibold, design: .monospaced))
+ .foregroundStyle(CPWTheme.primaryText)
+ Text(sublabel)
+ .font(.system(size: 9, weight: .medium))
+ .foregroundStyle(CPWTheme.tertiaryText)
+ .textCase(.uppercase)
+ }
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+struct CPWCapacityBar: View {
+ let value: Double
+ let status: UsageStatus
+
+ var body: some View {
+ GeometryReader { proxy in
+ ZStack(alignment: .leading) {
+ Capsule().fill(CPWTheme.line)
+ Capsule()
+ .fill(CPWTheme.statusColor(status))
+ .frame(width: proxy.size.width * min(max(value, 0), 1))
+ }
+ }
+ .frame(height: 4)
+ }
+}
+
+struct CPWProviderBadge: View {
+ let provider: Provider
+ var compact = false
+
+ var body: some View {
+ Text(provider.shortName)
+ .font(.system(size: compact ? 9 : 10, weight: .semibold, design: .monospaced))
+ .foregroundStyle(CPWTheme.providerColor(provider))
+ .lineLimit(1)
+ }
+}
+
+struct CPWStatusMark: View {
+ let status: UsageStatus
+ var size: CGFloat = 8
+
+ var body: some View {
+ Group {
+ switch status {
+ case .healthy:
+ Circle().fill(CPWTheme.statusColor(status))
+ case .close:
+ Circle().trim(from: 0, to: 0.75).stroke(CPWTheme.statusColor(status), lineWidth: 2)
+ case .limited:
+ RoundedRectangle(cornerRadius: 1).fill(CPWTheme.statusColor(status))
+ case .stale:
+ Circle().stroke(CPWTheme.statusColor(status), style: StrokeStyle(lineWidth: 1.4, dash: [2, 2]))
+ case .unknown:
+ Text("?").font(.system(size: size + 3, weight: .semibold)).foregroundStyle(CPWTheme.statusColor(status))
+ case .failure:
+ Image(systemName: "xmark").font(.system(size: size, weight: .bold)).foregroundStyle(CPWTheme.statusColor(status))
+ case .loading:
+ Circle().stroke(CPWTheme.statusColor(status), lineWidth: 1.4)
+ }
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+struct CPWSectionHeader: View {
+ let title: String
+ var trailing: String? = nil
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline) {
+ Text(title)
+ .font(.system(size: 10, weight: .semibold))
+ .tracking(0.8)
+ .textCase(.uppercase)
+ .foregroundStyle(CPWTheme.tertiaryText)
+ Spacer()
+ if let trailing {
+ Text(trailing)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(CPWTheme.tertiaryText)
+ }
+ }
+ }
+}
+
+struct CPWLabel: View {
+ let text: String
+
+ init(_ text: String) {
+ self.text = text
+ }
+
+ var body: some View {
+ HStack(spacing: 6) {
+ RoundedRectangle(cornerRadius: 1)
+ .fill(CPWTheme.accent)
+ .rotationEffect(.degrees(45))
+ .frame(width: 6, height: 6)
+ Text(text)
+ .font(.system(size: 10, weight: .semibold))
+ .tracking(0.8)
+ .textCase(.uppercase)
+ .foregroundStyle(CPWTheme.tertiaryText)
+ }
+ }
+}
+
+enum CPWTheme {
+ static let surface = Color(red: 250 / 255, green: 250 / 255, blue: 250 / 255)
+ static let line = Color.black.opacity(0.08)
+ static let primaryText = Color(red: 10 / 255, green: 10 / 255, blue: 11 / 255)
+ static let secondaryText = primaryText.opacity(0.66)
+ static let tertiaryText = primaryText.opacity(0.46)
+ static let accent = Color(red: 74 / 255, green: 91 / 255, blue: 122 / 255)
+
+ static func providerColor(_ provider: Provider) -> Color {
+ switch provider {
+ case .openAI:
+ Color(red: 56 / 255, green: 92 / 255, blue: 126 / 255)
+ case .anthropic:
+ Color(red: 139 / 255, green: 102 / 255, blue: 51 / 255)
+ case .google:
+ Color(red: 35 / 255, green: 116 / 255, blue: 106 / 255)
+ }
+ }
+
+ static func fastModeColor(_ recommendation: FastModeRecommendation?) -> Color {
+ switch recommendation {
+ case .safeThroughReset:
+ statusColor(.healthy)
+ case .safeForLimitedTime:
+ statusColor(.close)
+ case .saveFastMode, .limited:
+ statusColor(.limited)
+ case .needsCalibration, nil:
+ statusColor(.unknown)
+ }
+ }
+
+ static func statusColor(_ status: UsageStatus) -> Color {
+ switch status {
+ case .healthy:
+ Color(red: 74 / 255, green: 122 / 255, blue: 91 / 255)
+ case .close:
+ Color(red: 138 / 255, green: 106 / 255, blue: 42 / 255)
+ case .limited, .failure:
+ Color(red: 138 / 255, green: 74 / 255, blue: 74 / 255)
+ case .stale, .unknown, .loading:
+ Color(red: 106 / 255, green: 106 / 255, blue: 114 / 255)
+ }
+ }
+}
+
+extension UsageLimit {
+ var widgetUsageText: String {
+ if provider == .anthropic, unit == .unknown, status == .unknown {
+ return "allowance unknown"
+ }
+ if let remaining, let limit {
+ return "\(remaining)/\(limit) left"
+ }
+ if status == .failure { return "refresh failed" }
+ return "unknown"
+ }
+
+ var widgetResetText: String {
+ guard let resetsAt else {
+ return status == .failure ? "refresh failed" : "unknown reset"
+ }
+ if resetsAt < Date().addingTimeInterval(-60) {
+ return "reset passed"
+ }
+ return "resets \(resetsAt.widgetRelativeText)"
+ }
+}
+
+extension WidgetSnapshot {
+ var tightestCapacityRatio: Double {
+ guard let ratio = mostConstrainedLimits.first?.usageRatio else { return 0 }
+ return max(1 - ratio, 0)
+ }
+
+ var fastModeForecast: FastModeForecast? {
+ let forecasts = limits
+ .filter { $0.provider == .openAI && $0.unit == .percent }
+ .map { limit in
+ FastModeForecast(input: FastModeForecastInput(
+ limit: limit,
+ now: Date(),
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12),
+ reserveUnits: 6,
+ minimumSafeHours: 1
+ ))
+ }
+ return FastModePortfolioForecast(forecasts: forecasts).bestForecast
+ }
+
+ var fastModeStatus: FastModeRecommendation? {
+ fastModeForecast?.recommendation
+ }
+
+ var fastModeVerdict: String {
+ fastModeForecast?.copy ?? message
+ }
+
+ var fastModeDetail: String {
+ guard let forecast = fastModeForecast else { return "OpenAI account needed for fast-mode forecast" }
+ let runway = forecast.fastModeRunwayHours.map { "runway \(Self.format(hours: $0))" } ?? "runway unknown"
+ let reset = forecast.hoursUntilReset.map { "reset \(Self.format(hours: $0))" } ?? "reset unknown"
+ return "\(forecast.accountName) ยท \(runway) ยท \(reset)"
+ }
+
+ private static func format(hours: Double) -> String {
+ if hours < 1 {
+ return "\(max(Int((hours * 60).rounded()), 1))m"
+ }
+ if hours < 10 {
+ let rounded = (hours * 2).rounded() / 2
+ if rounded == rounded.rounded() { return "\(Int(rounded))h" }
+ return "\(rounded)h"
+ }
+ return "\(Int(hours.rounded()))h"
+ }
+}
+
+extension Date {
+ var widgetRelativeText: String {
+ let seconds = Int(timeIntervalSince(Date()))
+ if abs(seconds) < 60 { return "now" }
+ if seconds >= 0 {
+ let minutes = seconds / 60
+ if minutes < 60 { return "in \(minutes)m" }
+ let hours = minutes / 60
+ if hours < 24 { return "in \(hours)h" }
+ return "in \(hours / 24)d"
+ }
+ let elapsed = abs(seconds)
+ let minutes = elapsed / 60
+ if minutes < 60 { return "\(minutes)m ago" }
+ let hours = minutes / 60
+ if hours < 24 { return "\(hours)h ago" }
+ return "\(hours / 24)d ago"
+ }
+}
diff --git a/Sources/GeminiQuotaProbe/main.swift b/Sources/GeminiQuotaProbe/main.swift
new file mode 100644
index 0000000..b3d6e6d
--- /dev/null
+++ b/Sources/GeminiQuotaProbe/main.swift
@@ -0,0 +1,132 @@
+import ContextPanelCore
+import Foundation
+
+struct GeminiProbeError: LocalizedError {
+ let message: String
+
+ var errorDescription: String? { message }
+}
+struct ProbeConfiguration {
+ let account: GeminiAccountConfiguration
+
+ static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration {
+ var authPath: String?
+ var tokenEndpoint = URL(string: "https://oauth2.googleapis.com/token")!
+ var codeAssistEndpoint = URL(string: "https://cloudcode-pa.googleapis.com/v1internal")!
+ var metadata = GeminiOAuthClientMetadataDiscovery.discover()
+ var iterator = arguments.dropFirst().makeIterator()
+
+ while let argument = iterator.next() {
+ switch argument {
+ case "--auth":
+ guard let value = iterator.next() else {
+ throw GeminiProbeError(message: "--auth requires a path")
+ }
+ authPath = value
+ case "--token-endpoint":
+ guard let value = iterator.next(), let url = URL(string: value) else {
+ throw GeminiProbeError(message: "--token-endpoint requires an absolute URL")
+ }
+ tokenEndpoint = url
+ case "--code-assist-endpoint":
+ guard let value = iterator.next(), let url = URL(string: value) else {
+ throw GeminiProbeError(message: "--code-assist-endpoint requires an absolute URL")
+ }
+ codeAssistEndpoint = url
+ case "--client-id":
+ guard let value = iterator.next(), !value.isEmpty else {
+ throw GeminiProbeError(message: "--client-id requires a value")
+ }
+ metadata = GeminiOAuthClientMetadata(
+ clientID: value,
+ clientSecret: metadata?.clientSecret ?? ""
+ )
+ case "--client-secret":
+ guard let value = iterator.next(), !value.isEmpty else {
+ throw GeminiProbeError(message: "--client-secret requires a value")
+ }
+ metadata = GeminiOAuthClientMetadata(
+ clientID: metadata?.clientID ?? "",
+ clientSecret: value
+ )
+ case "--help", "-h":
+ printHelp()
+ Foundation.exit(0)
+ default:
+ throw GeminiProbeError(message: "unknown argument: \(argument)")
+ }
+ }
+
+ guard let metadata, !metadata.clientID.isEmpty, !metadata.clientSecret.isEmpty else {
+ throw GeminiProbeError(message: "install Gemini CLI, set GEMINI_OAUTH_CLIENT_ID/SECRET, or pass --client-id/--client-secret")
+ }
+
+ return ProbeConfiguration(account: GeminiAccountConfiguration(
+ authPath: authPath ?? defaultAuthPath(),
+ tokenEndpoint: tokenEndpoint,
+ codeAssistEndpoint: codeAssistEndpoint,
+ clientID: metadata.clientID,
+ clientSecret: metadata.clientSecret
+ ))
+ }
+
+ private static func defaultAuthPath() -> String {
+ let environment = ProcessInfo.processInfo.environment
+ let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path
+ let geminiHome = environment["GEMINI_CLI_HOME"] ?? "\(home)/.gemini"
+ return "\(geminiHome)/oauth_creds.json"
+ }
+
+ private static func printHelp() {
+ print("""
+ Usage: swift run GeminiQuotaProbe [--auth /path/to/oauth_creds.json]
+
+ Uses the locally installed Gemini CLI OAuth client metadata when
+ available. You can also set GEMINI_OAUTH_CLIENT_ID and
+ GEMINI_OAUTH_CLIENT_SECRET, or pass --client-id and --client-secret.
+ Do not commit OAuth client values to this repository.
+
+ Uses the production Gemini Code Assist connector and prints only a
+ redacted quota summary. Tokens, account identifiers, project IDs,
+ emails, headers, and raw response bodies are never printed.
+ """)
+ }
+}
+
+@main
+struct GeminiQuotaProbe {
+ static func main() async {
+ do {
+ let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments)
+ let connector = GeminiCodeAssistConnector(accounts: [configuration.account])
+ let result = await connector.refresh(now: Date())
+ printSummary(result: result, account: configuration.account)
+ } catch {
+ fputs("GeminiQuotaProbe failed: \(error.localizedDescription)\n", stderr)
+ Foundation.exit(1)
+ }
+ }
+
+ private static func printSummary(result: ConnectorRefreshResult, account: GeminiAccountConfiguration) {
+ print("Gemini Code Assist quota probe")
+ print("endpoint: \(account.codeAssistEndpoint.absoluteString)")
+ print("auth: \(ConnectorRedactor.redactedPath(account.authPath))")
+ print("accounts: \(result.reports.count)")
+ print("limits: \(result.snapshot.limits.count)")
+ print("redacted: tokens, account identifiers, project IDs, emails, headers, raw response bodies")
+ print("")
+
+ for report in result.reports {
+ print("- \(report.accountName): \(report.status.rawValue)")
+ if let errorMessage = report.errorMessage {
+ print(" error: \(errorMessage)")
+ }
+ for limit in report.limits.sorted(by: { $0.label < $1.label }) {
+ let used = limit.used.map { "\($0)% used" } ?? "unknown used"
+ let remaining = limit.remaining.map { "\($0)% remaining" } ?? "unknown remaining"
+ let reset = limit.resetsAt.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown reset"
+ print(" - \(limit.label): \(used), \(remaining), resets \(reset)")
+ }
+ }
+ }
+}
diff --git a/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift
new file mode 100644
index 0000000..3a0c712
--- /dev/null
+++ b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift
@@ -0,0 +1,426 @@
+import ContextPanelCore
+import SwiftUI
+import UniformTypeIdentifiers
+import WebKit
+
+@main
+struct OpenAILimitProbeApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ProbeRootView()
+ .frame(minWidth: 1180, minHeight: 760)
+ }
+ }
+}
+
+struct ProbeRootView: View {
+ @StateObject private var model = ProbeModel()
+
+ var body: some View {
+ HStack(spacing: 0) {
+ VStack(alignment: .leading, spacing: 14) {
+ header
+ controls
+ Divider()
+ ObservationList(observations: model.observations)
+ NetworkEventList(events: model.networkEvents)
+ Spacer()
+ safetyFooter
+ }
+ .frame(width: 360)
+ .padding(18)
+ .background(Color(nsColor: .controlBackgroundColor))
+
+ Divider()
+
+ ProbeWebView(model: model)
+ }
+ .fileExporter(
+ isPresented: $model.isExportingReport,
+ document: ProbeReportDocument(report: model.reportMarkdown),
+ contentType: .plainText,
+ defaultFilename: "openai-limit-probe-report.md"
+ ) { _ in }
+ }
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("OpenAI Limit Probe")
+ .font(.system(size: 24, weight: .semibold))
+ Text("Log in directly with OpenAI, navigate to ChatGPT/model picker, then scan visible text for subscription limit signals.")
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var controls: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Button("Open ChatGPT") {
+ model.loadChatGPT()
+ }
+ Button("Scan Visible Text") {
+ model.scanVisibleText()
+ }
+ .keyboardShortcut("s", modifiers: [.command])
+ }
+
+ HStack {
+ Button("Record Manual Observation") {
+ model.recordManualObservation()
+ }
+ Button("Export Redacted Report") {
+ model.exportReport()
+ }
+ .keyboardShortcut("e", modifiers: [.command])
+ }
+
+ TextField("Manual note, e.g. resets tomorrow 9:00 AM", text: $model.manualObservation)
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+
+ private var safetyFooter: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Label("No passwords, cookies, auth headers, or raw response bodies are exported.", systemImage: "lock.shield")
+ Label("Network capture stores only method/path/status/body size and matching field names.", systemImage: "point.3.connected.trianglepath.dotted")
+ }
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+}
+
+struct NetworkEventList: View {
+ let events: [NetworkProbeEvent]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Network candidates")
+ .font(.system(size: 12, weight: .semibold))
+ .textCase(.uppercase)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(events.count)")
+ .font(.system(.caption, design: .monospaced, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ if events.isEmpty {
+ Text("No candidate response fields yet. Open model menus or settings, then scan/navigate.")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(events.prefix(12)) { event in
+ VStack(alignment: .leading, spacing: 4) {
+ Text("\(event.method) \(event.pathHint)")
+ .font(.system(size: 11, design: .monospaced))
+ .lineLimit(2)
+ Text(event.matchedFields.joined(separator: ", "))
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ Text("status \(event.status.map(String.init) ?? "?") ยท \(event.bodySize ?? 0)b")
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(nsColor: .textBackgroundColor))
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+ }
+ }
+ .frame(maxHeight: 220)
+ }
+ }
+ }
+}
+
+struct ObservationList: View {
+ let observations: [LimitProbeObservation]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Observations")
+ .font(.system(size: 12, weight: .semibold))
+ .textCase(.uppercase)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(observations.count)")
+ .font(.system(.caption, design: .monospaced, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ if observations.isEmpty {
+ ContentUnavailableView(
+ "No signals yet",
+ systemImage: "waveform.path.ecg.rectangle",
+ description: Text("Open ChatGPT, log in, navigate to the model picker, and scan visible text.")
+ )
+ .frame(maxHeight: 220)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(observations) { observation in
+ VStack(alignment: .leading, spacing: 4) {
+ Text(observation.signalKind.rawValue)
+ .font(.system(size: 12, weight: .semibold))
+ Text(observation.sanitizedEvidence)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ Text("\(observation.source.rawValue) ยท \(observation.confidence.rawValue)")
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(nsColor: .textBackgroundColor))
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@MainActor
+final class ProbeModel: ObservableObject {
+ @Published var observations: [LimitProbeObservation] = []
+ @Published var networkEvents: [NetworkProbeEvent] = []
+ @Published var manualObservation = ""
+ @Published var isExportingReport = false
+
+ lazy var webView: WKWebView = {
+ let configuration = WKWebViewConfiguration()
+ configuration.websiteDataStore = .nonPersistent()
+ configuration.userContentController.add(ProbeScriptHandler(owner: self), name: "limitProbe")
+ configuration.userContentController.addUserScript(
+ WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false)
+ )
+ return WKWebView(frame: .zero, configuration: configuration)
+ }()
+
+ init() {
+ loadChatGPT()
+ }
+
+ var reportMarkdown: String {
+ LimitProbeReport(
+ provider: .openAI,
+ capturedAt: Date(),
+ observations: observations,
+ networkEvents: networkEvents
+ ).markdownSummary
+ }
+
+ func loadChatGPT() {
+ webView.load(URLRequest(url: URL(string: "https://chatgpt.com/")!))
+ }
+
+ func scanVisibleText() {
+ let script = "document.body ? document.body.innerText : ''"
+ webView.evaluateJavaScript(script) { [weak self] result, error in
+ Task { @MainActor in
+ guard let self else { return }
+ if let text = result as? String {
+ let newObservations = LimitProbeScanner.scanVisibleText(text, provider: .openAI)
+ self.merge(newObservations)
+ } else if let error {
+ self.merge([
+ LimitProbeObservation(
+ provider: .openAI,
+ observedAt: Date(),
+ source: .visibleText,
+ signalKind: .limitReached,
+ confidence: .unknown,
+ sanitizedEvidence: "Scan failed: \(error.localizedDescription)"
+ )
+ ])
+ }
+ }
+ }
+ }
+
+ func recordManualObservation() {
+ let trimmed = manualObservation.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ merge([
+ LimitProbeObservation(
+ provider: .openAI,
+ observedAt: Date(),
+ source: .manualUserEntry,
+ signalKind: .resetLanguage,
+ confidence: .manual,
+ sanitizedEvidence: trimmed
+ )
+ ])
+ manualObservation = ""
+ }
+
+ func exportReport() {
+ isExportingReport = true
+ }
+
+ private func merge(_ newObservations: [LimitProbeObservation]) {
+ var keys = Set(observations.map { "\($0.signalKind.rawValue):\($0.sanitizedEvidence.lowercased())" })
+ for observation in newObservations {
+ let key = "\(observation.signalKind.rawValue):\(observation.sanitizedEvidence.lowercased())"
+ guard !keys.contains(key) else { continue }
+ keys.insert(key)
+ observations.append(observation)
+ }
+ }
+
+ fileprivate func record(networkEvent event: NetworkProbeEvent) {
+ let key = "\(event.method):\(event.pathHint):\(event.matchedFields.joined(separator: ","))"
+ let existing = Set(networkEvents.map { "\($0.method):\($0.pathHint):\($0.matchedFields.joined(separator: ","))" })
+ guard !existing.contains(key), !event.matchedFields.isEmpty else { return }
+ networkEvents.insert(event, at: 0)
+ }
+
+ private static let networkProbeScript = #"""
+ (() => {
+ if (window.__contextPanelLimitProbeInstalled) return;
+ window.__contextPanelLimitProbeInstalled = true;
+ const candidate = /limit|usage|remaining|reset|quota|message|model|plan|cap/i;
+
+ function pathHint(rawUrl) {
+ try { return new URL(rawUrl, window.location.href).pathname; }
+ catch (_) { return String(rawUrl || '').split('?')[0].slice(0, 180); }
+ }
+
+ function collectFields(value, prefix = '', out = new Set(), depth = 0) {
+ if (!value || depth > 3 || out.size > 80) return out;
+ if (Array.isArray(value)) {
+ value.slice(0, 3).forEach(item => collectFields(item, prefix, out, depth + 1));
+ return out;
+ }
+ if (typeof value !== 'object') return out;
+ Object.keys(value).forEach(key => {
+ const full = prefix ? `${prefix}.${key}` : key;
+ if (candidate.test(full)) out.add(full);
+ collectFields(value[key], full, out, depth + 1);
+ });
+ return out;
+ }
+
+ function post(event) {
+ try { window.webkit.messageHandlers.limitProbe.postMessage(event); }
+ catch (_) {}
+ }
+
+ function inspect(method, url, status, contentType, text) {
+ const body = String(text || '');
+ let fields = [];
+ if (/json/i.test(contentType || '') && body.length < 2_000_000) {
+ try { fields = Array.from(collectFields(JSON.parse(body))).slice(0, 40); }
+ catch (_) { fields = []; }
+ }
+ if (fields.length === 0) return;
+ post({
+ method: method || 'GET',
+ pathHint: pathHint(url),
+ status: status || null,
+ contentType: contentType || null,
+ bodySize: body.length,
+ matchedFields: fields
+ });
+ }
+
+ const originalFetch = window.fetch;
+ if (originalFetch) {
+ window.fetch = async function(input, init) {
+ const response = await originalFetch.apply(this, arguments);
+ try {
+ const clone = response.clone();
+ const url = typeof input === 'string' ? input : (input && input.url) || '';
+ const method = (init && init.method) || (input && input.method) || 'GET';
+ const contentType = clone.headers.get('content-type') || '';
+ clone.text().then(text => inspect(method, url, clone.status, contentType, text)).catch(() => {});
+ } catch (_) {}
+ return response;
+ };
+ }
+
+ const originalOpen = XMLHttpRequest.prototype.open;
+ const originalSend = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function(method, url) {
+ this.__cpMethod = method;
+ this.__cpUrl = url;
+ return originalOpen.apply(this, arguments);
+ };
+ XMLHttpRequest.prototype.send = function() {
+ this.addEventListener('load', function() {
+ try {
+ const contentType = this.getResponseHeader('content-type') || '';
+ inspect(this.__cpMethod || 'GET', this.__cpUrl || '', this.status, contentType, this.responseText || '');
+ } catch (_) {}
+ });
+ return originalSend.apply(this, arguments);
+ };
+ })();
+ """#
+}
+
+final class ProbeScriptHandler: NSObject, WKScriptMessageHandler {
+ weak var owner: ProbeModel?
+
+ init(owner: ProbeModel) {
+ self.owner = owner
+ }
+
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let payload = message.body as? [String: Any] else { return }
+ let method = payload["method"] as? String ?? "GET"
+ let pathHint = payload["pathHint"] as? String ?? ""
+ let status = payload["status"] as? Int
+ let contentType = payload["contentType"] as? String
+ let bodySize = payload["bodySize"] as? Int
+ let matchedFields = payload["matchedFields"] as? [String] ?? []
+ let event = NetworkProbeEvent(
+ observedAt: Date(),
+ method: method,
+ pathHint: pathHint,
+ status: status,
+ contentType: contentType,
+ bodySize: bodySize,
+ matchedFields: matchedFields
+ )
+ Task { @MainActor [weak owner = self.owner] in
+ owner?.record(networkEvent: event)
+ }
+ }
+}
+
+struct ProbeWebView: NSViewRepresentable {
+ @ObservedObject var model: ProbeModel
+
+ func makeNSView(context: Context) -> WKWebView {
+ model.webView
+ }
+
+ func updateNSView(_ nsView: WKWebView, context: Context) {}
+}
+
+struct ProbeReportDocument: FileDocument {
+ static var readableContentTypes: [UTType] { [.plainText] }
+
+ var report: String
+
+ init(report: String) {
+ self.report = report
+ }
+
+ init(configuration: ReadConfiguration) throws {
+ report = ""
+ }
+
+ func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
+ FileWrapper(regularFileWithContents: Data(report.utf8))
+ }
+}
diff --git a/Sources/SnapshotStoreProbe/main.swift b/Sources/SnapshotStoreProbe/main.swift
new file mode 100644
index 0000000..1bfd4c4
--- /dev/null
+++ b/Sources/SnapshotStoreProbe/main.swift
@@ -0,0 +1,128 @@
+import ContextPanelCore
+import Foundation
+
+struct SnapshotStoreProbeError: LocalizedError {
+ let message: String
+
+ var errorDescription: String? { message }
+}
+
+struct ProbeConfiguration {
+ let outputDirectory: URL
+ let codexAccounts: [CodexAccountConfiguration]
+ let includeConfiguredAccounts: Bool
+ let includeClaude: Bool
+
+ static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration {
+ var outputDirectory: URL?
+ var codexAccounts: [CodexAccountConfiguration] = []
+ var includeConfiguredAccounts = false
+ var includeClaude = false
+ var iterator = arguments.dropFirst().makeIterator()
+
+ while let argument = iterator.next() {
+ switch argument {
+ case "--output":
+ guard let value = iterator.next() else {
+ throw SnapshotStoreProbeError(message: "--output requires a directory path")
+ }
+ outputDirectory = URL(fileURLWithPath: NSString(string: value).expandingTildeInPath)
+ case "--codex-auth":
+ guard let value = iterator.next() else {
+ throw SnapshotStoreProbeError(message: "--codex-auth requires a path")
+ }
+ codexAccounts.append(CodexAccountConfiguration(authPath: value))
+ case "--codex-account":
+ guard let value = iterator.next() else {
+ throw SnapshotStoreProbeError(message: "--codex-account requires label=path")
+ }
+ codexAccounts.append(try parseCodexAccount(value))
+ case "--configured-accounts":
+ includeConfiguredAccounts = true
+ case "--include-claude":
+ includeClaude = true
+ case "--help", "-h":
+ printHelp()
+ Foundation.exit(0)
+ default:
+ throw SnapshotStoreProbeError(message: "unknown argument: \(argument)")
+ }
+ }
+
+ return ProbeConfiguration(
+ outputDirectory: outputDirectory ?? defaultOutputDirectory(),
+ codexAccounts: codexAccounts,
+ includeConfiguredAccounts: includeConfiguredAccounts,
+ includeClaude: includeClaude
+ )
+ }
+
+ private static func parseCodexAccount(_ value: String) throws -> CodexAccountConfiguration {
+ let parts = value.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
+ guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else {
+ throw SnapshotStoreProbeError(message: "--codex-account requires label=path")
+ }
+ return CodexAccountConfiguration(authPath: String(parts[1]), accountName: String(parts[0]))
+ }
+
+ private static func defaultOutputDirectory() -> URL {
+ FileManager.default.temporaryDirectory
+ .appending(path: "context-panel-snapshot-store", directoryHint: .isDirectory)
+ }
+
+ private static func printHelp() {
+ print("""
+ Usage: swift run SnapshotStoreProbe [--output /tmp/context-panel-store] [--configured-accounts] [--codex-account Label=~/.codex/auth.json] [--include-claude]
+
+ Refreshes selected local connectors, writes the normalized snapshot to
+ the JSON snapshot store, then reloads it. The store contains normalized
+ usage state only; tokens, account identifiers, project IDs, emails,
+ headers, and raw provider responses are not persisted.
+ """)
+ }
+}
+
+@main
+struct SnapshotStoreProbe {
+ static func main() async {
+ do {
+ let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments)
+ let connectors = makeConnectors(configuration: configuration)
+ guard !connectors.isEmpty else {
+ throw SnapshotStoreProbeError(message: "no connectors selected")
+ }
+
+ let result = await ProviderConnectorRuntime(connectors: connectors).refreshAll()
+ let store = JSONSnapshotStore(rootDirectory: configuration.outputDirectory)
+ try store.save(StoredUsageSnapshot(savedAt: Date(), refreshResult: result))
+ let loaded = store.loadCurrent(policy: SnapshotStoreStalenessPolicy())
+
+ print("Context Panel snapshot store probe")
+ print("store: \(ConnectorRedactor.redactedPath(configuration.outputDirectory.path))")
+ print("reports: \(result.reports.count)")
+ print("limits: \(result.snapshot.limits.count)")
+ print("load status: \(loaded.status.rawValue)")
+ print("history entries: \(store.loadHistory().count)")
+ print("redacted: tokens, account identifiers, project IDs, emails, headers, raw response bodies")
+ } catch {
+ fputs("SnapshotStoreProbe failed: \(error.localizedDescription)\n", stderr)
+ Foundation.exit(1)
+ }
+ }
+
+ private static func makeConnectors(configuration: ProbeConfiguration) -> [any ProviderConnector] {
+ if configuration.includeConfiguredAccounts {
+ let store = AccountConfigurationStore(configurationURL: ContextPanelLocations.accountConfigurationURL())
+ return AccountConnectorFactory.connectors(from: store.load().document)
+ }
+
+ var connectors: [any ProviderConnector] = []
+ if !configuration.codexAccounts.isEmpty {
+ connectors.append(CodexRateLimitConnector(accounts: configuration.codexAccounts))
+ }
+ if configuration.includeClaude {
+ connectors.append(ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration()]))
+ }
+ return connectors
+ }
+}
diff --git a/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift
new file mode 100644
index 0000000..54844bb
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift
@@ -0,0 +1,124 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func accountConfigurationStoreReturnsDefaultsWhenMissing() throws {
+ let store = AccountConfigurationStore(configurationURL: try temporaryDirectory().appending(path: "accounts.json"))
+
+ let result = store.load(now: Date(timeIntervalSince1970: 0))
+
+ #expect(result.status == .unknown)
+ #expect(result.document.accounts.count == 3)
+ #expect(result.document.accounts.contains { $0.connectorKind == .codexRateLimits && $0.isEnabled })
+ #expect(result.document.accounts.contains { $0.connectorKind == .geminiCodeAssist && !$0.isEnabled })
+}
+
+@Test func accountConfigurationStoreRoundTripsAccounts() throws {
+ let url = try temporaryDirectory().appending(path: "accounts.json")
+ let store = AccountConfigurationStore(configurationURL: url)
+ let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 10), accounts: [
+ LocalProviderAccountConfiguration(
+ id: "codex-a",
+ provider: .openAI,
+ connectorKind: .codexRateLimits,
+ displayName: "OpenAI A",
+ authPath: "/tmp/codex-a.json"
+ )
+ ])
+
+ try store.save(document)
+ let result = store.load()
+
+ #expect(result.status == .healthy)
+ #expect(result.document == document)
+}
+
+@Test func accountConnectorFactorySkipsDisabledAndMissingSecretEnvironment() {
+ let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [
+ LocalProviderAccountConfiguration(
+ id: "codex",
+ provider: .openAI,
+ connectorKind: .codexRateLimits,
+ displayName: "OpenAI",
+ authPath: "/tmp/codex.json"
+ ),
+ LocalProviderAccountConfiguration(
+ id: "gemini",
+ provider: .google,
+ connectorKind: .geminiCodeAssist,
+ displayName: "Gemini",
+ authPath: "/tmp/gemini.json",
+ oauthClientIDEnvironmentName: "GEMINI_ID",
+ oauthClientSecretEnvironmentName: "GEMINI_SECRET"
+ ),
+ LocalProviderAccountConfiguration(
+ id: "claude-disabled",
+ provider: .anthropic,
+ connectorKind: .claudeLocalStatus,
+ displayName: "Claude",
+ isEnabled: false
+ ),
+ ])
+
+ let withoutGeminiEnvironment = AccountConnectorFactory.connectors(
+ from: document,
+ environment: [:],
+ geminiMetadataFileLoader: { _ in "" },
+ geminiMetadataFileExists: { _ in false }
+ )
+ let withGeminiEnvironment = AccountConnectorFactory.connectors(from: document, environment: [
+ "GEMINI_ID": "client",
+ "GEMINI_SECRET": "secret",
+ ])
+
+ #expect(withoutGeminiEnvironment.count == 1)
+ #expect(withoutGeminiEnvironment[0].provider == .openAI)
+ #expect(withGeminiEnvironment.count == 2)
+ #expect(Set(withGeminiEnvironment.map(\.provider)) == [.openAI, .google])
+}
+
+@Test func accountConnectorFactoryCanDiscoverGeminiMetadataFromInstalledCLI() {
+ let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [
+ LocalProviderAccountConfiguration(
+ id: "gemini",
+ provider: .google,
+ connectorKind: .geminiCodeAssist,
+ displayName: "Gemini",
+ authPath: "/tmp/gemini.json"
+ )
+ ])
+
+ let source = #"""
+ var OAUTH_CLIENT_ID = "client-id.apps.googleusercontent.com";
+ var OAUTH_CLIENT_SECRET = "client-secret";
+ """#
+ let connectors = AccountConnectorFactory.connectors(
+ from: document,
+ environment: ["GEMINI_CLI_BUNDLE_PATH": "/tmp/gemini-bundle.js"],
+ geminiMetadataFileLoader: { _ in source },
+ geminiMetadataFileExists: { _ in true }
+ )
+
+ #expect(connectors.count == 1)
+ #expect(connectors[0].provider == .google)
+}
+
+@Test func accountConfigurationStoreReportsCorruptFilesAsFailure() throws {
+ let url = try temporaryDirectory().appending(path: "accounts.json")
+ try Data("nope".utf8).write(to: url)
+
+ let result = AccountConfigurationStore(configurationURL: url).load(now: Date(timeIntervalSince1970: 0))
+
+ #expect(result.status == .failure)
+ #expect(result.document.accounts.count == 3)
+ #expect(result.errorMessage?.isEmpty == false)
+}
+
+private func temporaryDirectory() throws -> URL {
+ let url = FileManager.default.temporaryDirectory
+ .appending(path: "context-panel-account-tests")
+ .appending(path: UUID().uuidString, directoryHint: .isDirectory)
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ return url
+}
diff --git a/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift
new file mode 100644
index 0000000..9869c89
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift
@@ -0,0 +1,244 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func claudeAuthStatusParserReducesStatusToNonSecretFields() throws {
+ let json = #"""
+ {
+ "loggedIn": true,
+ "authMethod": "claude.ai",
+ "apiProvider": "firstParty",
+ "email": "friend@example.com",
+ "orgId": "org_secret",
+ "subscriptionType": "pro"
+ }
+ """#
+
+ let status = try ClaudeAuthStatusParser.status(from: Data(json.utf8))
+
+ #expect(status.loggedIn)
+ #expect(status.authMethod == "claude.ai")
+ #expect(status.apiProvider == "firstParty")
+ #expect(status.subscriptionType == "pro")
+ #expect(status.subscriptionDisplayName == "Claude Pro")
+}
+
+@Test func claudeStatsCacheParserSummarizesLocalActivityOnly() throws {
+ let json = #"""
+ {
+ "version": 2,
+ "lastComputedDate": "2026-05-06T14:00:00Z",
+ "dailyActivity": {
+ "2026-05-06": { "messageCount": 5 }
+ },
+ "modelUsage": {
+ "claude-sonnet-4-6": { "inputTokens": 12 },
+ "claude-opus-4-6": { "inputTokens": 3 }
+ },
+ "totalSessions": 7,
+ "totalMessages": 42,
+ "firstSessionDate": "2026-05-01"
+ }
+ """#
+
+ let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8))
+
+ #expect(summary.version == 2)
+ #expect(ContextPanelDateFormatting.string(from: summary.lastComputedDate!) == "2026-05-06T14:00:00Z")
+ #expect(summary.totalSessions == 7)
+ #expect(summary.totalMessages == 42)
+ #expect(summary.modelUsageCount == 2)
+ #expect(summary.dailyActivityCount == 1)
+}
+
+@Test func claudeStatsCacheParserAcceptsArrayShapedDailyActivity() throws {
+ let json = #"""
+ {
+ "lastComputedDate": "2026-04-26",
+ "dailyActivity": [
+ { "date": "2026-04-25" },
+ { "date": "2026-04-26" }
+ ],
+ "modelUsage": {},
+ "totalSessions": 1,
+ "totalMessages": 2
+ }
+ """#
+
+ let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8))
+
+ #expect(ContextPanelDateFormatting.string(from: summary.lastComputedDate!) == "2026-04-26T00:00:00Z")
+ #expect(summary.dailyActivityCount == 2)
+ #expect(summary.modelUsageCount == 0)
+}
+
+@Test func claudeStatsCacheParserAcceptsRateLimitSnapshotWhenPresent() throws {
+ let json = #"""
+ {
+ "lastComputedDate": "2026-05-06T14:00:00Z",
+ "rate_limits": {
+ "five_hour": {
+ "used_percentage": 42.4,
+ "resets_at": 1788397200
+ },
+ "seven_day": {
+ "used_percentage": 51.6,
+ "resets_at": 1788984000
+ }
+ }
+ }
+ """#
+
+ let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8))
+
+ #expect(summary.rateLimitSnapshot?.windows.map(\.label) == ["5-hour", "Weekly"])
+ #expect(summary.rateLimitSnapshot?.windows.map { Int($0.usedPercent.rounded()) } == [42, 52])
+}
+
+@Test func claudeUsageBlocksParserSummarizesActiveBlockAndLimitEstimate() throws {
+ let json = #"""
+ {
+ "blocks": [
+ { "isActive": false, "totalTokens": 1000 },
+ { "isActive": false, "totalTokens": 2000 },
+ {
+ "isActive": true,
+ "totalTokens": 500,
+ "endTime": "2026-05-06T23:00:00Z",
+ "projection": { "totalTokens": 1200, "remainingMinutes": 45 },
+ "models": ["claude-sonnet-4-6", ""]
+ }
+ ]
+ }
+ """#
+
+ let summary = try ClaudeUsageBlocksParser.summary(from: Data(json.utf8))
+
+ #expect(summary.activeBlock?.totalTokens == 500)
+ #expect(summary.activeBlock?.projectedTotalTokens == 1200)
+ #expect(summary.activeBlock?.remainingMinutes == 45)
+ #expect(summary.activeBlock?.modelCount == 1)
+ #expect(summary.completedBlockTokenLimitEstimate == 1000)
+}
+
+@Test func claudeLocalStatusLimitMakesUnknownAllowanceExplicit() {
+ let limit = claudeLocalStatusLimits(
+ authStatus: ClaudeAuthStatus(
+ loggedIn: true,
+ authMethod: "claude.ai",
+ apiProvider: "firstParty",
+ subscriptionType: "pro"
+ ),
+ statsSummary: nil,
+ accountID: "local",
+ accountName: "Claude",
+ observedAt: Date(timeIntervalSince1970: 0)
+ ).first!
+
+ #expect(limit.label == "Claude Pro status")
+ #expect(limit.modelLabel == "Claude Code")
+ #expect(limit.confidence == .observed)
+ #expect(limit.status == .unknown)
+ #expect(limit.note?.contains("allowance: not exposed by Claude Code") == true)
+}
+
+@Test func claudeStatuslineRateLimitCacheParsesSubscriptionWindows() throws {
+ let json = #"""
+ {
+ "observed_at": 1788379200,
+ "rate_limits": {
+ "five_hour": {
+ "used_percentage": 42.4,
+ "resets_at": 1788397200
+ },
+ "seven_day": {
+ "used_percentage": 51.6,
+ "resets_at": 1788984000
+ }
+ }
+ }
+ """#
+
+ let snapshot = try ClaudeSubscriptionRateLimitCacheParser.snapshot(from: Data(json.utf8))
+
+ #expect(ContextPanelDateFormatting.string(from: snapshot.observedAt) == "2026-09-02T20:00:00Z")
+ #expect(snapshot.windows.map(\.label) == ["5-hour", "Weekly"])
+ #expect(snapshot.windows.map { Int($0.usedPercent.rounded()) } == [42, 52])
+}
+
+@Test func claudeLocalStatusLimitsPrefersStatuslineSubscriptionWindows() {
+ let limits = claudeLocalStatusLimits(
+ authStatus: ClaudeAuthStatus(
+ loggedIn: true,
+ authMethod: "claude.ai",
+ apiProvider: "firstParty",
+ subscriptionType: "pro"
+ ),
+ statsSummary: nil,
+ rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot(
+ observedAt: Date(timeIntervalSince1970: 10),
+ windows: [
+ ClaudeSubscriptionRateLimitWindow(
+ label: "5-hour",
+ usedPercent: 42.4,
+ resetsAt: Date(timeIntervalSince1970: 100)
+ ),
+ ClaudeSubscriptionRateLimitWindow(
+ label: "Weekly",
+ usedPercent: 51.6,
+ resetsAt: Date(timeIntervalSince1970: 200)
+ ),
+ ]
+ ),
+ accountID: "local",
+ accountName: "Claude",
+ observedAt: Date(timeIntervalSince1970: 0)
+ )
+
+ #expect(limits.count == 2)
+ #expect(limits.map(\.provider) == [.anthropic, .anthropic])
+ #expect(limits.map(\.windowLabel) == ["5-hour", "Weekly"])
+ #expect(limits.map(\.modelLabel) == ["Claude Pro", "Claude Pro"])
+ #expect(limits.map(\.used) == [42, 52])
+ #expect(limits.allSatisfy { $0.unit == .percent && $0.limit == 100 && $0.confidence == .observed })
+}
+
+@Test func claudeLocalStatusLimitsUsesEveryCodeEstimateWhenAvailable() {
+ let limits = claudeLocalStatusLimits(
+ authStatus: ClaudeAuthStatus(
+ loggedIn: true,
+ authMethod: "claude.ai",
+ apiProvider: "firstParty",
+ subscriptionType: "pro"
+ ),
+ statsSummary: nil,
+ rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot(
+ observedAt: Date(timeIntervalSince1970: 10),
+ windows: [ClaudeSubscriptionRateLimitWindow(label: "5-hour", usedPercent: 4, resetsAt: nil)]
+ ),
+ usageBlocksSummary: ClaudeUsageBlocksSummary(
+ activeBlock: ClaudeUsageBlock(
+ isActive: true,
+ totalTokens: 500,
+ endTime: Date(timeIntervalSince1970: 9_000),
+ projectedTotalTokens: 1_200,
+ remainingMinutes: 45,
+ modelCount: 2
+ ),
+ completedBlockTokenLimitEstimate: 1_000
+ ),
+ accountID: "local",
+ accountName: "Claude",
+ observedAt: Date(timeIntervalSince1970: 1_000)
+ )
+
+ #expect(limits.count == 1)
+ #expect(limits[0].label == "Claude 5-hour estimate")
+ #expect(limits[0].unit == .tokens)
+ #expect(limits[0].used == 500)
+ #expect(limits[0].limit == 1_000)
+ #expect(limits[0].confidence == .estimated)
+ #expect(limits[0].resetsAt == Date(timeIntervalSince1970: 3_700))
+ #expect(limits[0].note?.contains("official subscription percentage unavailable") == true)
+}
diff --git a/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift b/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift
new file mode 100644
index 0000000..3e90790
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift
@@ -0,0 +1,62 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func claudeWebUsageParserBuildsSubscriptionPercentWindows() throws {
+ let payload = #"""
+ {
+ "rate_limits": {
+ "five_hour": {
+ "used_percentage": 42.4,
+ "resets_at": 1778109600
+ },
+ "seven_day": {
+ "remaining_percentage": 30,
+ "resets_at": "2026-05-08T01:00:00Z"
+ },
+ "seven_day_opus": {
+ "utilization": 0.91,
+ "resets_at": 1778192400000
+ }
+ },
+ "account_uuid": "1e13c5e0-a592-428d-a051-9fe5d6260e38"
+ }
+ """#.data(using: .utf8)!
+
+ let limits = try ClaudeWebUsageParser.usageLimits(
+ from: payload,
+ accountID: "claude-local",
+ accountName: "Claude Max",
+ observedAt: Date(timeIntervalSince1970: 1)
+ )
+
+ #expect(limits.count == 3)
+ #expect(limits[0].label == "Claude 5-hour")
+ #expect(limits[0].windowLabel == "5-hour")
+ #expect(limits[0].used == 42)
+ #expect(limits[0].confidence == .observed)
+ #expect(limits[1].used == 70)
+ #expect(limits[2].modelLabel == "Opus")
+ #expect(limits[2].used == 91)
+}
+
+@Test func claudeWebUsageSanitizerReturnsOnlyAllowedUsageFields() throws {
+ let payload = #"""
+ {
+ "rate_limits": {
+ "five_hour": { "used_percentage": 12, "resets_at": 1778109600 }
+ },
+ "email": "chris@example.com",
+ "organization_uuid": "1e13c5e0-a592-428d-a051-9fe5d6260e38"
+ }
+ """#.data(using: .utf8)!
+
+ let fields = try ClaudeWebUsageParser.sanitizedUsageFields(from: payload)
+
+ #expect(fields.contains("rate_limits"))
+ #expect(fields.contains("rate_limits.five_hour"))
+ #expect(fields.contains("rate_limits.five_hour.used_percentage"))
+ #expect(!fields.contains { $0.localizedCaseInsensitiveContains("email") })
+ #expect(!fields.contains { $0.localizedCaseInsensitiveContains("uuid") })
+}
diff --git a/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift b/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift
new file mode 100644
index 0000000..42aee55
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift
@@ -0,0 +1,96 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func codexUsagePayloadParserNormalizesPrimarySecondaryAndAdditionalWindows() throws {
+ let json = #"""
+ {
+ "plan_type": "pro",
+ "rate_limit": {
+ "allowed": true,
+ "limit_reached": false,
+ "primary_window": {
+ "used_percent": 45,
+ "limit_window_seconds": 18000,
+ "reset_after_seconds": 14000,
+ "reset_at": 1788393600
+ },
+ "secondary_window": {
+ "used_percent": 36,
+ "limit_window_seconds": 604800,
+ "reset_after_seconds": 500000,
+ "reset_at": 1788998400
+ }
+ },
+ "credits": {
+ "has_credits": true,
+ "unlimited": false,
+ "balance": "0"
+ },
+ "additional_rate_limits": [
+ {
+ "limit_name": "GPT-5.3-Codex-Spark",
+ "metered_feature": "codex_bengalfox",
+ "rate_limit": {
+ "allowed": true,
+ "limit_reached": false,
+ "primary_window": {
+ "used_percent": 1,
+ "limit_window_seconds": 18000,
+ "reset_after_seconds": 1000,
+ "reset_at": 1788400000
+ },
+ "secondary_window": null
+ }
+ }
+ ],
+ "rate_limit_reached_type": {
+ "type": "workspace_member_usage_limit_reached"
+ }
+ }
+ """#
+
+ let snapshots = try CodexUsagePayloadParser.snapshots(from: Data(json.utf8))
+
+ #expect(snapshots.count == 2)
+ #expect(snapshots[0].id == "codex")
+ #expect(snapshots[0].planType == "pro")
+ #expect(snapshots[0].primary?.usedPercent == 45)
+ #expect(snapshots[0].primary?.windowMinutes == 300)
+ #expect(snapshots[0].secondary?.windowMinutes == 10080)
+ #expect(snapshots[0].credits == CodexCreditsSnapshot(hasCredits: true, unlimited: false, balance: "0"))
+ #expect(snapshots[0].rateLimitReachedType == .workspaceMemberUsageLimitReached)
+ #expect(snapshots[1].id == "codex_bengalfox")
+ #expect(snapshots[1].limitName == "GPT-5.3-Codex-Spark")
+ #expect(snapshots[1].primary?.usedPercent == 1)
+ #expect(snapshots[1].credits == nil)
+
+ let limits = codexUsageLimits(
+ from: snapshots[0],
+ accountID: "acct",
+ accountName: "Personal",
+ observedAt: Date(timeIntervalSince1970: 0)
+ )
+ #expect(limits.map(\.windowLabel) == ["5-hour", "Weekly"])
+ #expect(limits.first?.modelLabel == "Codex")
+}
+
+@Test func codexUsagePayloadParserHandlesMissingLimitDetails() throws {
+ let json = #"""
+ {
+ "plan_type": "plus",
+ "rate_limit": null,
+ "additional_rate_limits": null,
+ "credits": null
+ }
+ """#
+
+ let snapshots = try CodexUsagePayloadParser.snapshots(from: Data(json.utf8))
+
+ #expect(snapshots.count == 1)
+ #expect(snapshots[0].id == "codex")
+ #expect(snapshots[0].planType == "plus")
+ #expect(snapshots[0].primary == nil)
+ #expect(snapshots[0].secondary == nil)
+}
diff --git a/Tests/ContextPanelCoreTests/FastModeForecastTests.swift b/Tests/ContextPanelCoreTests/FastModeForecastTests.swift
new file mode 100644
index 0000000..808d0dc
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/FastModeForecastTests.swift
@@ -0,0 +1,112 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+private let now = Date(timeIntervalSinceReferenceDate: 900_000_000)
+
+@Test func forecastReportsSafeThroughResetWhenFastBurnFitsWithReserve() {
+ let forecast = FastModeForecast(
+ input: FastModeForecastInput(
+ limit: openAILimit(used: 20, limit: 100, resetsInHours: 4),
+ now: now,
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 4),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 10),
+ reserveUnits: 10
+ )
+ )
+
+ #expect(forecast.recommendation == .safeThroughReset)
+ #expect(forecast.fastModeRunwayHours == 7)
+ #expect(forecast.copy == "Fast mode looks safe through reset.")
+}
+
+@Test func forecastReportsLimitedFastModeRunwayWhenFastBurnDoesNotReachReset() {
+ let forecast = FastModeForecast(
+ input: FastModeForecastInput(
+ limit: openAILimit(used: 60, limit: 100, resetsInHours: 8),
+ now: now,
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 15),
+ reserveUnits: 10
+ )
+ )
+
+ #expect(forecast.recommendation == .safeForLimitedTime)
+ #expect(forecast.copy == "Fast mode safe for about 2h.")
+}
+
+@Test func forecastSavesFastModeWhenRunwayIsBelowMinimumWindow() {
+ let forecast = FastModeForecast(
+ input: FastModeForecastInput(
+ limit: openAILimit(used: 88, limit: 100, resetsInHours: 6),
+ now: now,
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 1),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 10),
+ reserveUnits: 5,
+ minimumSafeHours: 1
+ )
+ )
+
+ #expect(forecast.recommendation == .saveFastMode)
+ #expect(forecast.copy == "Save fast mode before reset.")
+}
+
+@Test func forecastNeedsCalibrationWithoutResetOrFastBurnRate() {
+ let forecast = FastModeForecast(
+ input: FastModeForecastInput(
+ limit: openAILimit(used: 10, limit: 100, resetsInHours: nil),
+ now: now,
+ standardBurnRate: nil,
+ fastBurnRate: nil
+ )
+ )
+
+ #expect(forecast.recommendation == .needsCalibration)
+ #expect(forecast.copy == "Needs calibration before fast mode.")
+}
+
+@Test func portfolioChoosesBestOpenAIAccountForFastMode() {
+ let personal = FastModeForecast(
+ input: FastModeForecastInput(
+ limit: openAILimit(accountName: "Personal", used: 80, limit: 100, resetsInHours: 12),
+ now: now,
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12),
+ reserveUnits: 5
+ )
+ )
+ let work = FastModeForecast(
+ input: FastModeForecastInput(
+ limit: openAILimit(accountName: "Work", used: 20, limit: 100, resetsInHours: 4),
+ now: now,
+ standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2),
+ fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12),
+ reserveUnits: 5
+ )
+ )
+
+ let portfolio = FastModePortfolioForecast(forecasts: [personal, work])
+
+ #expect(portfolio.bestForecast?.accountName == "Work")
+ #expect(portfolio.copy == "Fast mode looks safe through reset.")
+}
+
+private func openAILimit(
+ accountName: String = "Personal",
+ used: Int,
+ limit: Int,
+ resetsInHours: Double?
+) -> UsageLimit {
+ UsageLimit(
+ provider: .openAI,
+ accountID: "openai-\(accountName.lowercased())",
+ accountName: accountName,
+ label: "GPT-5 Thinking",
+ unit: .percent,
+ used: used,
+ limit: limit,
+ resetsAt: resetsInHours.map { now.addingTimeInterval($0 * 3_600) },
+ confidence: .estimated
+ )
+}
diff --git a/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift
new file mode 100644
index 0000000..6c93b80
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift
@@ -0,0 +1,96 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func geminiQuotaPayloadParserNormalizesBucketsAsPercentPressure() throws {
+ let json = #"""
+ {
+ "buckets": [
+ {
+ "modelId": "gemini-3-flash-preview",
+ "remainingFraction": 0.965,
+ "resetTime": "2026-05-06T16:04:50Z"
+ },
+ {
+ "modelId": "gemini-3.1-pro-preview",
+ "remainingFraction": 1,
+ "remainingAmount": 12,
+ "resetTime": "2026-05-07T14:19:35Z"
+ }
+ ]
+ }
+ """#
+
+ let buckets = try GeminiQuotaPayloadParser.buckets(from: Data(json.utf8))
+
+ #expect(buckets.count == 2)
+ #expect(buckets[0].modelID == "gemini-3-flash-preview")
+ #expect(buckets[0].remainingFraction == 0.965)
+ #expect(abs((buckets[0].usedPercent ?? 0) - 3.5) < 0.0001)
+ #expect(ContextPanelDateFormatting.string(from: buckets[0].resetsAt!) == "2026-05-06T16:04:50Z")
+ #expect(buckets[1].remainingAmount == 12)
+
+ let limit = buckets[0].usageLimit(accountID: "local", accountName: "Gemini CLI", observedAt: Date(timeIntervalSince1970: 0))
+ #expect(limit.provider == .google)
+ #expect(limit.unit == .percent)
+ #expect(limit.used == 4)
+ #expect(limit.limit == 100)
+ #expect(limit.confidence == .observed)
+}
+
+@Test func geminiQuotaPayloadParserHandlesMissingBuckets() throws {
+ let json = #"{"notBuckets": true}"#
+
+ let buckets = try GeminiQuotaPayloadParser.buckets(from: Data(json.utf8))
+
+ #expect(buckets.isEmpty)
+}
+
+@Test func geminiOAuthClientMetadataDiscoveryParsesInstalledCLIBundleShape() {
+ let source = #"""
+ var OAUTH_CLIENT_ID = "client-id.apps.googleusercontent.com";
+ var OAUTH_CLIENT_SECRET = "client-secret";
+ """#
+
+ let metadata = GeminiOAuthClientMetadataDiscovery.parseClientMetadata(from: source)
+
+ #expect(metadata?.clientID == "client-id.apps.googleusercontent.com")
+ #expect(metadata?.clientSecret == "client-secret")
+}
+
+@Test func geminiOAuthClientMetadataDiscoveryPrefersEnvironmentValues() {
+ let metadata = GeminiOAuthClientMetadataDiscovery.discover(
+ environment: [
+ "GEMINI_OAUTH_CLIENT_ID": "env-client",
+ "GEMINI_OAUTH_CLIENT_SECRET": "env-secret",
+ ],
+ fileLoader: { _ in "" },
+ fileExists: { _ in false },
+ directoryLister: { _ in [] }
+ )
+
+ #expect(metadata == GeminiOAuthClientMetadata(clientID: "env-client", clientSecret: "env-secret"))
+}
+
+@Test func geminiOAuthClientMetadataDiscoveryScansInstalledBundleDirectory() {
+ let source = #"""
+ var OAUTH_CLIENT_ID = "bundle-client";
+ var OAUTH_CLIENT_SECRET = "bundle-secret";
+ """#
+
+ let metadata = GeminiOAuthClientMetadataDiscovery.discover(
+ environment: [:],
+ fileLoader: { path in
+ path.hasSuffix("chunk-with-oauth.js") ? source : ""
+ },
+ fileExists: { _ in true },
+ directoryLister: { root in
+ root == "/opt/homebrew/lib/node_modules/@google/gemini-cli/bundle"
+ ? ["\(root)/chunk-with-oauth.js"]
+ : []
+ }
+ )
+
+ #expect(metadata == GeminiOAuthClientMetadata(clientID: "bundle-client", clientSecret: "bundle-secret"))
+}
diff --git a/Tests/ContextPanelCoreTests/LimitProbeTests.swift b/Tests/ContextPanelCoreTests/LimitProbeTests.swift
new file mode 100644
index 0000000..dc63c59
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/LimitProbeTests.swift
@@ -0,0 +1,112 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func visibleTextScannerFindsSubscriptionLimitSignals() {
+ let text = """
+ GPT-5 Thinking unavailable. You have reached your weekly limit.
+ Try again in 3h. Pro shows 52 percent used in the 5 hour window.
+ """
+
+ let observations = LimitProbeScanner.scanVisibleText(text, provider: .openAI)
+ let kinds = Set(observations.map(\.signalKind))
+
+ #expect(kinds.contains(.limitReached))
+ #expect(kinds.contains(.relativeDuration))
+ #expect(kinds.contains(.usagePressure))
+ #expect(kinds.contains(.modelAvailability))
+ #expect(kinds.contains(.planLanguage))
+}
+
+@Test func responseShapeScannerOnlyReportsCandidateFieldNames() {
+ let observations = LimitProbeScanner.scanResponseShape(
+ fieldNames: ["id", "email", "used_percent", "reset_at", "avatar", "token_pressure"],
+ provider: .openAI
+ )
+
+ #expect(observations.map(\.sanitizedEvidence).contains("reset_at"))
+ #expect(!observations.map(\.sanitizedEvidence).contains("email"))
+ #expect(observations.map(\.sanitizedEvidence).contains("used_percent"))
+ #expect(observations.map(\.sanitizedEvidence).contains("token_pressure"))
+}
+
+@Test func probeEvidenceRedactsSecretsBeforeStorage() {
+ let observation = LimitProbeObservation(
+ provider: .openAI,
+ observedAt: Date(),
+ source: .visibleText,
+ signalKind: .resetLanguage,
+ confidence: .observed,
+ sanitizedEvidence: "Authorization: bearer abc.def.ghi user chris@example.com token=secret"
+ )
+
+ #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("abc.def.ghi"))
+ #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("chris@example.com"))
+ #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("secret"))
+ #expect(observation.sanitizedEvidence.localizedCaseInsensitiveContains("[email redacted]"))
+}
+
+@Test func markdownReportContainsRedactionStatement() {
+ let report = LimitProbeReport(
+ provider: .openAI,
+ capturedAt: Date(timeIntervalSinceReferenceDate: 1),
+ observations: [
+ LimitProbeObservation(
+ provider: .openAI,
+ observedAt: Date(timeIntervalSinceReferenceDate: 1),
+ source: .visibleText,
+ signalKind: .relativeDuration,
+ confidence: .observed,
+ sanitizedEvidence: "resets in 3h"
+ )
+ ],
+ networkEvents: [
+ NetworkProbeEvent(
+ observedAt: Date(timeIntervalSinceReferenceDate: 1),
+ method: "GET",
+ pathHint: "/backend-api/accounts/check",
+ status: 200,
+ contentType: "application/json",
+ bodySize: 256,
+ matchedFields: ["default_account_plan_type", "accounts.[id].entitlement.subscription_plan"]
+ )
+ ]
+ )
+
+ #expect(report.markdownSummary.contains("Limit Probe Report"))
+ #expect(report.markdownSummary.contains("resets in 3h"))
+ #expect(report.markdownSummary.contains("Network Candidates"))
+ #expect(report.markdownSummary.contains("/backend-api/accounts/check"))
+ #expect(report.markdownSummary.contains("subscription_plan"))
+ #expect(report.markdownSummary.contains("authorization headers"))
+}
+
+@Test func networkProbeEventRedactsPathIdentifiers() {
+ let event = NetworkProbeEvent(
+ observedAt: Date(),
+ method: "GET",
+ pathHint: "https://chatgpt.com/backend-api/conversation/abc123def456abc123def456?token=secret",
+ status: 200,
+ contentType: "application/json",
+ bodySize: 120,
+ matchedFields: ["reset_at", "used_percent"]
+ )
+
+ #expect(event.pathHint == "/backend-api/conversation/[id]")
+ #expect(event.matchedFields == ["reset_at", "used_percent"])
+}
+
+@Test func networkProbeEventRedactsIdentifiersInsideFieldNames() {
+ let event = NetworkProbeEvent(
+ observedAt: Date(),
+ method: "GET",
+ pathHint: "/api/accounts",
+ status: 200,
+ contentType: "application/json",
+ bodySize: 120,
+ matchedFields: ["accounts.1e13c5e0-a592-428d-a051-9fe5d6260e38.entitlement.subscription_plan"]
+ )
+
+ #expect(event.matchedFields == ["accounts.[id].entitlement.subscription_plan"])
+}
diff --git a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift
new file mode 100644
index 0000000..566fc71
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift
@@ -0,0 +1,240 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func codexConnectorRefreshesMultipleAccountsIntoNormalizedLimits() async throws {
+ let auth = #"{"tokens":{"access_token":"token-secret"}}"#.data(using: .utf8)!
+ let usage = #"""
+ {
+ "plan_type": "pro",
+ "rate_limit": {
+ "primary_window": { "used_percent": 50, "limit_window_seconds": 18000, "reset_at": 1788393600 },
+ "secondary_window": { "used_percent": 25, "limit_window_seconds": 604800, "reset_at": 1788998400 }
+ }
+ }
+ """#.data(using: .utf8)!
+ let http = StubHTTPClient(responses: [ConnectorHTTPResponse(statusCode: 200, data: usage)])
+ let connector = CodexRateLimitConnector(
+ accounts: [
+ CodexAccountConfiguration(authPath: "/tmp/openai-a.json", accountName: "OpenAI A"),
+ CodexAccountConfiguration(authPath: "/tmp/openai-b.json", accountName: "OpenAI B"),
+ ],
+ httpClient: http,
+ fileLoader: { _ in auth }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 0))
+
+ #expect(result.reports.count == 2)
+ #expect(result.snapshot.limits.count == 4)
+ #expect(Set(result.snapshot.limits.map(\.accountName)) == ["OpenAI A", "OpenAI B"])
+ #expect(result.snapshot.limits.allSatisfy { $0.provider == .openAI && $0.unit == .percent })
+ #expect(result.snapshot.limits.contains { $0.used == 50 && $0.windowLabel == "5-hour" })
+ #expect(http.requests.count == 2)
+}
+
+@Test func codexConnectorRedactsHTTPFailures() async {
+ let auth = #"{"tokens":{"access_token":"token-secret"}}"#.data(using: .utf8)!
+ let http = StubHTTPClient(responses: [ConnectorHTTPResponse(statusCode: 401, data: Data("secret body".utf8))])
+ let connector = CodexRateLimitConnector(
+ accounts: [CodexAccountConfiguration(authPath: "/tmp/openai.json", accountName: "OpenAI")],
+ httpClient: http,
+ fileLoader: { _ in auth }
+ )
+
+ let result = await connector.refresh(now: Date())
+
+ #expect(result.reports.count == 1)
+ #expect(result.reports[0].status == .failure)
+ #expect(result.reports[0].errorMessage?.contains("HTTP 401") == true)
+ #expect(result.reports[0].errorMessage?.contains("secret body") == false)
+ #expect(result.snapshot.limits.isEmpty)
+}
+
+@Test func geminiConnectorRefreshesQuotaBuckets() async throws {
+ let credentials = #"{"refresh_token":"refresh-secret"}"#.data(using: .utf8)!
+ let refresh = #"{"access_token":"access-secret"}"#.data(using: .utf8)!
+ let load = #"{"cloudaicompanionProject":"project-secret","currentTier":{"name":"Gemini Code Assist"}}"#.data(using: .utf8)!
+ let quota = #"""
+ {
+ "buckets": [
+ { "modelId": "gemini-3-flash-preview", "remainingFraction": 0.75, "resetTime": "2026-05-06T16:04:50Z" }
+ ]
+ }
+ """#.data(using: .utf8)!
+ let http = StubHTTPClient(responses: [
+ ConnectorHTTPResponse(statusCode: 200, data: refresh),
+ ConnectorHTTPResponse(statusCode: 200, data: load),
+ ConnectorHTTPResponse(statusCode: 200, data: quota),
+ ])
+ let connector = GeminiCodeAssistConnector(
+ accounts: [GeminiAccountConfiguration(authPath: "/tmp/gemini.json", accountName: "Gemini", clientID: "client", clientSecret: "secret")],
+ httpClient: http,
+ fileLoader: { _ in credentials }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 0))
+
+ #expect(result.reports.count == 1)
+ #expect(result.snapshot.limits.count == 1)
+ #expect(result.snapshot.limits[0].provider == .google)
+ #expect(result.snapshot.limits[0].label == "gemini-3-flash-preview")
+ #expect(result.snapshot.limits[0].used == 25)
+ #expect(http.requests.map(\.method) == ["POST", "POST", "POST"])
+ #expect(http.requests[2].body.flatMap { String(data: $0, encoding: .utf8) }?.contains("project-secret") == true)
+}
+
+@Test func claudeConnectorReportsUnknownLiveAllowanceFromLocalStatus() async throws {
+ let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)!
+ let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)!
+ let connector = ClaudeLocalStatusConnector(
+ accounts: [ClaudeAccountConfiguration(accountName: "Claude", claudeBinary: "claude", statsPath: "/tmp/stats.json")],
+ processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)),
+ fileLoader: { _ in stats },
+ fileExists: { path in path == "/tmp/stats.json" }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 0))
+
+ #expect(result.reports.count == 1)
+ #expect(result.reports[0].status == .unknown)
+ #expect(result.snapshot.limits.count == 1)
+ #expect(result.snapshot.limits[0].provider == .anthropic)
+ #expect(result.snapshot.limits[0].status == .unknown)
+ #expect(result.snapshot.limits[0].note?.contains("subscription: pro") == true)
+}
+
+@Test func claudeConnectorReportsHealthyWhenStatuslineLimitsExist() async throws {
+ let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)!
+ let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)!
+ let cache = #"{"observed_at":1788379200,"rate_limits":{"five_hour":{"used_percentage":4,"resets_at":1788397200},"seven_day":{"used_percentage":0,"resets_at":1788984000}}}"#.data(using: .utf8)!
+ let connector = ClaudeLocalStatusConnector(
+ accounts: [ClaudeAccountConfiguration(
+ accountName: "Claude",
+ claudeBinary: "claude",
+ statsPath: "/tmp/stats.json",
+ rateLimitSnapshotPath: "/tmp/claude-statusline.json",
+ rateLimitSnapshotMaximumAge: 60
+ )],
+ processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)),
+ fileLoader: { path in path == "/tmp/claude-statusline.json" ? cache : stats },
+ fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/claude-statusline.json" }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_788_379_230))
+
+ #expect(result.reports[0].status == .healthy)
+ #expect(result.snapshot.limits.count == 2)
+ #expect(result.snapshot.limits.map(\.windowLabel) == ["5-hour", "Weekly"])
+}
+
+@Test func claudeConnectorMarksOldStatuslineLimitsStale() async throws {
+ let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)!
+ let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)!
+ let cache = #"{"observed_at":1788379200,"rate_limits":{"five_hour":{"used_percentage":4,"resets_at":1788397200},"seven_day":{"used_percentage":0,"resets_at":1788984000}}}"#.data(using: .utf8)!
+ let connector = ClaudeLocalStatusConnector(
+ accounts: [ClaudeAccountConfiguration(
+ accountName: "Claude",
+ claudeBinary: "claude",
+ statsPath: "/tmp/stats.json",
+ rateLimitSnapshotPath: "/tmp/claude-statusline.json",
+ rateLimitSnapshotMaximumAge: 60
+ )],
+ processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)),
+ fileLoader: { path in path == "/tmp/claude-statusline.json" ? cache : stats },
+ fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/claude-statusline.json" }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_788_379_500))
+
+ #expect(result.reports[0].status == .stale)
+ #expect(result.snapshot.limits.map(\.status) == [.stale, .stale])
+ #expect(result.snapshot.limits[0].note?.contains("stale Claude Code statusline") == true)
+}
+
+@Test func claudeConnectorReportsEveryCodeUsageEstimate() async throws {
+ let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)!
+ let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)!
+ let blocks = #"{"blocks":[{"isActive":false,"totalTokens":1000},{"isActive":true,"totalTokens":500,"projection":{"totalTokens":1200,"remainingMinutes":30},"models":["claude-sonnet-4-6"]}]}"#.data(using: .utf8)!
+ let connector = ClaudeLocalStatusConnector(
+ accounts: [ClaudeAccountConfiguration(
+ accountName: "Claude",
+ claudeBinary: "claude",
+ statsPath: "/tmp/stats.json",
+ usageBlocksPath: "/tmp/ccusage-blocks.json"
+ )],
+ processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)),
+ fileLoader: { path in path == "/tmp/ccusage-blocks.json" ? blocks : stats },
+ fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/ccusage-blocks.json" }
+ )
+
+ let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_000))
+
+ #expect(result.reports[0].status == .healthy)
+ #expect(result.snapshot.limits.count == 1)
+ #expect(result.snapshot.limits[0].confidence == .estimated)
+ #expect(result.snapshot.limits[0].used == 500)
+ #expect(result.snapshot.limits[0].limit == 1000)
+ #expect(result.snapshot.limits[0].note?.contains("Every Code/Claude sessions") == true)
+}
+
+@Test func providerConnectorRuntimeAggregatesConnectorSnapshots() async {
+ let connectorA = StubConnector(provider: .openAI, report: ProviderConnectorReport(
+ provider: .openAI,
+ accountID: "a",
+ accountName: "A",
+ generatedAt: Date(timeIntervalSince1970: 0),
+ limits: [UsageLimit(provider: .openAI, label: "A", used: 1, limit: 100)]
+ ))
+ let connectorB = StubConnector(provider: .google, report: ProviderConnectorReport(
+ provider: .google,
+ accountID: "b",
+ accountName: "B",
+ generatedAt: Date(timeIntervalSince1970: 0),
+ limits: [UsageLimit(provider: .google, label: "B", used: 2, limit: 100)]
+ ))
+
+ let result = await ProviderConnectorRuntime(connectors: [connectorA, connectorB]).refreshAll(now: Date(timeIntervalSince1970: 10))
+
+ #expect(result.reports.count == 2)
+ #expect(result.snapshot.generatedAt == Date(timeIntervalSince1970: 10))
+ #expect(Set(result.snapshot.limits.map(\.provider)) == [.openAI, .google])
+}
+
+private final class StubHTTPClient: ConnectorHTTPClient, @unchecked Sendable {
+ private var responses: [ConnectorHTTPResponse]
+ private(set) var requests: [ConnectorHTTPRequest] = []
+
+ init(responses: [ConnectorHTTPResponse]) {
+ self.responses = responses
+ }
+
+ func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse {
+ requests.append(request)
+ guard !responses.isEmpty else {
+ return ConnectorHTTPResponse(statusCode: 500, data: Data())
+ }
+ if responses.count == 1 {
+ return responses[0]
+ }
+ return responses.removeFirst()
+ }
+}
+
+private struct StubProcessClient: ConnectorProcessClient {
+ let result: ConnectorProcessResult
+
+ func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult {
+ result
+ }
+}
+
+private struct StubConnector: ProviderConnector {
+ let provider: Provider
+ let report: ProviderConnectorReport
+
+ func refresh(now: Date) async -> ConnectorRefreshResult {
+ ConnectorRefreshResult(generatedAt: now, reports: [report])
+ }
+}
diff --git a/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift
new file mode 100644
index 0000000..3099d0a
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift
@@ -0,0 +1,210 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func jsonSnapshotStoreRoundTripsCurrentSnapshotAndReports() throws {
+ let root = try temporaryDirectory()
+ let store = JSONSnapshotStore(rootDirectory: root)
+ let savedAt = Date(timeIntervalSince1970: 100)
+ let refresh = ConnectorRefreshResult(generatedAt: savedAt, reports: [ProviderConnectorReport(
+ provider: .openAI,
+ accountID: "local-openai",
+ accountName: "OpenAI",
+ generatedAt: savedAt,
+ limits: [usageLimit(provider: .openAI, accountID: "local-openai", used: 30, savedAt: savedAt)]
+ )])
+
+ try store.save(StoredUsageSnapshot(savedAt: savedAt, refreshResult: refresh))
+
+ let result = store.loadCurrent()
+
+ #expect(result.status == .healthy)
+ #expect(result.snapshot?.schemaVersion == 1)
+ #expect(result.snapshot?.savedAt == savedAt)
+ #expect(result.snapshot?.snapshot.limits.count == 1)
+ #expect(result.snapshot?.reports.count == 1)
+ #expect(result.snapshot?.reports[0].provider == .openAI)
+ #expect(FileManager.default.fileExists(atPath: store.currentSnapshotURL.path))
+ #expect(store.loadHistory().count == 1)
+}
+
+@Test func jsonSnapshotStoreFiltersHistoryByProviderAccountAndLimit() throws {
+ let root = try temporaryDirectory()
+ let store = JSONSnapshotStore(rootDirectory: root)
+ let first = Date(timeIntervalSince1970: 100)
+ let second = Date(timeIntervalSince1970: 200)
+
+ try store.save(StoredUsageSnapshot(savedAt: first, snapshot: UsageSnapshot(
+ generatedAt: first,
+ limits: [usageLimit(provider: .openAI, accountID: "a", used: 10, savedAt: first)]
+ )))
+ try store.save(StoredUsageSnapshot(savedAt: second, snapshot: UsageSnapshot(
+ generatedAt: second,
+ limits: [usageLimit(provider: .google, accountID: "b", used: 20, savedAt: second)]
+ )))
+
+ #expect(store.loadHistory().map(\.savedAt) == [second, first])
+ #expect(store.loadHistory(query: SnapshotStoreQuery(provider: .openAI)).map(\.savedAt) == [first])
+ #expect(store.loadHistory(query: SnapshotStoreQuery(accountID: "b")).map(\.savedAt) == [second])
+ #expect(store.loadHistory(query: SnapshotStoreQuery(limit: 1)).count == 1)
+}
+
+@Test func jsonSnapshotStoreMergesRefreshResultByProviderAccount() throws {
+ let root = try temporaryDirectory()
+ let store = JSONSnapshotStore(rootDirectory: root)
+ let first = Date(timeIntervalSince1970: 100)
+ let second = Date(timeIntervalSince1970: 200)
+
+ let initial = ConnectorRefreshResult(generatedAt: first, reports: [
+ ProviderConnectorReport(
+ provider: .openAI,
+ accountID: "openai-a",
+ accountName: "OpenAI A",
+ generatedAt: first,
+ limits: [usageLimit(provider: .openAI, accountID: "openai-a", used: 40, savedAt: first)]
+ ),
+ ProviderConnectorReport(
+ provider: .google,
+ accountID: "gemini-a",
+ accountName: "Gemini A",
+ generatedAt: first,
+ limits: [usageLimit(provider: .google, accountID: "gemini-a", used: 10, savedAt: first)]
+ ),
+ ])
+ try store.save(StoredUsageSnapshot(savedAt: first, refreshResult: initial))
+
+ let claudeRefresh = ConnectorRefreshResult(generatedAt: second, reports: [
+ ProviderConnectorReport(
+ provider: .anthropic,
+ accountID: "claude-web",
+ accountName: "Claude Web",
+ generatedAt: second,
+ limits: [usageLimit(provider: .anthropic, accountID: "claude-web", used: 3, savedAt: second)]
+ )
+ ])
+
+ try store.saveMerged(refreshResult: claudeRefresh, savedAt: second)
+
+ let limits = try #require(store.loadCurrent().snapshot?.snapshot.limits)
+ #expect(limits.map(\.provider).contains(.openAI))
+ #expect(limits.map(\.provider).contains(.google))
+ #expect(limits.map(\.provider).contains(.anthropic))
+ #expect(store.loadCurrent().snapshot?.reports.count == 3)
+ #expect(store.loadHistory().count == 2)
+
+ let replacement = ConnectorRefreshResult(generatedAt: second, reports: [
+ ProviderConnectorReport(
+ provider: .openAI,
+ accountID: "openai-a",
+ accountName: "OpenAI A",
+ generatedAt: second,
+ limits: [usageLimit(provider: .openAI, accountID: "openai-a", used: 80, savedAt: second)]
+ )
+ ])
+ try store.saveMerged(refreshResult: replacement, savedAt: second.addingTimeInterval(1))
+
+ let replaced = try #require(store.loadCurrent().snapshot?.snapshot.limits)
+ let openAILimit = try #require(replaced.first { $0.provider == .openAI && $0.accountID == "openai-a" })
+ #expect(openAILimit.used == 80)
+ #expect(replaced.filter { $0.provider == .openAI && $0.accountID == "openai-a" }.count == 1)
+ #expect(replaced.map(\.provider).contains(.google))
+ #expect(replaced.map(\.provider).contains(.anthropic))
+}
+
+@Test func jsonSnapshotStoreReportsMissingCurrentAsUnknown() throws {
+ let store = JSONSnapshotStore(rootDirectory: try temporaryDirectory())
+
+ let result = store.loadCurrent()
+
+ #expect(result.snapshot == nil)
+ #expect(result.status == .unknown)
+}
+
+@Test func jsonSnapshotStoreReportsCorruptCurrentAsFailureWithoutThrowing() throws {
+ let root = try temporaryDirectory()
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ try Data("not json".utf8).write(to: root.appending(path: "current-snapshot.json"))
+
+ let result = JSONSnapshotStore(rootDirectory: root).loadCurrent()
+
+ #expect(result.snapshot == nil)
+ #expect(result.status == .failure)
+ #expect(result.errorMessage?.isEmpty == false)
+}
+
+@Test func jsonSnapshotStoreAppliesStalenessPolicy() throws {
+ let root = try temporaryDirectory()
+ let store = JSONSnapshotStore(rootDirectory: root)
+ let savedAt = Date(timeIntervalSince1970: 100)
+ try store.save(StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot(
+ generatedAt: savedAt,
+ limits: [usageLimit(provider: .google, accountID: "g", used: 20, savedAt: savedAt)]
+ )))
+
+ let fresh = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 60), now: Date(timeIntervalSince1970: 120))
+ let stale = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 60), now: Date(timeIntervalSince1970: 200))
+
+ #expect(fresh.status == .healthy)
+ #expect(stale.status == .stale)
+}
+
+@Test func jsonSnapshotStoreRejectsUnsupportedSchema() throws {
+ let root = try temporaryDirectory()
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ let current = root.appending(path: "current-snapshot.json")
+ let json = #"""
+ {
+ "schemaVersion": 99,
+ "savedAt": "1970-01-01T00:00:00Z",
+ "snapshot": { "generatedAt": "1970-01-01T00:00:00Z", "limits": [] },
+ "reports": []
+ }
+ """#
+ try Data(json.utf8).write(to: current)
+
+ let result = JSONSnapshotStore(rootDirectory: root).loadCurrent()
+
+ #expect(result.status == .failure)
+ #expect(result.errorMessage?.contains("Unsupported snapshot schema") == true)
+}
+
+@Test func storedProviderReportRedactsErrorMessages() {
+ let report = ProviderConnectorReport(
+ provider: .openAI,
+ accountID: "local",
+ accountName: "OpenAI",
+ generatedAt: Date(timeIntervalSince1970: 0),
+ limits: [],
+ status: .failure,
+ errorMessage: "failed for user@example.com with bearer sk-secret"
+ )
+
+ let stored = StoredProviderReport(report: report)
+
+ #expect(stored.errorMessage?.contains("user@example.com") == false)
+ #expect(stored.errorMessage?.contains("sk-secret") == false)
+}
+
+private func usageLimit(provider: Provider, accountID: String, used: Int, savedAt: Date) -> UsageLimit {
+ UsageLimit(
+ provider: provider,
+ accountID: accountID,
+ accountName: accountID,
+ label: "usage",
+ unit: .percent,
+ used: used,
+ limit: 100,
+ resetsAt: savedAt.addingTimeInterval(3_600),
+ lastUpdatedAt: savedAt,
+ confidence: .observed
+ )
+}
+
+private func temporaryDirectory() throws -> URL {
+ let url = FileManager.default.temporaryDirectory
+ .appending(path: "context-panel-tests")
+ .appending(path: UUID().uuidString, directoryHint: .isDirectory)
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ return url
+}
diff --git a/Tests/ContextPanelCoreTests/UsageLimitTests.swift b/Tests/ContextPanelCoreTests/UsageLimitTests.swift
new file mode 100644
index 0000000..fa3f369
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/UsageLimitTests.swift
@@ -0,0 +1,112 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func remainingCapacityDoesNotGoBelowZero() {
+ let limit = UsageLimit(provider: .openAI, label: "GPT-5", used: 12, limit: 10)
+
+ #expect(limit.remaining == 0)
+}
+
+@Test func usageRatioIsCappedAtOne() {
+ let limit = UsageLimit(provider: .anthropic, label: "Claude", used: 15, limit: 10)
+
+ #expect(limit.usageRatio == 1)
+}
+
+@Test func unknownLimitsHaveUnknownStatus() {
+ let limit = UsageLimit(
+ provider: .google,
+ accountID: "google-personal",
+ accountName: "Personal",
+ label: "Gemini Pro",
+ used: nil,
+ limit: nil,
+ confidence: .unknown
+ )
+
+ #expect(limit.remaining == nil)
+ #expect(limit.usageRatio == nil)
+ #expect(limit.status == .unknown)
+}
+
+@Test func snapshotSortsMostConstrainedFirst() {
+ let snapshot = UsageSnapshot(
+ generatedAt: Date(),
+ limits: [
+ UsageLimit(
+ provider: .google,
+ accountID: "google-work",
+ accountName: "Work",
+ label: "Gemini Deep Research",
+ used: nil,
+ limit: nil,
+ confidence: .unknown,
+ statusOverride: .failure
+ ),
+ UsageLimit(
+ provider: .openAI,
+ accountID: "openai-team",
+ accountName: "Team",
+ label: "Image generation",
+ used: 49,
+ limit: 50,
+ confidence: .observed
+ )
+ ]
+ )
+ let first = snapshot.mostConstrainedLimits.first
+
+ #expect(first?.label == "Image generation")
+ #expect(abs(snapshot.aggregateCapacityRatio - 0.02) < 0.0001)
+}
+
+@Test func aggregateCapacityUsesTightestTrackedWindow() {
+ let snapshot = UsageSnapshot(
+ generatedAt: Date(),
+ limits: [
+ UsageLimit(provider: .openAI, label: "Weekly", used: 95, limit: 100),
+ UsageLimit(provider: .openAI, label: "5-hour", used: 5, limit: 100),
+ ]
+ )
+
+ #expect(abs(snapshot.aggregateCapacityRatio - 0.05) < 0.0001)
+}
+
+@Test func usageLimitSeparatesWindowAndModelLabels() {
+ let limit = UsageLimit(
+ provider: .openAI,
+ accountID: "openai-personal",
+ accountName: "Personal",
+ label: "Codex 5-hour",
+ windowLabel: "5-hour",
+ modelLabel: "Codex",
+ used: 42,
+ limit: 100
+ )
+
+ #expect(limit.displayLabel == "5-hour")
+ #expect(limit.contextLabel == "Codex ยท Personal")
+}
+
+@Test func providersCoverInitialScope() {
+ #expect(Provider.allCases == [.openAI, .anthropic, .google])
+}
+
+@Test func usageLimitCanRepresentPercentPressure() {
+ let limit = UsageLimit(
+ provider: .openAI,
+ accountID: "openai-personal",
+ accountName: "Personal",
+ label: "Codex weekly",
+ unit: .percent,
+ used: 38,
+ limit: 100,
+ confidence: .official
+ )
+
+ #expect(limit.unit == .percent)
+ #expect(limit.remaining == 62)
+ #expect(limit.usageRatio == 0.38)
+}
diff --git a/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift b/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift
new file mode 100644
index 0000000..53be3a9
--- /dev/null
+++ b/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift
@@ -0,0 +1,69 @@
+import Foundation
+import Testing
+
+@testable import ContextPanelCore
+
+@Test func widgetSnapshotUsesSetupNeededForMissingStore() {
+ let widget = WidgetSnapshot.fromStore(
+ SnapshotStoreLoadResult(snapshot: nil, status: .unknown),
+ now: Date(timeIntervalSince1970: 0)
+ )
+
+ #expect(widget.state == .setupNeeded)
+ #expect(widget.status == .unknown)
+ #expect(widget.limits.isEmpty)
+ #expect(widget.message.contains("Set up"))
+}
+
+@Test func widgetSnapshotPreservesStaleCachedLimits() {
+ let savedAt = Date(timeIntervalSince1970: 100)
+ let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot(
+ generatedAt: savedAt,
+ limits: [UsageLimit(provider: .openAI, label: "Codex", used: 20, limit: 100)]
+ ))
+
+ let widget = WidgetSnapshot.fromStore(
+ SnapshotStoreLoadResult(snapshot: stored, status: .stale),
+ now: Date(timeIntervalSince1970: 1_000)
+ )
+
+ #expect(widget.state == .stale)
+ #expect(widget.limits.count == 1)
+ #expect(widget.message == "Last snapshot is stale.")
+}
+
+@Test func widgetSnapshotBuildsProviderSummaries() {
+ let savedAt = Date(timeIntervalSince1970: 100)
+ let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot(
+ generatedAt: savedAt,
+ limits: [
+ UsageLimit(provider: .openAI, label: "Codex", used: 85, limit: 100),
+ UsageLimit(provider: .google, label: "Gemini", used: 10, limit: 100),
+ ]
+ ))
+
+ let widget = WidgetSnapshot.fromStore(SnapshotStoreLoadResult(snapshot: stored, status: .healthy))
+ let summaries = Dictionary(uniqueKeysWithValues: widget.providerSummaries.map { ($0.provider, $0) })
+
+ #expect(widget.state == .ready)
+ #expect(summaries[.openAI]?.status == .close)
+ #expect(summaries[.openAI]?.limitCount == 1)
+ #expect(summaries[.google]?.status == .healthy)
+ #expect(summaries[.anthropic]?.limitCount == 0)
+}
+
+@Test func providerSummariesUseTheTightestWindowInsteadOfAverageCapacity() {
+ let savedAt = Date(timeIntervalSince1970: 100)
+ let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot(
+ generatedAt: savedAt,
+ limits: [
+ UsageLimit(provider: .openAI, label: "Weekly", used: 95, limit: 100),
+ UsageLimit(provider: .openAI, label: "5-hour", used: 5, limit: 100),
+ ]
+ ))
+
+ let widget = WidgetSnapshot.fromStore(SnapshotStoreLoadResult(snapshot: stored, status: .healthy))
+ let openAI = widget.providerSummaries.first { $0.provider == .openAI }
+
+ #expect(abs((openAI?.capacityRatio ?? 0) - 0.05) < 0.0001)
+}
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..3fb5baf
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,10 @@
+# Context Panel Docs
+
+- [Agent Notes](../AGENTS.md)
+- [Product Goals](product-goals.md)
+- [Architecture](architecture.md)
+- [Design Direction](design-direction.md)
+- [Local Limit Probe Design](local-limit-probe.md)
+- [Provider Usage Access Research](provider-usage-access.md)
+- [macOS Release Path](release.md)
+- [Repository Settings](repo-settings.md)
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..4da0aa9
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,71 @@
+# Architecture
+
+Context Panel is expected to split into a few native boundaries:
+
+- `ContextPanelCore`: provider-neutral domain models, limit math, and refresh
+ policy.
+- Account store: multiple logins per provider, local credential references,
+ display names, and enabled/disabled state.
+- Provider adapters: small clients that retrieve or normalize usage state for
+ each service without leaking provider quirks into the UI.
+- Snapshot store: the latest normalized usage state plus refresh history for
+ widgets and charts.
+- macOS app: account setup, credentials, refresh scheduling, detailed charts,
+ provider health, and settings.
+- Widget extension: compact read-only display backed by the app's latest local
+ snapshot.
+
+The first committed code lives in `ContextPanelCore` so provider, account, and
+UI work can share the same vocabulary from the start.
+
+## Connector Runtime
+
+`ContextPanelCore` owns the provider connector contract. A connector refreshes
+one or more configured local accounts and returns a `ConnectorRefreshResult`,
+which carries provider/account reports plus a normalized `UsageSnapshot` for UI
+and storage code.
+
+MVP connectors:
+
+- `CodexRateLimitConnector`: reads Codex-style auth roots such as `~/.code` or
+ `~/.codex`, calls the live Codex usage endpoint, and normalizes primary,
+ secondary, and additional percent-window buckets.
+- `GeminiCodeAssistConnector`: reads Gemini CLI OAuth credentials, uses
+ explicitly supplied OAuth client inputs, resolves the active Code Assist
+ project internally, and normalizes model quota buckets as percent pressure.
+- `ClaudeLocalStatusConnector`: runs `claude auth status --json` and summarizes
+ `~/.claude/stats-cache.json`; live personal subscription allowance remains
+ unknown unless a clean provider signal appears.
+
+Connector implementations must keep secrets out of normalized state. Do not
+persist or print tokens, account IDs, project IDs, organization IDs, emails,
+headers, or raw response bodies. Errors should mention status and operation but
+not provider response content.
+
+## Snapshot Store
+
+The MVP cache is a local JSON store. It writes one current snapshot file plus a
+history directory of timestamped snapshots. The schema is intentionally simple:
+`StoredUsageSnapshot` includes a schema version, save time, normalized
+`UsageSnapshot`, and redacted provider refresh reports.
+
+The widget should read `current-snapshot.json` and apply a staleness policy. It
+must not read provider credential files or make provider network calls. The app
+owns connector refreshes, account setup, diagnostics, and future migration from
+JSON to a richer store if history queries become more complex.
+
+The WidgetKit implementation uses a `WidgetSnapshot` projection from the stored
+snapshot. That projection owns setup-needed, stale, failure, provider-summary,
+and most-constrained row selection so the widget view stays read-only and small.
+
+## Account Configuration
+
+The MVP account configuration is also local JSON. It stores account labels,
+enabled/disabled state, connector kind, and local paths or command names needed
+to locate provider CLI auth. It does not store provider secrets. Gemini OAuth
+client inputs are referenced by environment variable names so the values can
+remain outside the repository and outside the account config file.
+
+Widget interactions should keep the widget simple. Tapping the widget should
+open the app to the relevant provider or account detail; mutation and setup stay
+inside the app.
diff --git a/docs/design-direction.md b/docs/design-direction.md
new file mode 100644
index 0000000..b3cec85
--- /dev/null
+++ b/docs/design-direction.md
@@ -0,0 +1,100 @@
+# Design Direction
+
+Last updated: 2026-05-05.
+
+## Accepted Direction
+
+Use **Quiet Instrument** as the default Context Panel visual direction.
+
+The widget should feel like a calm Mac status instrument, not a billing
+dashboard. It should answer the user's immediate question first: can I keep
+working, and which account is most constrained?
+
+Adopt **Concept A - Instrument** as the primary widget direction:
+
+- Small widget: answer-first verdict, tightest account/model, capacity indicator,
+ provider mini-status, and nearest reset.
+- Medium widget: overall verdict/dial plus three or four most constrained rows.
+- Large widget: provider groups, six to eight account/model rows, compact trend,
+ refresh/stale state, and reset summary.
+
+Use **Concept B - Ledger** as the dense-list treatment inside the app and as a
+fallback large-widget direction if the Instrument layout cannot fit realistic
+multi-account data.
+
+## Visual System
+
+- True neutral gray surfaces, tuned separately for light and dark appearances.
+- One swappable accent color; default accent is a restrained slate blue.
+- Subtle status tints. Avoid alarm-heavy red as the dominant state language.
+- Provider identity should use short text badges plus labels, not abstract
+ shapes or provider logos as the only hierarchy.
+- Status must be communicated by color plus nearby text for color-vision safety.
+- Widgets are read-only and deep-link into the app for setup and detail.
+- Failure and stale states isolate to the affected account or provider; never
+ blank the whole widget when neighboring data is still valid.
+
+## Native App Shape
+
+The first app window should use a native macOS split-view structure:
+
+- Sidebar: provider/account groups, account status, and setup entry points.
+- Detail: selected account/provider limits, reset timing, trend, forecast, and
+ refresh history.
+- Inspector: normalized raw limits, confidence, provider connection state, and
+ troubleshooting.
+
+The app is where mutation lives: adding logins, naming accounts, disabling or
+removing accounts, refreshing, calibration, and credential/privacy messaging.
+
+## Component Map
+
+Translate the design artifact into native SwiftUI/WidgetKit components instead
+of copying the React implementation:
+
+- `CapacityDial`: ring/dial for overall or account capacity.
+- `CapacityBar`: compact account/model capacity bar.
+- `ProviderBadge`: provider short-name text badge.
+- `StatusMark`: compact status marker paired with text.
+- `AccountRow`: reusable account/model row for widgets and app detail.
+- `ContextWidget`: WidgetKit configuration for small, medium, and large layouts.
+- `WidgetTimelineProvider`: timeline backed by cached local snapshots and
+ last-good stale state.
+- `AppRoot`: SwiftUI app shell with `NavigationSplitView`.
+
+Keep colors, spacing, radius, typography, material, and status semantics in a
+native theme layer rather than scattering raw values through views.
+
+## State Coverage
+
+Required states for design and implementation:
+
+- Healthy/default.
+- Close to limit.
+- Limited or exceeded.
+- Stale data with last-good timestamp.
+- Unknown limit without implying zero capacity.
+- Provider or account refresh failure.
+- Loading/refreshing with last-known values preserved.
+- Empty/first run setup state.
+- Dense multi-account data.
+
+## Implementation Notes
+
+- Use native `.system` typography and SF Mono for numeric/tabular data. Web fonts
+ in the design artifact are preview stand-ins only.
+- Tune dark-mode contrast in SwiftUI; do not blindly trust exported CSS values.
+- Verify large-widget row density with realistic data before committing to six to
+ eight visible rows.
+- Prefer answer-first small widget copy, with the tightest account as supporting
+ text.
+- Default provider ordering can be by constraint/tightness for the widget, while
+ the app can support stable user/provider grouping.
+
+## Design Artifact
+
+The external design export was delivered as `/Users/cbusillo/Downloads/Context Panel.zip`.
+It includes React/HTML files for review only. The local browser export did not
+render under the current helper because the CDN-backed local HTML stayed blank,
+so visual implementation should use the handoff notes and source structure, then
+be validated again once native SwiftUI/WidgetKit views exist.
diff --git a/docs/local-limit-probe.md b/docs/local-limit-probe.md
new file mode 100644
index 0000000..2a3753e
--- /dev/null
+++ b/docs/local-limit-probe.md
@@ -0,0 +1,197 @@
+# Local Limit Probe Design
+
+Last updated: 2026-05-05.
+
+## Goal
+
+Context Panel needs to know whether subscription limits are exposed anywhere a
+logged-in user can legitimately see them. The first target is OpenAI ChatGPT
+subscription usage: weekly limits, short rolling windows, reset times, model
+availability, and any percent or token pressure signals. If the approach works
+for OpenAI, the same diagnostic shape can be reused for Claude and Gemini.
+
+The probe is a local diagnostic tool, not a production data integration. It
+should help answer:
+
+- Does the provider expose subscription limits in visible UI text?
+- Does the provider expose subscription limits in browser-accessible structured
+ responses after a normal login?
+- Which observations are safe and reliable enough to become Context Panel
+ signals?
+- Which observations should stay manual/calibrated because the provider hides or
+ changes them?
+
+## Safety Rules
+
+- The user logs in directly with the provider. Context Panel never asks for or
+ stores the provider password.
+- The probe never prints, commits, uploads, or logs cookies, bearer tokens,
+ session IDs, full response bodies, or account identifiers.
+- Captured artifacts are local and gitignored by default.
+- Raw network/body capture is opt-in and redacted before display.
+- The default probe reports only route/method/status/content-type/body-size plus
+ detected field names or text snippets that match usage-limit patterns.
+- Automated message sending is out of scope. The probe observes login/account
+ pages and model picker state; it does not burn subscription allowance.
+
+## Recommended Implementation
+
+Build a local macOS diagnostic surface called **Limit Probe** inside the
+companion app or as a development-only target.
+
+For the first implementation, prefer a native `WKWebView`-based probe because it
+lets the user log in normally while keeping the session isolated from Safari or
+Chrome. Later, a browser-extension or browser-control probe can be considered if
+WKWebView cannot observe enough.
+
+### Flow
+
+1. User opens `Limit Probe`.
+2. User selects provider: OpenAI first, then Anthropic, then Google.
+3. App opens an isolated `WKWebView` at the provider's normal product URL.
+4. User logs in normally.
+5. Probe shows a checklist:
+ - Login detected.
+ - Model picker/account UI reachable.
+ - Limit/reset text detected.
+ - Candidate structured responses detected.
+ - Manual observation needed.
+6. User navigates to the relevant UI, such as ChatGPT model picker.
+7. Probe scans visible text and sanitized network metadata for usage/reset
+ signals.
+8. User can press `Record Observation` to save a sanitized event.
+9. User can press `Export Redacted Report` for a local Markdown/JSON report.
+
+### OpenAI Targets
+
+Initial OpenAI pages and states to inspect:
+
+- ChatGPT app after login.
+- Model picker when GPT-5/GPT-5.5/Thinking modes are available.
+- Provider UI when a model is close to its limit.
+- Provider UI when a model is unavailable or limit-reached.
+- Account or plan surfaces that mention current plan and reset.
+
+Detection patterns:
+
+- `reset`, `resets`, `refresh`, `available`, `limit`, `usage`, `percent`,
+ `tokens`, `weekly`, `every 3 hours`, `every 5 hours`, `Thinking`, `fast`,
+ `temporary`.
+- Dates and relative durations such as `tomorrow`, `in 42m`, `3h`, `5 hours`,
+ `7 days`, `weekly`.
+- JSON field names containing `limit`, `usage`, `used_percent`, `token`,
+ `remaining`, `reset`, `cap`, `quota`, `model`, or `plan`.
+
+## Capture Model
+
+```swift
+struct LimitProbeObservation: Codable, Sendable {
+ var provider: Provider
+ var accountLabel: String?
+ var surface: String
+ var observedAt: Date
+ var source: ProbeSource
+ var signal: ProbeSignal
+ var confidence: UsageConfidence
+ var sanitizedEvidence: String
+}
+
+enum ProbeSource: String, Codable, Sendable {
+ case visibleText
+ case networkMetadata
+ case redactedResponseShape
+ case manualUserEntry
+}
+
+enum ProbeSignal: Codable, Sendable {
+ case resetTime(Date)
+ case relativeReset(seconds: TimeInterval)
+ case knownLimit(used: Int?, limit: Int, unit: String)
+ case modelAvailable(model: String)
+ case modelUnavailable(model: String, reason: String?)
+ case plan(name: String)
+ case unknownLimit
+}
+```
+
+## Sanitized Network Probe
+
+`WKWebView` does not expose every network body through public APIs. There are
+three possible tiers:
+
+1. **Visible text only**: safest and easiest. JavaScript reads `document.body`
+ text after login and extracts matching snippets.
+2. **JavaScript fetch instrumentation**: inject a user script that wraps
+ `window.fetch` and `XMLHttpRequest` to record sanitized URL path, method,
+ status, content type, body size, and matching field names from JSON responses.
+ Do not store headers or raw bodies.
+3. **External browser-control/devtools probe**: use a separate development-only
+ probe with browser automation/DevTools if WKWebView cannot see enough. This
+ remains local and diagnostic-only.
+
+Start with tier 1 and tier 2. Escalate only if OpenAI hides the useful signal
+from visible text and simple response-shape inspection.
+
+## Report Shape
+
+The exported report should be local and safe to share in a PR or issue after
+review:
+
+```json
+{
+ "schema_version": 1,
+ "provider": "openai",
+ "captured_at": "2026-05-05T00:00:00Z",
+ "surfaces": [
+ {
+ "surface": "chatgpt-model-picker",
+ "signals": [
+ {
+ "source": "visibleText",
+ "signal": "relativeReset",
+ "evidence": "resets in 3h",
+ "confidence": "observed"
+ }
+ ]
+ }
+ ],
+ "candidate_network_shapes": [
+ {
+ "method": "GET",
+ "path_hint": "/.../models/...",
+ "status": 200,
+ "content_type": "application/json",
+ "matched_fields": ["model", "limit", "reset"]
+ }
+ ],
+ "redactions": [
+ "cookies",
+ "authorization headers",
+ "account ids",
+ "emails",
+ "raw response bodies"
+ ]
+}
+```
+
+## Acceptance Criteria For A Prototype
+
+- User can log in to OpenAI in an isolated local web view.
+- Probe can scan visible UI text for limit/reset signals without storing
+ secrets.
+- Probe can record a manual observation when the UI exposes reset or limit state.
+- Probe can show whether any structured candidate responses appear, without
+ revealing raw response bodies or tokens.
+- Probe writes only redacted local artifacts under a gitignored directory.
+- Findings can update `docs/provider-usage-access.md` with evidence and
+ confidence.
+
+## Open Questions
+
+- Does ChatGPT's login flow work reliably in `WKWebView`, or does it require the
+ user's default browser?
+- Does the model picker expose useful reset text before a limit is reached?
+- Can visible text reveal enough to calibrate weekly Thinking limits without
+ network inspection?
+- Are subscription limit signals account-specific enough to distinguish multiple
+ OpenAI accounts cleanly?
diff --git a/docs/product-goals.md b/docs/product-goals.md
new file mode 100644
index 0000000..f5503d7
--- /dev/null
+++ b/docs/product-goals.md
@@ -0,0 +1,25 @@
+# Product Goals
+
+Context Panel should make AI usage limits legible without requiring people to
+open each provider dashboard or remember which account is close to a limit.
+
+## Initial Goals
+
+- Show remaining usage for OpenAI, Anthropic, and Google.
+- Support multiple logins per provider from the first real architecture slice.
+- Prefer native macOS surfaces: WidgetKit for glance state, a companion app for
+ setup and detail, and a menu bar surface later only if it earns its place.
+- Keep credentials local and avoid syncing usage data unless the user asks for
+ that later.
+- Display provider state in plain language: available, close to limit, limited,
+ unknown, and reset time.
+- Use compact visualizations that fit a macOS widget: progress rings, stacked
+ bars, sparklines, reset countdowns, and account/model grouping.
+
+## Non-Goals For The First Slice
+
+- Web dashboard.
+- Docker deployment.
+- Team billing or admin reporting.
+- Provider account automation that violates provider terms.
+- A cloud backend for the first version.
diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md
new file mode 100644
index 0000000..3ebb7aa
--- /dev/null
+++ b/docs/provider-usage-access.md
@@ -0,0 +1,308 @@
+# Provider Usage Access Research
+
+Last verified: 2026-05-06.
+
+## Summary
+
+Context Panel should model two different worlds:
+
+- API/provider-console usage, where official usage APIs, cost APIs, quota APIs,
+ rate-limit headers, or Cloud Monitoring data can provide real measurements.
+- Consumer chat subscription usage, where providers often expose limits and reset
+ timing in product UI but do not expose a stable public API for the underlying
+ percent or token pressure.
+
+The OpenAI account use case needs special treatment. For ChatGPT-style weekly
+subscription limits, current product surfaces have moved away from a visible
+message counter. Context Panel should model OpenAI usage as percent or token
+pressure over reset windows: multiple OpenAI accounts, reset windows, observed
+usage pressure, burn-rate history, and a clear answer to "am I safe to turn on
+fast mode?"
+
+## Forecast Requirement
+
+The app should answer these questions for each account and across all enabled
+accounts:
+
+- How much usable allowance remains before reset?
+- At my current pace, will I run out before the weekly reset?
+- If I turn on fast mode now, how long can I leave it on safely?
+- Which account should I use next if one account is close to its limit?
+
+Recommended model:
+
+- `LimitWindow`: provider, account, bucket, unit, limit, used or remaining,
+ reset time, reset policy, and confidence.
+- `UsageObservation`: sampled value, source, timestamp, and confidence.
+- `BurnProfile`: observed standard-mode and fast-mode usage rate over recent
+ windows.
+- `Forecast`: projected use through reset, estimated runway, recommended mode,
+ and confidence.
+
+Recommended safe-mode rule:
+
+```text
+safe_fast_mode = projected_fast_usage_until_reset + reserve <= remaining
+```
+
+Where `reserve` is a user-configurable safety buffer. The widget should say
+"safe", "safe for about N hours", "save fast mode", or "needs calibration"
+instead of pretending an estimate is exact.
+
+## Provider Matrix
+
+| Provider surface | Official data available | Reset/limit signal | Multi-login shape | V1 recommendation | Confidence |
+| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| OpenAI API organizations | Usage API, Costs API, and rate-limit headers. Usage can be grouped by project, user, API key, model, batch, and service tier depending on endpoint. | API rate limits expose remaining requests/tokens and reset headers; monthly usage limits are organization/project concerns. | One connected API organization/project per credential. Multiple credentials/accounts should be supported. | Support API org usage as an official adapter using admin or sufficiently privileged API keys. | High |
+| OpenAI ChatGPT accounts | No stable public API found for general personal ChatGPT subscription pressure outside Codex. Current product surfaces no longer present a simple message counter; the useful automated signal found so far is percent-used pressure for Codex/Fast Mode. | Weekly and short rolling reset windows matter. Codex/Fast Mode exposes live percent-used windows through the Codex backend usage endpoint. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, percent/token pressure, and local observation history. | For Codex/Fast Mode, use the live Codex usage endpoint. For non-Codex ChatGPT surfaces, keep manual/assisted observations and forecast confidence until a clean provider signal exists. | High for Codex percent windows; medium for visible reset clues; low for non-Codex automation |
+| Anthropic API organizations | Usage and Cost API can report message usage and costs by time bucket, model, workspace, API key, service tier, context window, geo, and beta fast-mode speed. API responses include rate-limit headers with remaining and reset values. | API rate limits use token bucket behavior; monthly spend limits exist by tier. | Organization/workspace/API-key credentials. Multiple organizations and workspaces should be supported. | Support official API usage/cost adapter. Capture fast-mode dimensions where available. | High |
+| Claude subscriptions and Claude Code seats | Claude Code status-line JSON can include `rate_limits.five_hour` and `rate_limits.seven_day` for Claude.ai Pro and Max subscribers after a session receives an API response. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. Non-interactive `claude -p --output-format stream-json --verbose` can emit `rate_limit_info` with status, active window type, and reset time, but no used percentage was observed. `ccusage blocks --json --offline` can derive active 5-hour block token use and runway from local aggregate session data used by Every Code. | Status-line rate-limit windows include used percentage and reset epochs. Pro/Max/Team usage has session-based reset behavior. `ccusage` estimates token pressure and reset/runway rather than official server percent. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata, local activity freshness, optional status-line rate-limit cache, and an explicit Every Code-compatible `ccusage` estimate. Mark old status-line readings stale, and label `ccusage` rows as estimated. Do not read auth, Keychain, raw transcripts, prompts, or conversation JSONL directly. | High for fresh status-line subscription windows when configured; medium for non-interactive reset/status metadata; medium for `ccusage` estimated runway; medium for auth/subscription metadata and local history |
+| Google Gemini CLI / Code Assist | Gemini CLI OAuth credentials can be refreshed locally, then the Code Assist backend returns live quota buckets with model IDs, remaining fractions, optional remaining amounts, and reset times. | Quota buckets are percent-style remaining fractions per model with provider reset timestamps. | Google account plus Code Assist project is the natural boundary; multiple `GEMINI_CLI_HOME` roots can represent multiple logins. | Support a Gemini Code Assist live quota connector using Gemini CLI auth. Store only normalized percent pressure and reset times. | High for Gemini CLI/Code Assist buckets observed locally |
+| Google Gemini API / Google AI Studio projects | AI Studio and Cloud Billing show usage. Gemini API rate limits are project-scoped, not API-key-scoped. Service Usage API lists quota limits; Cloud Monitoring exposes quota usage metrics; Cloud Billing export to BigQuery provides detailed cost/usage data. | Rate limits are RPM, input TPM, and RPD, with model/tier variation. RPD quotas reset at midnight Pacific time. | Google project is the natural account boundary. Multiple Google accounts/projects should be supported. | Support Google API projects after OAuth/service-account design. Use Service Usage for limits, Cloud Monitoring for quota usage, and optional Billing export for cost history. | Medium-high, but setup is heavier |
+| Google consumer Gemini app subscriptions | No stable public API for personal Gemini app subscription allowance was found in this pass. | Provider UI likely remains source of truth. | Multiple Google accounts may matter, but automation risk is high. | Defer for v1 unless a supported API emerges. | Low |
+
+## OpenAI Fast-Mode Forecasting
+
+For the user's immediate OpenAI need, the best product path is a live Codex
+percent-window connector backed by an honest local predictor:
+
+1. Add each OpenAI account separately.
+2. Record plan and relevant buckets, such as Codex/Fast Mode weekly and
+ five-hour windows.
+3. Fetch current percent-used pressure and reset times when the Codex endpoint
+ is available.
+4. Capture reset time from the UI when no endpoint signal exists, or let the
+ user enter it.
+5. Start a local usage ledger from the moment Context Panel is installed.
+6. Estimate standard and fast-mode burn rates in percent or tokens per hour
+ from local usage history.
+7. Recommend when to enable fast mode only when the forecast has enough margin.
+
+The widget should make confidence visible. Good copy examples:
+
+- `Fast mode looks safe through reset.`
+- `Fast mode safe for about 2h, then switch back.`
+- `Save fast mode: projected to run out 18h before reset.`
+- `Needs calibration: open ChatGPT and set reset time.`
+
+## Local Probe And Every Code Evidence
+
+The first OpenAI Limit Probe run confirmed the uncomfortable but useful shape of
+the problem:
+
+- ChatGPT visible text exposed plan/model language such as model names, `Pro`,
+ `Instant`, and `Thinking`, but did not expose a percent/token counter or a
+ reset time before exhaustion.
+- Sanitized network response-shape scanning found account entitlement and plan
+ fields, including subscription-plan style field names, but no obvious
+ `used_percent`, token pressure, `reset_at`, weekly allowance, or five-hour
+ allowance fields outside the Codex usage surface.
+- The probe should remain useful as a diagnostic harness because it can detect
+ if OpenAI later starts exposing cleaner fields, and it can produce redacted
+ evidence across multiple accounts.
+
+Codex-family tooling exposes a stronger path for Codex/Fast Mode. Upstream
+Codex CLI has an app-server method, `account/rateLimits/read`, backed by a
+backend client call that fetches live snapshots from the ChatGPT Codex backend:
+`GET /backend-api/wham/usage` for ChatGPT-backed auth, or `/api/codex/usage` for
+Codex API-style deployments. The payload maps into rate-limit snapshots with
+provider window buckets, reset times, plan type, credits, reached-limit
+classification, and additional buckets keyed by `limit_id`.
+
+Every Code is useful as a fallback and validation source. It does not derive
+Codex rate-limit snapshots from local token counts. It sends authenticated
+requests to the ChatGPT Codex backend, parses server-reported `x-codex-*`
+response headers into percentage and reset-window snapshots, and persists the
+latest server snapshot under local usage files. The local files are a cache of
+server state plus local token history, which explains why displayed limit
+pressure reflects cloud and other-machine usage for the same account.
+
+Every Code also has a deliberate refresh path: it sends a tiny `"ok"` prompt via
+the selected account, waits for a `RateLimits` event from response headers, then
+persists the snapshot and updates the `/limits` UI. Separately, when the backend
+returns `usage_limit_reached`, it records `plan_type`, `resets_in_seconds`, and
+the reached-limit type as a hint.
+
+That is stronger evidence than visible ChatGPT UI scraping for Codex-style
+limits, but it is still product-surface-specific. Context Panel should separate
+`OpenAI ChatGPT product UI hints` from `OpenAI Codex backend percent windows`.
+The latter looks viable as an automated adapter if Context Panel can reuse the
+same authenticated account flow safely.
+
+Implication: v1 should not promise exact general ChatGPT subscription counters.
+For Codex/Fast Mode, though, the preferred path is a live OpenAI Codex limits
+connector using the same shape as Codex CLI's
+`account/rateLimits/read`/`get_rate_limits_many()` flow. If that cannot be made
+stable or safely testable, fall back to Every Code's local `usage/*.json` cache
+or Codex CLI's app-server request.
+
+### Codex Limits Connector
+
+Preferred v1 connector scope:
+
+- Fetch live Codex limits directly from the Codex backend usage endpoint shape:
+ `GET https://chatgpt.com/backend-api/wham/usage` for ChatGPT-backed auth.
+- Support provider window buckets, reset times, plan type, credits,
+ reached-limit classification, and additional `limit_id` buckets.
+- Keep auth handling isolated and redacted; never log tokens, cookies,
+ authorization headers, account IDs, emails, or raw response bodies.
+- Expose a diagnostic probe that reports only sanitized structure, percentages,
+ reset timing, bucket labels, and staleness.
+- Mark this as an OpenAI Codex/Fast Mode percent-window source, not a general
+ ChatGPT subscription counter.
+- Do not require the Codex CLI binary or app server at runtime. The local
+ `CodexRateLimitProbe` executable exists to prove the direct call path against
+ an existing Codex `auth.json` while printing only redacted summaries.
+
+### Gemini Code Assist Connector
+
+The local Gemini CLI path gives Context Panel a second viable live connector.
+The CLI stores OAuth credentials under `~/.gemini/oauth_creds.json`, while the
+active account metadata lives separately under `~/.gemini/google_accounts.json`.
+The quota values are not persisted as a durable local cache; Gemini CLI keeps
+quota state in memory and refreshes it from the Code Assist backend.
+
+Preferred v1 connector scope:
+
+- Resolve `GEMINI_CLI_HOME`, then default to `~/.gemini`.
+- Read `oauth_creds.json` only to refresh an access token locally; never print,
+ store, or upload token values.
+- Call the Gemini Code Assist load path to resolve the active project internally;
+ never print or persist the raw project identifier.
+- Call the Gemini Code Assist quota path and normalize buckets by model ID,
+ remaining fraction, optional remaining amount, and reset time.
+- Represent each bucket as percent pressure: `used = round((1 - remaining) *
+100)`, `limit = 100`, `unit = percent`.
+- Mark confidence as observed because this is a product backend surface rather
+ than a public quota API contract.
+
+The local `GeminiQuotaProbe` executable proves this path with redacted output.
+On 2026-05-06 it returned seven live model buckets for the local Gemini CLI
+account, including Gemini 2.5 and Gemini 3 preview models, with percent
+remaining and reset timestamps.
+
+### Claude Subscription Connector
+
+Claude subscription pressure should use Claude Code's supported status-line JSON
+surface, not Anthropic API organization usage. Claude Code's status-line input
+can contain `rate_limits.five_hour.used_percentage`,
+`rate_limits.five_hour.resets_at`, `rate_limits.seven_day.used_percentage`, and
+`rate_limits.seven_day.resets_at` for Claude.ai Pro and Max subscribers after a
+Claude Code session receives an API response.
+
+This status-line surface is currently interactive-session scoped. On
+2026-05-06, a local non-interactive probe using `claude -p "Reply with exactly
+OK." --output-format json --verbose` emitted a `rate_limit_event` with
+`rate_limit_info.status`, `rateLimitType`, and `resetsAt`, but did not emit
+`used_percentage` and did not refresh the configured status-line cache. A local
+`claude -p "/usage" --output-format json --verbose` probe returned only that the
+Claude Code subscription was in use, not the five-hour or weekly usage
+percentages. This means Every Code's current external `claude -p` agent path
+does not by itself provide official subscription percent pressure.
+
+Local binary/bundle inspection found runtime strings for
+`anthropic-ratelimit-unified-*`, `utilization`, `five_hour`, and `seven_day` in
+Claude Code/Desktop surfaces. It also found the Claude web/desktop usage hook:
+the installed Claude app calls
+`GET /api/organizations/{active_organization_uuid}/usage` and refreshes it on a
+five-minute interval from the settings usage page. The usage page component is
+what renders "Claude subscription usage", current-session/five-hour usage, and
+weekly limit rows.
+
+That endpoint is the strongest direct subscription API candidate found so far,
+but it is authenticated through the Claude web/app session. A local automated
+browser probe against `https://claude.ai/settings/usage` on 2026-05-06 was
+blocked by Cloudflare before login/session reuse, so Context Panel has not yet
+proven it can call this endpoint without a user-visible web login context. We
+should not extract browser cookies, Keychain credentials, OAuth tokens, local
+storage, raw response bodies, transcripts, account UUIDs, or emails to force the
+call. The next safe implementation path is a Claude web usage probe that runs in
+a user-visible embedded web session and records only sanitized fields such as
+`five_hour`, `seven_day`, `used_percentage`, `remaining_percentage`,
+`utilization`, and `resets_at`.
+
+The local `ClaudeWebUsageProbe` executable implements that path. It opens
+Claude's usage page in a visible WebKit session, lets the user complete login or
+Cloudflare verification normally, observes only `/api/organizations/*/usage`
+responses, and reduces the page response to whitelisted usage windows before
+Swift receives anything. Saving from the probe writes normalized percent/reset
+rows to Context Panel's snapshot store; it does not persist cookies,
+authorization headers, tokens, local storage, account UUIDs, organization UUIDs,
+emails, or raw response bodies.
+
+No safe persisted local Claude Desktop file/cache containing official
+subscription percentages was found. The remaining research target is an
+explicit, privacy-safe metadata capture path for Every Code/non-interactive
+usage, tracked separately in issue #19.
+
+The official Claude Code authentication docs say macOS credentials are stored in
+the encrypted macOS Keychain. Context Panel must not read Keychain secrets or
+try to extract subscription OAuth tokens.
+
+Preferred v1 connector scope:
+
+- Call `claude auth status --json` and keep only non-secret fields such as
+ `loggedIn`, `authMethod`, `apiProvider`, and `subscriptionType`.
+- Offer a tiny status-line helper that receives Claude Code status-line JSON on
+ stdin and writes only observed timestamp, five-hour percentage/reset, and
+ weekly percentage/reset to a Context Panel cache file.
+- Read that sanitized status-line cache and normalize Claude five-hour and
+ weekly windows as percent limits when present.
+- Read `~/.claude/stats-cache.json` only as local historical activity, not live
+ subscription allowance.
+- Summarize local stats by freshness and counts; do not read raw transcript
+ JSONL files, prompts, account UUIDs, emails, organization IDs, or token blobs.
+- Show Claude subscription allowance as unknown until the status-line cache has
+ been populated by a live Claude Code response.
+- Treat `ccusage` and local token aggregates as estimated pressure only; never
+ present them as official Claude subscription percent used.
+
+The local `ClaudeLimitProbe` executable proves the conservative fallback path.
+On 2026-05-06 it confirmed the local Claude CLI is logged in with subscription
+metadata and has a local stats cache. The follow-up subscription path is the
+sanitized status-line cache, not raw Claude auth/session data.
+
+### Every Code Cache Fallback
+
+Fallback connector scope:
+
+- Resolve `CODE_HOME`, then `CODEX_HOME`, then default to `~/.code`.
+- Read only `$CODE_HOME/usage/*.json`.
+- Never read auth files, token files, debug logs, history files, or config files
+ for the connector.
+- Parse `rate_limit.snapshot`, `observed_at`, `primary_next_reset_at`,
+ `secondary_next_reset_at`, `last_usage_limit_hit_at`, and `plan`.
+- Normalize Codex provider window buckets as limits with observed
+ confidence and freshness state.
+- Mark the connector stale when `observed_at` or `last_updated` is older than a
+ conservative threshold; do not trigger refreshes.
+- Show account IDs only as short local labels unless the user assigns names.
+
+## Product Decisions
+
+- Treat `unknown`, `manual`, `observed`, and `official` as distinct confidence
+ levels in the data model and UI.
+- Do not block the whole widget when one provider cannot expose usage. Show stale
+ or estimated state for that account and keep official data for other accounts.
+- Prioritize OpenAI ChatGPT forecasting in the UX even if the first automated
+ data source is manual/local, because it directly answers the fast-mode problem.
+- Keep provider terms and account safety ahead of automation convenience.
+
+## Sources
+
+- [OpenAI Usage API reference](https://developers.openai.com/api/reference/resources/admin/subresources/organization/subresources/usage)
+- [OpenAI API rate limits](https://developers.openai.com/api/docs/guides/rate-limits#usage-tiers)
+- [GPT-5.3 and GPT-5.5 in ChatGPT](https://help.openai.com/en/articles/11909943-gpt-5-in-chatgpt)
+- [OpenAI o3 and o4-mini usage limits](https://help.openai.com/en/articles/9824962-openai-o1and-o1-mini-usage-limits-on-chatgpt-and-the-api)
+- [Anthropic Usage and Cost API](https://platform.claude.com/docs/en/build-with-claude/usage-cost-api)
+- [Anthropic API rate limits](https://docs.anthropic.com/en/api/rate-limits)
+- [Claude Code authentication](https://code.claude.com/docs/en/authentication)
+- [Claude usage and length limits](https://support.claude.com/en/articles/11647753-how-do-usage-and-length-limits-work)
+- [Models, usage, and limits in Claude Code](https://support.claude.com/en/articles/14552983-models-usage-and-limits-in-claude-code)
+- [Gemini CLI authentication](https://google-gemini.github.io/gemini-cli/docs/get-started/authentication.html)
+- [Gemini CLI quotas and pricing](https://google-gemini.github.io/gemini-cli/docs/quota-and-pricing.html)
+- [Gemini API billing](https://ai.google.dev/gemini-api/docs/billing/)
+- [Gemini API rate limits](https://ai.google.dev/gemini-api/docs/rate-limits)
+- [Google Service Usage consumer quota metrics](https://cloud.google.com/service-usage/docs/reference/rest/v1beta1/services.consumerQuotaMetrics/list)
+- [Google Cloud quota usage metrics](https://docs.cloud.google.com/monitoring/alerts/using-quota-metrics)
+- [Cloud Billing export to BigQuery](https://cloud.google.com/billing/docs/how-to/export-data-bigquery)
diff --git a/docs/release.md b/docs/release.md
new file mode 100644
index 0000000..bd1e890
--- /dev/null
+++ b/docs/release.md
@@ -0,0 +1,80 @@
+# macOS Release Path
+
+Last verified: 2026-05-06.
+
+Context Panel has two local packaging paths:
+
+- a native Xcode app target with an embedded WidgetKit extension, generated by
+ XcodeGen
+- an interim SwiftPM wrapper script for quickly sharing the preview app without
+ the widget extension
+
+The native Xcode path is the one to use for widget installability.
+
+## Build The Native App And Widget
+
+```sh
+xcodegen generate --spec project.yml
+xcodebuild \
+ -project ContextPanel.xcodeproj \
+ -scheme ContextPanel \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ -allowProvisioningUpdates \
+ build
+```
+
+This builds `Context Panel.app` and embeds
+`ContextPanelWidgetExtension.appex` under `Contents/PlugIns`. The app and widget
+both carry the `group.com.shinycomputers.contextpanel` App Group entitlement and
+share snapshots through the App Group container when it is available.
+
+The Xcode project is generated from `project.yml`; update the spec and
+regenerate rather than editing the project by hand.
+
+On 2026-05-06, the Debug Xcode build succeeded locally with automatic signing,
+embedded the WidgetKit extension, included the WidgetKit extension point in the
+extension Info.plist, and passed `codesign --verify --deep --strict`.
+
+## Build A Signed App
+
+```sh
+scripts/package-macos-app.sh --output dist --identity auto
+```
+
+The script builds `ContextPanelPreview` in release mode, wraps it as
+`dist/Context Panel.app`, writes app metadata, signs the bundle, verifies the
+signature, and runs a local Gatekeeper assessment.
+
+`--identity auto` asks Keychain for available code-signing identities and
+prefers `Developer ID Application`, then `Apple Development`, then ad-hoc
+signing. The script does not read private keys or credentials; signing is
+performed by macOS Keychain through `codesign`.
+
+Useful variants:
+
+```sh
+scripts/package-macos-app.sh --debug
+scripts/package-macos-app.sh --identity -
+scripts/package-macos-app.sh \
+ --product ClaudeWebUsageProbe \
+ --display-name "Claude Usage Probe" \
+ --bundle-id com.shinycomputers.contextpanel.claudeprobe
+```
+
+## Current Constraints
+
+- The package is signed but not notarized by this script.
+- The script's app bundle contains the SwiftPM preview app, not the native Xcode
+ app target.
+- The script does not embed the WidgetKit extension; use the Xcode build for
+ widget testing.
+- Full friend-installable distribution still needs archive/export and
+ notarization polish.
+
+## Validation
+
+On 2026-05-06, `scripts/package-macos-app.sh --output dist --identity auto`
+produced `dist/Context Panel.app`, signed with Developer ID Application:
+Shiny Computers Leasing LLC, and `spctl --assess --type execute` accepted the
+bundle as Developer ID signed.
diff --git a/docs/repo-settings.md b/docs/repo-settings.md
new file mode 100644
index 0000000..3252910
--- /dev/null
+++ b/docs/repo-settings.md
@@ -0,0 +1,17 @@
+# Repository Settings
+
+Expected GitHub settings:
+
+- Visibility: public.
+- Default branch: `main`.
+- Issues: enabled.
+- Projects: enabled.
+- Discussions: enabled.
+- Wiki: disabled.
+- Delete branches on merge: enabled.
+- Merge commits: enabled.
+- Squash and rebase merges: disabled.
+- Dependabot: enabled for Swift Package Manager and GitHub Actions.
+- CodeQL: enabled for Swift.
+
+Implementation work should happen on focused branches with pull requests.
diff --git a/project.yml b/project.yml
new file mode 100644
index 0000000..f9c3967
--- /dev/null
+++ b/project.yml
@@ -0,0 +1,78 @@
+---
+name: ContextPanel
+options:
+ bundleIdPrefix: com.shinycomputers
+ deploymentTarget:
+ macOS: "14.0"
+settings:
+ base:
+ DEVELOPMENT_TEAM: MM5YXC7T6E
+ CODE_SIGN_STYLE: Automatic
+ SWIFT_VERSION: "6.0"
+ MACOSX_DEPLOYMENT_TARGET: "14.0"
+packages: {}
+targets:
+ ContextPanelCore:
+ type: library.static
+ platform: macOS
+ deploymentTarget: "14.0"
+ sources:
+ - path: Sources/ContextPanelCore
+ settings:
+ base:
+ PRODUCT_NAME: ContextPanelCore
+ SKIP_INSTALL: true
+
+ ContextPanel:
+ type: application
+ platform: macOS
+ deploymentTarget: "14.0"
+ sources:
+ - path: Sources/ContextPanelPreview
+ dependencies:
+ - target: ContextPanelCore
+ - target: ContextPanelWidgetExtension
+ embed: true
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.shinycomputers.contextpanel
+ PRODUCT_NAME: Context Panel
+ GENERATE_INFOPLIST_FILE: true
+ INFOPLIST_KEY_CFBundleDisplayName: Context Panel
+ MARKETING_VERSION: "1.0"
+ CURRENT_PROJECT_VERSION: "1"
+ INFOPLIST_KEY_LSApplicationCategoryType: >-
+ public.app-category.productivity
+ CODE_SIGN_ENTITLEMENTS: Config/ContextPanel.entitlements
+
+ ContextPanelWidgetExtension:
+ type: app-extension
+ platform: macOS
+ deploymentTarget: "14.0"
+ sources:
+ - path: Sources/ContextPanelWidget
+ dependencies:
+ - target: ContextPanelCore
+ info:
+ path: Config/ContextPanelWidget-Info.plist
+ properties:
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.widgetkit-extension
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.shinycomputers.contextpanel.widget
+ PRODUCT_NAME: ContextPanelWidgetExtension
+ GENERATE_INFOPLIST_FILE: false
+ CODE_SIGN_ENTITLEMENTS: Config/ContextPanelWidget.entitlements
+ SKIP_INSTALL: true
+
+schemes:
+ ContextPanel:
+ build:
+ targets:
+ ContextPanel: all
+ ContextPanelWidgetExtension: all
+ run:
+ config: Debug
+ archive:
+ config: Release
diff --git a/scripts/claude-ccusage-cache.sh b/scripts/claude-ccusage-cache.sh
new file mode 100755
index 0000000..1b85566
--- /dev/null
+++ b/scripts/claude-ccusage-cache.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cache_dir="${CONTEXT_PANEL_CLAUDE_RATE_LIMIT_DIR:-$HOME/Library/Application Support/Context Panel/ClaudeRateLimits}"
+cache_file="$cache_dir/ccusage-blocks-cache.json"
+
+mkdir -p "$cache_dir"
+
+if command -v ccusage >/dev/null 2>&1; then
+ ccusage blocks --json --offline >"$cache_file.tmp"
+elif command -v bunx >/dev/null 2>&1; then
+ bunx ccusage@latest blocks --json --offline >"$cache_file.tmp"
+else
+ echo "ccusage or bunx is required" >&2
+ exit 127
+fi
+
+mv "$cache_file.tmp" "$cache_file"
+echo "$cache_file"
diff --git a/scripts/claude-statusline-cache.sh b/scripts/claude-statusline-cache.sh
new file mode 100755
index 0000000..03348be
--- /dev/null
+++ b/scripts/claude-statusline-cache.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+input="$(cat)"
+out_dir="${CONTEXT_PANEL_CLAUDE_RATE_LIMIT_DIR:-$HOME/Library/Application Support/Context Panel/ClaudeRateLimits}"
+out_file="$out_dir/statusline-cache.json"
+tmp_file="${out_file}.tmp"
+
+mkdir -p "$out_dir"
+
+cache_json="$(jq -c --argjson observed_at "$(date +%s)" '
+ def clean_window:
+ select(type == "object")
+ | {
+ used_percentage: (.used_percentage // empty),
+ resets_at: (.resets_at // null)
+ };
+
+ {
+ observed_at: $observed_at,
+ rate_limits: {
+ five_hour: (.rate_limits.five_hour? | clean_window),
+ seven_day: (.rate_limits.seven_day? | clean_window)
+ }
+ }
+ | .rate_limits |= with_entries(select(.value != null))
+ | select((.rate_limits | length) > 0)
+' <<<"$input")"
+
+if [ -n "$cache_json" ]; then
+ printf '%s\n' "$cache_json" >"$tmp_file"
+ mv "$tmp_file" "$out_file"
+fi
+
+model="$(jq -r '.model.display_name // "Claude"' <<<"$input")"
+five_hour="$(jq -r '.rate_limits.five_hour.used_percentage // empty' <<<"$input")"
+weekly="$(jq -r '.rate_limits.seven_day.used_percentage // empty' <<<"$input")"
+
+limits=""
+if [ -n "$five_hour" ]; then
+ limits="5h: $(printf '%.0f' "$five_hour")%"
+fi
+if [ -n "$weekly" ]; then
+ limits="${limits:+$limits }7d: $(printf '%.0f' "$weekly")%"
+fi
+
+if [ -n "$limits" ]; then
+ printf '[%s] | %s\n' "$model" "$limits"
+else
+ printf '[%s]\n' "$model"
+fi
diff --git a/scripts/commit-gate.sh b/scripts/commit-gate.sh
new file mode 100755
index 0000000..f3bf9ea
--- /dev/null
+++ b/scripts/commit-gate.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+swift build
+swift test
diff --git a/scripts/package-macos-app.sh b/scripts/package-macos-app.sh
new file mode 100755
index 0000000..8e09c15
--- /dev/null
+++ b/scripts/package-macos-app.sh
@@ -0,0 +1,175 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+product="ContextPanelPreview"
+display_name="Context Panel"
+bundle_id="com.shinycomputers.contextpanel"
+configuration="release"
+output_dir="dist"
+signing_identity="auto"
+
+usage() {
+ cat <<'USAGE'
+Usage: scripts/package-macos-app.sh [options]
+
+Builds a SwiftPM executable and wraps it in a launchable macOS .app bundle.
+
+Options:
+ --product NAME SwiftPM executable product. Default: ContextPanelPreview
+ --display-name NAME App display name. Default: Context Panel
+ --bundle-id ID CFBundleIdentifier. Default: com.shinycomputers.contextpanel
+ --debug Build debug instead of release
+ --output DIR Output directory. Default: dist
+ --identity VALUE codesign identity, "auto", or "-" for ad-hoc. Default: auto
+ -h, --help Show this help
+
+The script never reads private keys or credentials. When --identity auto is
+used, it asks Keychain for signing identities and prefers Developer ID
+Application, then Apple Development, then ad-hoc signing.
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --product)
+ product="${2:?--product requires a value}"
+ shift 2
+ ;;
+ --display-name)
+ display_name="${2:?--display-name requires a value}"
+ shift 2
+ ;;
+ --bundle-id)
+ bundle_id="${2:?--bundle-id requires a value}"
+ shift 2
+ ;;
+ --debug)
+ configuration="debug"
+ shift
+ ;;
+ --output)
+ output_dir="${2:?--output requires a value}"
+ shift 2
+ ;;
+ --identity)
+ signing_identity="${2:?--identity requires a value}"
+ shift 2
+ ;;
+ -h | --help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "unknown option: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+build_args=(--product "$product")
+if [[ "$configuration" == "release" ]]; then
+ build_args+=(--configuration release)
+fi
+
+swift build "${build_args[@]}"
+
+bin_path_args=(--show-bin-path)
+if [[ "$configuration" == "release" ]]; then
+ bin_path_args+=(--configuration release)
+fi
+triple=$(swift build "${bin_path_args[@]}" 2>/dev/null || true)
+if [[ -z "$triple" ]]; then
+ if [[ "$configuration" == "release" ]]; then
+ triple=".build/release"
+ else
+ triple=".build/debug"
+ fi
+fi
+
+executable_path="$triple/$product"
+if [[ ! -x "$executable_path" ]]; then
+ echo "built executable not found: $executable_path" >&2
+ exit 1
+fi
+
+app_path="$output_dir/$display_name.app"
+contents="$app_path/Contents"
+macos="$contents/MacOS"
+resources="$contents/Resources"
+
+rm -rf "$app_path"
+mkdir -p "$macos" "$resources"
+cp "$executable_path" "$macos/$product"
+
+cat >"$contents/Info.plist" <
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $product
+ CFBundleIdentifier
+ $bundle_id
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $display_name
+ CFBundleDisplayName
+ $display_name
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 0.1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 14.0
+ NSHighResolutionCapable
+
+ NSPrincipalClass
+ NSApplication
+
+
+PLIST
+
+printf 'APPL????' >"$contents/PkgInfo"
+
+resolve_identity() {
+ if [[ "$signing_identity" != "auto" ]]; then
+ printf '%s\n' "$signing_identity"
+ return
+ fi
+
+ local identity
+ identity=$(security find-identity -v -p codesigning 2>/dev/null |
+ sed -n 's/^.*"\(Developer ID Application:[^"]*\)".*$/\1/p' |
+ head -n 1)
+ if [[ -z "$identity" ]]; then
+ identity=$(security find-identity -v -p codesigning 2>/dev/null |
+ sed -n 's/^.*"\(Apple Development:[^"]*\)".*$/\1/p' |
+ head -n 1)
+ fi
+ printf '%s\n' "${identity:--}"
+}
+
+resolved_identity=$(resolve_identity)
+if [[ "$resolved_identity" == "-" ]]; then
+ codesign --force --sign - "$app_path"
+else
+ if ! codesign --force --options runtime --timestamp --sign "$resolved_identity" "$app_path"; then
+ echo "Developer signing failed; retrying ad-hoc signing." >&2
+ codesign --force --sign - "$app_path"
+ resolved_identity="-"
+ fi
+fi
+
+codesign --verify --deep --strict --verbose=2 "$app_path"
+spctl --assess --type execute --verbose=2 "$app_path" || true
+
+echo "Packaged: $app_path"
+echo "Executable: $product"
+echo "Bundle ID: $bundle_id"
+echo "Signing identity: $resolved_identity"