Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions ClaudeUsageWidget/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand All @@ -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)") {
Expand Down Expand Up @@ -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."
Expand Down
14 changes: 14 additions & 0 deletions ClaudeUsageWidgetExtension/ClaudeUsageWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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.

---

Expand All @@ -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
Expand Down
26 changes: 23 additions & 3 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ macOS 桌面小组件(WidgetKit),同时监控你的 Claude、Claude Fable
- **每周用量** + 进度条
- **Fable 独立周额度** + 重置时间
- **Codex 用量** 自动读取本机 ChatGPT 登录,按实际窗口显示
- **单提供方模式** 关掉 Claude 或 Codex,小组件只显示另一个,占满整个宽度,且各尺寸都显示重置时间
- **应用内仪表板** 手动刷新、分别开关 Claude / Codex
- **重置倒计时**
- **颜色随用量变化** 绿 → 黄 → 橙 → 红
Expand Down Expand Up @@ -117,7 +118,7 @@ curl -s https://claude.ai/api/organizations \
}
```

原配置兼容;`claudeEnabled` / `codexEnabled` 可分别开关提供方。保存时保留未知字段、原子写入,并设置文件权限为 `0600`。
原配置兼容。保存时保留未知字段、原子写入,并设置文件权限为 `0600`。

### 3. 添加小组件

Expand All @@ -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 尺寸也能显示每项额度的重置倒计时。*

---

## 工作原理

小组件分别调用两家的用量接口:
Expand All @@ -147,7 +167,7 @@ curl -s https://claude.ai/api/organizations \

百分比表示**已用**额度。缺失数据显示 `—`,不当作 0%;不自行按 50% 换算 Fable 额度。Codex 展示通用订阅额度,不汇总 Spark 等附加额度或 API 账单。

两家请求并发执行、错误独立展示。401 提示更新凭证,403 提示检查登录及访问权限,429 提示等待下一次刷新。中、大尺寸显示重置时间,大尺寸显示更新时间。订阅用量接口可能变化。
两家请求并发执行、错误独立展示;被关掉的提供方不会发出请求。401 提示更新凭证,403 提示检查登录及访问权限,429 提示等待下一次刷新。中、大尺寸显示重置时间,只显示单个提供方时各尺寸都显示重置时间,大尺寸显示更新时间。订阅用量接口可能变化。

---

Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion Shared/UsageModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,24 @@ 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 {
let date: Date
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: [
Expand Down
41 changes: 31 additions & 10 deletions Shared/UsageViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
}
}
}
18 changes: 18 additions & 0 deletions Tests/UsageCoreTests/UsageCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Binary file added screenshots/single-provider-claude-only.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 8 additions & 2 deletions scripts/RenderPreviews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -28,6 +34,6 @@ struct RenderPreviews {
}
}
}
print("Rendered 18 layout previews in \(destination.path)")
print("Rendered \(states.count * 6) layout previews in \(destination.path)")
}
}