diff --git a/ClaudeUsageWidget/ContentView.swift b/ClaudeUsageWidget/ContentView.swift index f681a05..093b739 100644 --- a/ClaudeUsageWidget/ContentView.swift +++ b/ClaudeUsageWidget/ContentView.swift @@ -32,10 +32,18 @@ struct ContentView: View { } if let snapshot { + // Mirror the widget: only the providers you turned on are shown. HStack(alignment: .top, spacing: 20) { - ProviderUsageView(usage: snapshot.claude) - Divider() - ProviderUsageView(usage: snapshot.codex) + if snapshot.visibleProviders.isEmpty { + Text("Both providers are hidden. Turn one on below to fill the widget.") + .font(.caption).foregroundStyle(.secondary) + Spacer() + } else { + ForEach(snapshot.visibleProviders) { usage in + if usage.id != snapshot.visibleProviders.first?.id { Divider() } + ProviderUsageView(usage: usage) + } + } } .padding(14) .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) @@ -46,6 +54,8 @@ struct ContentView: View { GroupBox { VStack(alignment: .leading, spacing: 10) { Toggle("Show Claude and Fable", isOn: $claudeEnabled) + Text("Turn this off to make the widget a Codex-only widget; Claude then takes no space at all.") + .font(.caption).foregroundStyle(.secondary) Text("Fable's weekly limit is read with your Claude usage; no extra token is needed.") .font(.caption).foregroundStyle(.secondary) SecureField("Claude OAuth Bearer Token (preferred)", text: $oauthToken) @@ -60,6 +70,8 @@ struct ContentView: View { GroupBox { VStack(alignment: .leading, spacing: 10) { Toggle("Show Codex", isOn: $codexEnabled) + Text("Turn this off if you do not use Codex; the widget then shows Claude alone, with reset times in every size.") + .font(.caption).foregroundStyle(.secondary) Text("Automatically reads the current ChatGPT login from ~/.codex/auth.json. Sign in with Codex CLI first. API keys do not provide subscription usage.") .font(.caption).foregroundStyle(.secondary) DisclosureGroup("Manual token (overrides automatic login)") { @@ -105,6 +117,11 @@ struct ContentView: View { } private func saveConfig() { + guard claudeEnabled || codexEnabled else { + statusMessage = "Turn on Claude or Codex — the widget needs at least one provider." + isSuccess = false + return + } if claudeEnabled, sessionKey.nonempty != nil, UUID(uuidString: organizationId.trimmingCharacters(in: .whitespacesAndNewlines)) == nil { statusMessage = "Enter a valid Claude organization UUID for the session key." diff --git a/ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift b/ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift index aebf87e..ec51b8e 100644 --- a/ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift +++ b/ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift @@ -77,3 +77,17 @@ struct ClaudeUsageWidget: Widget { } timeline: { ClaudeUsageEntry.placeholder } + +#Preview("Claude only, medium", as: .systemMedium) { + ClaudeUsageWidget() +} timeline: { + ClaudeUsageEntry(snapshot: UsageSnapshot(date: Date(), claude: UsageSnapshot.preview.claude, + codex: ProviderUsage(name: "Codex", isEnabled: false))) +} + +#Preview("Codex only, medium", as: .systemMedium) { + ClaudeUsageWidget() +} timeline: { + ClaudeUsageEntry(snapshot: UsageSnapshot(date: Date(), claude: ProviderUsage(name: "Claude", isEnabled: false), + codex: UsageSnapshot.preview.codex)) +} diff --git a/README.md b/README.md index 2bc70c4..80558f0 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ A macOS desktop widget (WidgetKit) that monitors Claude, Claude Fable, and Codex - **Weekly usage** with progress bar - **Separate Fable weekly usage** with reset time - **Codex usage** from your local ChatGPT login, labelled by actual window duration +- **Single-provider mode** — turn off Claude or Codex and the widget shows only the other one, full width, with reset times in every size - **In-app dashboard** with manual refresh and independent provider switches - **Reset countdown** for both windows - **Color-coded** green → yellow → orange → red @@ -117,7 +118,7 @@ For Keychain-only credentials, a custom `CODEX_HOME`, or another location, enter } ``` -Existing configuration remains compatible. `claudeEnabled` / `codexEnabled` toggle each provider. Saves preserve unknown fields, replace the file atomically, and set owner-only permissions (`0600`). +Existing configuration remains compatible. Saves preserve unknown fields, replace the file atomically, and set owner-only permissions (`0600`). ### 3. Add Widget @@ -127,6 +128,25 @@ Existing configuration remains compatible. `claudeEnabled` / `codexEnabled` togg --- +## Show Only Claude or Only Codex + +If you subscribe to just one of them, hide the other. Use **Show Claude and Fable** / **Show Codex** in the app, or set `claudeEnabled` / `codexEnabled` in the config file: + +```json +{ + "oauthToken": "your-oauth-bearer-token", + "codexEnabled": false +} +``` + +A provider that is off disappears completely — no "Disabled" placeholder, no divider, and no request to its endpoint. The remaining provider takes the full widget width and shows reset times in every widget size, including small. At least one provider must stay on; the app refuses to save with both off. + +![Claude-only widget](screenshots/single-provider-claude-only.png) + +*Codex turned off — Claude takes the full widget and shows reset countdowns for every metric, even in the small size.* + +--- + ## How It Works The widget calls each provider's usage endpoint: @@ -147,7 +167,7 @@ Returns: Percentages represent **usage consumed**. Missing data shows `—`, not 0%; Fable percentages are not rescaled by an inferred 50% allowance. Codex shows the general subscription pool, not additional pools such as Spark or API billing. -Requests run concurrently with independent errors. HTTP 401 prompts credential renewal, 403 prompts checking login and access, and 429 prompts waiting until the next refresh. Medium and large widgets include reset times; large includes the update timestamp. Subscription usage endpoints may change. +Requests run concurrently with independent errors. A disabled provider is never requested. HTTP 401 prompts credential renewal, 403 prompts checking login and access, and 429 prompts waiting until the next refresh. Medium and large widgets include reset times, as does any size showing a single provider; large includes the update timestamp. Subscription usage endpoints may change. --- @@ -174,7 +194,7 @@ ClaudeUsageWidget/ swift test ./scripts/build-local.sh -# 18 SwiftUI previews: three sizes × light/dark × normal/error/missing data +# 30 SwiftUI previews: three sizes × light/dark × normal/error/missing/claude-only/codex-only mkdir -p build swiftc Shared/UsageModels.swift Shared/UsageViews.swift scripts/RenderPreviews.swift -o build/render-previews build/render-previews diff --git a/README_CN.md b/README_CN.md index ceff901..d4aac5a 100644 --- a/README_CN.md +++ b/README_CN.md @@ -22,6 +22,7 @@ macOS 桌面小组件(WidgetKit),同时监控你的 Claude、Claude Fable - **每周用量** + 进度条 - **Fable 独立周额度** + 重置时间 - **Codex 用量** 自动读取本机 ChatGPT 登录,按实际窗口显示 +- **单提供方模式** 关掉 Claude 或 Codex,小组件只显示另一个,占满整个宽度,且各尺寸都显示重置时间 - **应用内仪表板** 手动刷新、分别开关 Claude / Codex - **重置倒计时** - **颜色随用量变化** 绿 → 黄 → 橙 → 红 @@ -117,7 +118,7 @@ curl -s https://claude.ai/api/organizations \ } ``` -原配置兼容;`claudeEnabled` / `codexEnabled` 可分别开关提供方。保存时保留未知字段、原子写入,并设置文件权限为 `0600`。 +原配置兼容。保存时保留未知字段、原子写入,并设置文件权限为 `0600`。 ### 3. 添加小组件 @@ -127,6 +128,25 @@ curl -s https://claude.ai/api/organizations \ --- +## 只显示 Claude 或只显示 Codex + +只订阅其中一家时,把另一家关掉即可。在应用里使用 **Show Claude and Fable** / **Show Codex** 开关,或在配置文件里设置 `claudeEnabled` / `codexEnabled`: + +```json +{ + "oauthToken": "your-oauth-bearer-token", + "codexEnabled": false +} +``` + +被关掉的提供方会彻底消失:不再显示 "Disabled" 占位文字,不再显示分隔线,也不会再请求它的接口。剩下的那一个占满整个小组件宽度,并在包括 small 在内的所有尺寸上显示重置时间。至少要保留一个提供方,两个都关时应用会拒绝保存。 + +![只显示 Claude 的小组件](screenshots/single-provider-claude-only.png) + +*关掉 Codex 后,Claude 独占整个小组件宽度,即使是 small 尺寸也能显示每项额度的重置倒计时。* + +--- + ## 工作原理 小组件分别调用两家的用量接口: @@ -147,7 +167,7 @@ curl -s https://claude.ai/api/organizations \ 百分比表示**已用**额度。缺失数据显示 `—`,不当作 0%;不自行按 50% 换算 Fable 额度。Codex 展示通用订阅额度,不汇总 Spark 等附加额度或 API 账单。 -两家请求并发执行、错误独立展示。401 提示更新凭证,403 提示检查登录及访问权限,429 提示等待下一次刷新。中、大尺寸显示重置时间,大尺寸显示更新时间。订阅用量接口可能变化。 +两家请求并发执行、错误独立展示;被关掉的提供方不会发出请求。401 提示更新凭证,403 提示检查登录及访问权限,429 提示等待下一次刷新。中、大尺寸显示重置时间,只显示单个提供方时各尺寸都显示重置时间,大尺寸显示更新时间。订阅用量接口可能变化。 --- @@ -174,7 +194,7 @@ ClaudeUsageWidget/ swift test ./scripts/build-local.sh -# 生成三种尺寸、深浅色、正常/失败/缺失数据的 18 张 SwiftUI 预览 +# 生成三种尺寸、深浅色、正常/失败/缺失/仅 Claude/仅 Codex 的 30 张 SwiftUI 预览 mkdir -p build swiftc Shared/UsageModels.swift Shared/UsageViews.swift scripts/RenderPreviews.swift -o build/render-previews build/render-previews diff --git a/Shared/UsageModels.swift b/Shared/UsageModels.swift index 0352604..f7a0028 100644 --- a/Shared/UsageModels.swift +++ b/Shared/UsageModels.swift @@ -69,11 +69,13 @@ struct UsageMetric: Identifiable, Sendable { } } -struct ProviderUsage: Sendable { +struct ProviderUsage: Identifiable, Sendable { let name: String var metrics: [UsageMetric] = [] var error: String? var isEnabled = true + + var id: String { name } } struct UsageSnapshot: Sendable { @@ -81,6 +83,10 @@ struct UsageSnapshot: Sendable { let claude: ProviderUsage let codex: ProviderUsage + /// Providers the user asked to see. A disabled provider is hidden rather than + /// spending widget space on a "Disabled" placeholder. + var visibleProviders: [ProviderUsage] { [claude, codex].filter(\.isEnabled) } + static var preview: UsageSnapshot { let now = Date() return UsageSnapshot(date: now, claude: ProviderUsage(name: "Claude", metrics: [ diff --git a/Shared/UsageViews.swift b/Shared/UsageViews.swift index b0e22d9..4c9d454 100644 --- a/Shared/UsageViews.swift +++ b/Shared/UsageViews.swift @@ -83,9 +83,7 @@ struct ProviderUsageView: View { Text(usage.name).font(.system(size: compact ? (showReset ? 12 : 11) : 14, weight: .bold)) Spacer(minLength: 0) } - if !usage.isEnabled { - Text("Disabled").font(.caption).foregroundStyle(.secondary) - } else if let error = usage.error { + if let error = usage.error { Text(error) .font(.system(size: compact ? 10 : 12)) .foregroundStyle(.secondary) @@ -105,6 +103,10 @@ struct UsageDashboardView: View { var small = false var large = false + private var providers: [ProviderUsage] { snapshot.visibleProviders } + /// One provider owns the whole widget, so reset times fit in every family. + private var isSolo: Bool { providers.count == 1 } + var body: some View { VStack(alignment: .leading, spacing: small ? 3 : 8) { if large { @@ -114,15 +116,19 @@ struct UsageDashboardView: View { Text("Used").font(.caption).foregroundStyle(.secondary) } } - if small || large { - ProviderUsageView(usage: snapshot.claude, compact: !large, showReset: large) - Divider() - ProviderUsageView(usage: snapshot.codex, compact: !large, showReset: large) + if providers.isEmpty { + Text("Turn on Claude or Codex in the app.") + .font(.system(size: small ? 10 : 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } else if small || large || isSolo { + stacked } else { + // Medium with both providers: two columns, each still showing its reset + // countdowns — the widget is wide enough and this is the long-standing layout. HStack(alignment: .top, spacing: 14) { - ProviderUsageView(usage: snapshot.claude, compact: true) - Divider() - ProviderUsageView(usage: snapshot.codex, compact: true) + dividedProviders(compact: true, showReset: true) } } if large { @@ -137,4 +143,19 @@ struct UsageDashboardView: View { } } } + + private var stacked: some View { + VStack(alignment: .leading, spacing: small ? 3 : 8) { + dividedProviders(compact: !large, showReset: large || isSolo) + if isSolo && !large { Spacer(minLength: 0) } + } + } + + @ViewBuilder + private func dividedProviders(compact: Bool, showReset: Bool) -> some View { + ForEach(providers) { usage in + if usage.id != providers.first?.id { Divider() } + ProviderUsageView(usage: usage, compact: compact, showReset: showReset) + } + } } diff --git a/Tests/UsageCoreTests/UsageCoreTests.swift b/Tests/UsageCoreTests/UsageCoreTests.swift index 49006c9..2597a6b 100644 --- a/Tests/UsageCoreTests/UsageCoreTests.swift +++ b/Tests/UsageCoreTests/UsageCoreTests.swift @@ -94,6 +94,24 @@ final class UsageCoreTests: XCTestCase { XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) } + func testDisabledProviderIsHiddenInsteadOfShownAsDisabled() { + let both = UsageSnapshot.preview + XCTAssertEqual(both.visibleProviders.map(\.name), ["Claude", "Codex"]) + let claudeOnly = UsageSnapshot(date: Date(), claude: both.claude, + codex: ProviderUsage(name: "Codex", isEnabled: false)) + XCTAssertEqual(claudeOnly.visibleProviders.map(\.name), ["Claude"]) + let codexOnly = UsageSnapshot(date: Date(), claude: ProviderUsage(name: "Claude", isEnabled: false), + codex: both.codex) + XCTAssertEqual(codexOnly.visibleProviders.map(\.name), ["Codex"]) + let neither = UsageSnapshot(date: Date(), claude: ProviderUsage(name: "Claude", isEnabled: false), + codex: ProviderUsage(name: "Codex", isEnabled: false)) + XCTAssertTrue(neither.visibleProviders.isEmpty) + // An errored provider stays visible so the reason still reaches the widget. + let failed = UsageSnapshot(date: Date(), claude: ProviderUsage(name: "Claude", error: "boom"), + codex: ProviderUsage(name: "Codex", isEnabled: false)) + XCTAssertEqual(failed.visibleProviders.map(\.name), ["Claude"]) + } + func testInvalidConfigIsNotSilentlyOverwritten() throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: url) } diff --git a/screenshots/single-provider-claude-only.png b/screenshots/single-provider-claude-only.png new file mode 100644 index 0000000..1d311b4 Binary files /dev/null and b/screenshots/single-provider-claude-only.png differ diff --git a/scripts/RenderPreviews.swift b/scripts/RenderPreviews.swift index 5cc092e..b2c27eb 100644 --- a/scripts/RenderPreviews.swift +++ b/scripts/RenderPreviews.swift @@ -11,7 +11,13 @@ struct RenderPreviews { codex: UsageSnapshot.preview.codex) let missing = UsageSnapshot(date: Date(), claude: try UsageParser.claude(Data("{\"five_hour\":{\"utilization\":0},\"seven_day\":{\"utilization\":12}}".utf8)), codex: try UsageParser.codex(Data("{\"rate_limit\":{\"primary_window\":{\"used_percent\":40,\"limit_window_seconds\":604800}}}".utf8))) - for (state, snapshot) in [("normal", UsageSnapshot.preview), ("error", error), ("missing", missing)] { + let claudeOnly = UsageSnapshot(date: Date(), claude: UsageSnapshot.preview.claude, + codex: ProviderUsage(name: "Codex", isEnabled: false)) + let codexOnly = UsageSnapshot(date: Date(), claude: ProviderUsage(name: "Claude", isEnabled: false), + codex: UsageSnapshot.preview.codex) + let states = [("normal", UsageSnapshot.preview), ("error", error), ("missing", missing), + ("claude-only", claudeOnly), ("codex-only", codexOnly)] + for (state, snapshot) in states { for (name, width, height) in [("small", 158.0, 158.0), ("medium", 338.0, 158.0), ("large", 338.0, 354.0)] { for scheme in [ColorScheme.dark, .light] { let view = UsageDashboardView(snapshot: snapshot, small: name == "small", large: name == "large") @@ -28,6 +34,6 @@ struct RenderPreviews { } } } - print("Rendered 18 layout previews in \(destination.path)") + print("Rendered \(states.count * 6) layout previews in \(destination.path)") } }