Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## 0.46.1 — Unreleased

### Added
- Notion AI: add Business and Enterprise workspace allowance tracking for rolling and billing-period windows (#2552). Thanks @n0ah37!
- Notion AI: pace estimates on both the rolling and billing-period bars, scored against the real calendar month ending at the reset rather than a flat 30 days (#2552). Thanks @n0ah37!
- Sync: opt-in iCloud sync (Settings → iCloud Sync, default off) syncs provider configuration, a curated preferences subset, and per-device usage snapshots across Macs via CloudKit; API keys/cookies/tokens ride end-to-end-encrypted fields with their own opt-out, hooks and machine-local paths never sync, and menus can show accounts from other Macs with last-known usage ("via <Mac> · 1h ago") when the local fetch is unavailable. The app now also watches `config.json`, so external CLI edits apply live.
- z.ai: add 7-day and 30-day model-usage chart ranges with dataset-consistent legends, colors, and daily tooltips (#2524). Thanks @LeoLin990405!
- Refresh: add a default-off global Low Power Mode that limits automatic provider, local usage, and storage work to once every 30 minutes while keeping manual refresh immediate (#2518). Thanks @Carl723000!
Expand All @@ -12,6 +14,8 @@
- Menu bar: Session/Weekly/Auto pace layout tokens that render the signed pace delta (`+11%`, `-8%`, `0%`), restoring the pre-0.45 "Both" display in the layout editor (#2540, fixes #2534). Thanks @kratocz!

### Fixed
- Notion AI: `codexbar` now honors the provider's Workspace ID, manual cookie header, and `off` source instead of always auto-selecting a workspace (#2552). Thanks @n0ah37!
- Providers with monthly billing windows (Notion AI, Amp, MiMo, StepFun, Doubao, Alibaba, OpenCode Go): the menu bar's pace token, the "runs out" estimate, and predictive pace warnings now measure the real calendar cycle, matching the card and the CLI instead of scoring every period as a flat 30 days (#2552). Thanks @n0ah37!
- Cursor: make on-demand extra usage follow the shared optional-usage setting and remove the unsupported credits placeholder (#2338). Thanks @Zihao-Qi!
- Antigravity/Sessions: inspect processes in-process via libproc instead of spawning full-system ps/lsof, eliminating repeated macOS 26 “access data from other apps” prompts (#2267 hardening).
- Doubao: show Agent Plan windows alongside Coding Plan usage for Volcengine AK/SK accounts that subscribe to both products (#2517). Thanks @Astro-Han!
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE)
[![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app)

<a href="https://codexbar.app"><img src="docs/social.png" alt="CodexBar — every AI coding limit in your menu bar. 66 providers." width="100%" /></a>
<a href="https://codexbar.app"><img src="docs/social.png" alt="CodexBar — every AI coding limit in your menu bar. 67 providers." width="100%" /></a>

Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, ClinePass, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons.

Expand Down
6 changes: 5 additions & 1 deletion Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,11 @@ extension UsageMenuCardView.Model {
window.remainingPercent > 0
else { return nil }
let paceWindow = Self.resetWindowForPace(provider: input.provider, window: window)
let resolved = pace ?? UsagePace.weekly(
// A caller-supplied pace was measured against the raw window, so reuse it only when resolution
// left the duration alone. Trusting it for a monthly sentinel would score the billing period as
// a flat 30 days and silently undo the calendar-cycle resolution one line above.
let reusablePace = paceWindow.windowMinutes == window.windowMinutes ? pace : nil
let resolved = reusablePace ?? UsagePace.weekly(
window: paceWindow,
now: input.now,
defaultWindowMinutes: 10080,
Expand Down
104 changes: 104 additions & 0 deletions Sources/CodexBar/Providers/Notion/NotionProviderImplementation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import AppKit
import CodexBarCore
import Foundation
import SwiftUI

struct NotionProviderImplementation: ProviderImplementation {
let id: UsageProvider = .notion
let supportsLoginFlow: Bool = true

@MainActor
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
ProviderPresentation { _ in "web" }
}

@MainActor
func runLoginFlow(context _: ProviderLoginContext) async -> Bool {
if let url = URL(string: "https://app.notion.com/") {
NSWorkspace.shared.open(url)
}
return false
}

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.notionCookieSource
_ = settings.notionCookieHeader
_ = settings.notionWorkspaceID
}

@MainActor
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
.notion(context.settings.notionSettingsSnapshot(tokenOverride: context.tokenOverride))
}

@MainActor
func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
let cookieBinding = Binding(
get: { context.settings.notionCookieSource.rawValue },
set: { raw in
context.settings.notionCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
})
let options = ProviderCookieSourceUI.options(
allowsOff: true,
keychainDisabled: context.settings.debugDisableKeychainAccess)

let subtitle: () -> String? = {
ProviderCookieSourceUI.subtitle(
source: context.settings.notionCookieSource,
keychainDisabled: context.settings.debugDisableKeychainAccess,
auto: "Automatically imports the browser session cookie.",
manual: "Paste a full cookie header or the token_v2 value.",
off: "Notion cookies are disabled.")
}

return [
ProviderSettingsPickerDescriptor(
id: "notion-cookie-source",
title: "Cookie source",
subtitle: "Automatically imports the browser session cookie.",
dynamicSubtitle: subtitle,
binding: cookieBinding,
options: options,
isVisible: nil,
onChange: nil),
]
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "notion-cookie",
title: "",
subtitle: "",
kind: .secure,
placeholder: "Cookie: \u{2026}\n\nor paste the token_v2 value",
binding: context.stringBinding(\.notionCookieHeader),
actions: [
ProviderSettingsActionDescriptor(
id: "notion-open-usage",
title: "Open Usage Page",
style: .link,
isVisible: nil,
perform: {
if let url = URL(string: "https://app.notion.com/") {
NSWorkspace.shared.open(url)
}
}),
],
isVisible: { context.settings.notionCookieSource == .manual },
onActivate: nil),
ProviderSettingsFieldDescriptor(
id: "notion-workspace-id",
title: "Workspace ID",
subtitle: "Optional. Defaults to the first Business or Enterprise workspace on the account.",
kind: .plain,
placeholder: "00000000-0000-0000-0000-000000000000",
binding: context.stringBinding(\.notionWorkspaceID),
actions: [],
isVisible: nil,
onActivate: nil),
]
}
}
55 changes: 55 additions & 0 deletions Sources/CodexBar/Providers/Notion/NotionSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import CodexBarCore
import Foundation

extension SettingsStore {
var notionCookieHeader: String {
get { self.configSnapshot.providerConfig(for: .notion)?.sanitizedCookieHeader ?? "" }
set {
self.updateProviderConfig(provider: .notion) { entry in
entry.cookieHeader = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .notion, field: "cookieHeader", value: newValue)
}
}

var notionCookieSource: ProviderCookieSource {
get { self.resolvedCookieSource(provider: .notion, fallback: .auto) }
set {
self.updateProviderConfig(provider: .notion) { entry in
entry.cookieSource = newValue
}
self.logProviderModeChange(provider: .notion, field: "cookieSource", value: newValue.rawValue)
}
}

var notionWorkspaceID: String {
get { self.configSnapshot.providerConfig(for: .notion)?.sanitizedWorkspaceID ?? "" }
set {
self.updateProviderConfig(provider: .notion) { entry in
entry.workspaceID = self.normalizedConfigValue(newValue)
}
}
}
}

extension SettingsStore {
func notionSettingsSnapshot(
tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.NotionProviderSettings
{
// Resolved directly rather than through `resolvedCookieSettings`, whose generic construction
// can only carry the two cookie fields and would drop the workspace override.
let resolved = ProviderCookieSettingsResolver.resolve(
provider: .notion,
configuredSource: self.notionCookieSource,
configuredHeader: self.notionCookieHeader,
selectedAccount: ProviderTokenAccountSelection.selectedAccount(
provider: .notion,
settings: self,
override: tokenOverride))
let workspaceID = self.notionWorkspaceID
return ProviderSettingsSnapshot.NotionProviderSettings(
cookieSource: resolved.cookieSource,
manualCookieHeader: resolved.manualCookieHeader,
workspaceID: workspaceID.isEmpty ? nil : workspaceID)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,6 @@ enum ProviderImplementationManifest {
{ AiAndProviderImplementation() },
{ ZoomMateProviderImplementation() },
{ XAIProviderImplementation() },
{ NotionProviderImplementation() },
]
}
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/ProviderIcon-notion.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions Sources/CodexBar/ShareStatsPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ struct ShareStatsSubscriptionName: Sendable, Equatable {
"starter": "Starter", "pro": "Pro", "team": "Team", "enterprise": "Enterprise",
],
UsageProvider.t3chat.rawValue: ["free": "Free", "pro": "Pro", "team": "Team"],
UsageProvider.notion.rawValue: [
"free": "Free", "plus": "Plus", "business": "Business", "enterprise": "Enterprise",
],
UsageProvider.sub2api.rawValue: [
"free": "Free", "pro": "Pro", "team": "Team", "claude team": "Team",
"enterprise": "Enterprise", "wallet plan": "Wallet",
Expand Down
10 changes: 9 additions & 1 deletion Sources/CodexBar/UsagePaceText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ enum UsagePaceText {

static func sessionPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? {
guard provider == .codex || provider == .claude || provider == .ollama || provider == .antigravity ||
provider == .kimi
provider == .kimi || provider == .notion
else { return nil }
if provider == .ollama, window.windowMinutes == nil {
return nil
Expand All @@ -167,6 +167,14 @@ enum UsagePaceText {
if provider == .kimi, window.windowMinutes != KimiProviderDescriptor.sessionWindowMinutes {
return nil
}
if provider == .notion {
// Notion parses its rolling length from an API token (`6h`), so the shape is not guaranteed.
// Only a real rolling allowance may be paced here; anything longer is a billing period and
// belongs on the descriptor's reset-window pace instead.
guard let minutes = window.windowMinutes,
minutes <= NotionProviderDescriptor.rollingWindowMaxMinutes
else { return nil }
}
guard window.remainingPercent > 0 else { return nil }
guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300) else { return nil }
guard pace.expectedUsedPercent >= 3 else { return nil }
Expand Down
9 changes: 8 additions & 1 deletion Sources/CodexBar/UsageStore+HistoricalPace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ extension UsageStore {
// windows without windowMinutes would fabricate a weekly pace for non-weekly windows
// (e.g. Factory monthly with only resetsAt).
guard window.windowMinutes != nil else { return nil }
resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080, workDays: workDays)
// Expand a monthly sentinel to the real calendar cycle before scoring. The menu card and the
// CLI both resolve first, so skipping it here would score a billing period as a flat 30 days
// and disagree with them — and a 31-day cycle would exceed the sentinel outright, dropping the
// pace for the first day of every long month.
let paceWindow = ProviderDescriptorRegistry.descriptor(for: provider)
.pace
.resolvedResetWindowForPace(window)
resolved = UsagePace.weekly(window: paceWindow, now: now, defaultWindowMinutes: 10080, workDays: workDays)
}

guard let resolved else { return nil }
Expand Down
21 changes: 21 additions & 0 deletions Sources/CodexBar/UsageStore+NotionDebug.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import CodexBarCore
import Foundation

extension UsageStore {
static func debugNotionLog(
browserDetection: BrowserDetection,
notionCookieSource: ProviderCookieSource,
notionCookieHeader: String,
notionWorkspaceID: String) async -> String
{
await runWithTimeout(seconds: 15) {
let fetcher = NotionUsageFetcher(browserDetection: browserDetection)
let manualHeader = notionCookieSource == .manual
? CookieHeaderNormalizer.normalize(notionCookieHeader)
: nil
return await fetcher.debugRawProbe(
cookieHeaderOverride: manualHeader,
preferredSpaceID: notionWorkspaceID.isEmpty ? nil : notionWorkspaceID)
}
}
}
5 changes: 5 additions & 0 deletions Sources/CodexBar/UsageStore+WidgetSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import WidgetKit

extension UsageStore {
func persistWidgetSnapshot(reason: String) {
#if DEBUG
// Unsigned test processes must not cross into the real app-group container. Snapshot tests
// opt in with an in-memory override, which also keeps their assertions deterministic.
guard !SettingsStore.isRunningTests || self._test_widgetSnapshotSaveOverride != nil else { return }
#endif
// A fresh process has token-cost data before a user-authorized Claude OAuth refresh can run.
// Keep the last queued snapshot in memory so back-to-back writes cannot race the on-disk cache.
let previousSnapshot = self.lastQueuedWidgetSnapshot ?? {
Expand Down
17 changes: 13 additions & 4 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,9 @@ extension UsageStore {
let ampCookieHeader = self.settings.ampCookieHeader
let ollamaCookieSource = self.settings.ollamaCookieSource
let ollamaCookieHeader = self.settings.ollamaCookieHeader
let notionCookieSource = self.settings.notionCookieSource
let notionCookieHeader = self.settings.notionCookieHeader
let notionWorkspaceID = self.settings.notionWorkspaceID
let processEnvironment = self.environmentBase
let openAIDebugContext = self.openAIAPIKeyDebugContext(processEnvironment: processEnvironment)
let azureOpenAIDebugContext = self.azureOpenAIAPIKeyDebugContext(processEnvironment: processEnvironment)
Expand Down Expand Up @@ -1070,10 +1073,10 @@ extension UsageStore {
switch provider {
case .codex:
return await codexFetcher.debugRawRateLimits()
case .openai:
return Self.apiKeyDebugLine(openAIDebugContext)
case .azureopenai:
return Self.apiKeyDebugLine(azureOpenAIDebugContext)
// Folded into one case: both read the same helper, and keeping them apart pushed this
// switch past the cyclomatic-complexity cap when the Notion case was added.
case .openai, .azureopenai:
return Self.apiKeyDebugLine(provider == .openai ? openAIDebugContext : azureOpenAIDebugContext)
case .claude:
guard let claudeDebugConfiguration else {
return "Claude debug log configuration unavailable"
Expand Down Expand Up @@ -1123,6 +1126,12 @@ extension UsageStore {
browserDetection: browserDetection,
ollamaCookieSource: ollamaCookieSource,
ollamaCookieHeader: ollamaCookieHeader)
case .notion:
return await Self.debugNotionLog(
browserDetection: browserDetection,
notionCookieSource: notionCookieSource,
notionCookieHeader: notionCookieHeader,
notionWorkspaceID: notionWorkspaceID)
case .openrouter:
return Self.apiKeyDebugLine(openRouterDebugContext)
case .elevenlabs:
Expand Down
7 changes: 5 additions & 2 deletions Sources/CodexBarCLI/CLIRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,8 @@ enum CLIRenderer {
func supportsStandardPace(provider: UsageProvider) -> Bool {
switch self {
case .session:
provider == .codex || provider == .claude || provider == .ollama || provider == .kimi
provider == .codex || provider == .claude || provider == .ollama || provider == .kimi ||
provider == .notion
case .weekly:
provider == .codex || provider == .claude || provider == .opencode || provider == .ollama ||
provider == .kimi
Expand Down Expand Up @@ -1222,7 +1223,9 @@ enum CLIRenderer {
guard supportsWindow else { return nil }
}
// Only pace a real session window here; Claude w/o 5-hour data falls a 7-day window into primary.
if case .session = resolvedKind, let minutes = paceWindow.windowMinutes, minutes > 300 {
// Notion's rolling allowance is a 6-hour window, so it needs the wider ceiling to match the card.
let sessionCeilingMinutes = provider == .notion ? NotionProviderDescriptor.rollingWindowMaxMinutes : 300
if case .session = resolvedKind, let minutes = paceWindow.windowMinutes, minutes > sessionCeilingMinutes {
return nil
}
if provider == .ollama, paceWindow.windowMinutes == nil {
Expand Down
Loading