Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
- Reduce CPU/energy regressions and JSONL scanner overhead in Codex/web usage paths (#402, #392). Thanks @bald-ai and @asonawalla!

### Providers & Usage
- Perplexity: add provider support with credit tracking for recurring (monthly), bonus (promotional), and purchased on-demand credits; plan detection (Pro/Max); and browser-cookie auto-import with manual-cookie fallback (#449). Thanks @BeelixGit!
- Codex: add historical pace risk forecasting and backfill, gate pace computation by display mode, and handle zero-usage days in historical data (#482, supersedes #438). Thanks @tristanmanchester!
- Kilo: add provider support with source-mode fallback, clearer credential/login guidance, auto top-up activity labeling, zero-balance credit handling, and pass parsing/menu rendering (#454). Thanks @coreh!
- Ollama: add provider support with token-account support in app/CLI, Chrome-default auto cookie import, and manual-cookie mode (#380). Thanks @CryptoSageSnr!
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CodexBar 🎚️ - May your tokens never run out.

Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, and OpenRouter limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, and Perplexity limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.

<img src="codexbar.png" alt="CodexBar menu screenshot" width="520" />

Expand Down
6 changes: 6 additions & 0 deletions Sources/CodexBar/IconView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import SwiftUI

enum IconRemainingResolver {
static func resolvedRemaining(snapshot: UsageSnapshot, style: IconStyle) -> (primary: Double?, secondary: Double?) {
if style == .perplexity {
let windows = snapshot.orderedPerplexityDisplayWindows()
return (
primary: windows.first?.remainingPercent,
secondary: windows.dropFirst().first?.remainingPercent)
}
guard style == .antigravity else {
return (
primary: snapshot.primary?.remainingPercent,
Expand Down
12 changes: 12 additions & 0 deletions Sources/CodexBar/MenuBarMetricWindowResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ enum MenuBarMetricWindowResolver {
guard let snapshot else { return nil }
switch preference {
case .tertiary:
if provider == .perplexity {
return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary
}
guard provider == .cursor else {
if provider == .antigravity {
return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary
Expand All @@ -20,11 +23,17 @@ enum MenuBarMetricWindowResolver {
}
return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary
case .primary:
if provider == .perplexity {
return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary
}
if provider == .antigravity {
return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary
}
return snapshot.primary ?? snapshot.secondary
case .secondary:
if provider == .perplexity {
return snapshot.secondary ?? snapshot.tertiary ?? snapshot.primary
}
if provider == .antigravity {
return snapshot.secondary ?? snapshot.primary ?? snapshot.tertiary
}
Expand All @@ -45,6 +54,9 @@ enum MenuBarMetricWindowResolver {
if provider == .antigravity {
return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary
}
if provider == .perplexity {
return snapshot.automaticPerplexityWindow()
}
if provider == .factory || provider == .kimi {
return snapshot.secondary ?? snapshot.primary
}
Expand Down
13 changes: 12 additions & 1 deletion Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,13 @@ extension UsageMenuCardView.Model {
{
weeklyDetailText = detail
}
// Perplexity bonus credits don't reset; show balance without "Resets" prefix.
if input.provider == .perplexity,
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
!detail.isEmpty
{
weeklyResetText = detail
}
metrics.append(Metric(
id: "secondary",
title: input.metadata.weeklyLabel,
Expand Down Expand Up @@ -1039,12 +1046,16 @@ extension UsageMenuCardView.Model {
{
tertiaryDetailText = detail
}
// Perplexity purchased credits don't reset; show balance without "Resets" prefix.
let opusResetText: String? = input.provider == .perplexity
? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now)
metrics.append(Metric(
id: "tertiary",
title: input.metadata.opusLabel ?? "Sonnet",
percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent),
percentStyle: percentStyle,
resetText: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now),
resetText: opusResetText,
detailText: tertiaryDetailText,
detailLeftText: nil,
detailRightText: nil,
Expand Down
9 changes: 7 additions & 2 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ struct MenuDescriptor {
}
if let weekly = snap.secondary {
let weeklyResetOverride: String? = {
guard provider == .warp || provider == .kilo else { return nil }
guard provider == .warp || provider == .kilo || provider == .perplexity else { return nil }
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let detail, !detail.isEmpty else { return nil }
if provider == .kilo, weekly.resetsAt != nil {
Expand Down Expand Up @@ -172,12 +172,17 @@ struct MenuDescriptor {
}
}
if meta.supportsOpus, let opus = snap.tertiary {
// Perplexity purchased credits don't reset; show the balance as plain text.
let opusResetOverride: String? = provider == .perplexity
? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
: nil
Self.appendRateWindow(
entries: &entries,
title: meta.opusLabel ?? "Sonnet",
window: opus,
resetStyle: resetStyle,
showUsed: settings.usageBarsShowUsed)
showUsed: settings.usageBarsShowUsed,
resetOverride: opusResetOverride)
}

if let cost = snap.providerCost {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import AppKit
import CodexBarCore
import CodexBarMacroSupport
import Foundation
import SwiftUI

@ProviderImplementationRegistration
struct PerplexityProviderImplementation: ProviderImplementation {
let id: UsageProvider = .perplexity
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://www.perplexity.ai/") {
NSWorkspace.shared.open(url)
}
return false
}

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.perplexityCookieSource
_ = settings.perplexityManualCookieHeader
}

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

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

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

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

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "perplexity-cookie",
title: "",
subtitle: "",
kind: .secure,
placeholder: "Cookie: \u{2026}\n\nor paste the __Secure-next-auth.session-token value",
binding: context.stringBinding(\.perplexityManualCookieHeader),
actions: [
ProviderSettingsActionDescriptor(
id: "perplexity-open-usage",
title: "Open Usage Page",
style: .link,
isVisible: nil,
perform: {
if let url = URL(string: "https://www.perplexity.ai/account/usage") {
NSWorkspace.shared.open(url)
}
}),
],
isVisible: { context.settings.perplexityCookieSource == .manual },
onActivate: nil),
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import CodexBarCore
import Foundation

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

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

extension SettingsStore {
func perplexitySettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot
.PerplexityProviderSettings {
// tokenOverride is not used: Perplexity auth is cookie-based, not token-account-based.
// Manual cookies are handled via perplexityManualCookieHeader in the settings snapshot below.
_ = tokenOverride
return ProviderSettingsSnapshot.PerplexityProviderSettings(
cookieSource: self.perplexityCookieSource,
manualCookieHeader: self.perplexityManualCookieHeader)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ enum ProviderImplementationRegistry {
case .synthetic: SyntheticProviderImplementation()
case .openrouter: OpenRouterProviderImplementation()
case .warp: WarpProviderImplementation()
case .perplexity: PerplexityProviderImplementation()
}
}

Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/ProviderIcon-perplexity.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 5 additions & 3 deletions Sources/CodexBar/SettingsStore+MenuPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@ extension SettingsStore {
}

func menuBarMetricSupportsTertiary(for provider: UsageProvider) -> Bool {
provider == .cursor
provider == .cursor || provider == .perplexity
}

func menuBarMetricSupportsTertiary(for provider: UsageProvider, snapshot: UsageSnapshot?) -> Bool {
guard provider == .cursor else { return self.menuBarMetricSupportsTertiary(for: provider) }
return snapshot?.tertiary != nil
if provider == .cursor {
return snapshot?.tertiary != nil
}
return self.menuBarMetricSupportsTertiary(for: provider)
}

func menuBarMetricPreference(for provider: UsageProvider, snapshot: UsageSnapshot?) -> MenuBarMetricPreference {
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1231,7 +1231,7 @@ extension UsageStore {
let source = resolution?.source.rawValue ?? "none"
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
.kimik2, .jetbrains:
.kimik2, .jetbrains, .perplexity:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
Expand Down
13 changes: 11 additions & 2 deletions Sources/CodexBarCLI/TokenAccountCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
case .perplexity:
let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
return self.makeSnapshot(
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
cookieSource: cookieSource,
manualCookieHeader: cookieHeader))
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
return nil
}
Expand All @@ -196,7 +203,8 @@ struct TokenAccountCLIContext {
augment: ProviderSettingsSnapshot.AugmentProviderSettings? = nil,
amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil,
ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil,
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil,
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot.make(
codex: codex,
Expand All @@ -212,7 +220,8 @@ struct TokenAccountCLIContext {
augment: augment,
amp: amp,
ollama: ollama,
jetbrains: jetbrains)
jetbrains: jetbrains,
perplexity: perplexity)
}

func environment(
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBarCore/Logging/LogCategories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ public enum LogCategories {
public static let ollama = "ollama"
public static let opencodeUsage = "opencode-usage"
public static let openRouterUsage = "openrouter-usage"
public static let perplexityAPI = "perplexity-api"
public static let perplexityCookie = "perplexity-cookie"
public static let perplexityWeb = "perplexity-web"
public static let providerDetection = "provider-detection"
public static let providers = "providers"
public static let sessionQuota = "sessionQuota"
Expand Down
27 changes: 27 additions & 0 deletions Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation

public enum PerplexityAPIError: LocalizedError, Sendable, Equatable {
case missingToken
case invalidCookie
case invalidToken
case networkError(String)
case apiError(String)
case parseFailed(String)

public var errorDescription: String? {
switch self {
case .missingToken:
"Perplexity session token is missing. Please log into Perplexity in your browser."
case .invalidCookie:
"Perplexity manual cookie header is empty or invalid."
case .invalidToken:
"Perplexity session token is invalid or expired. Please log in again."
case let .networkError(message):
"Perplexity network error: \(message)"
case let .apiError(message):
"Perplexity API error: \(message)"
case let .parseFailed(message):
"Failed to parse Perplexity usage data: \(message)"
}
}
}
Loading