Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,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
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,35 @@
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.
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
24 changes: 24 additions & 0 deletions Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

public enum PerplexityAPIError: LocalizedError, Sendable, Equatable {
case missingToken
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 .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)"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation

public struct PerplexityCookieOverride: Sendable {
public let name: String
public let token: String

public init(name: String, token: String) {
self.name = name
self.token = token
}
}

public enum PerplexityCookieHeader {
public static let defaultSessionCookieName = "__Secure-next-auth.session-token"
public static let supportedSessionCookieNames = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
"__Secure-authjs.session-token",
"authjs.session-token",
]

public static func resolveCookieOverride(context: ProviderFetchContext) -> PerplexityCookieOverride? {
if let settings = context.settings?.perplexity, settings.cookieSource == .manual {
if let manual = settings.manualCookieHeader, !manual.isEmpty {
return self.override(from: manual)
}
}
return nil
}

public static func override(from raw: String?) -> PerplexityCookieOverride? {
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}

// Accept bare token value
if !raw.contains("=") && !raw.contains(";") {
return PerplexityCookieOverride(name: self.defaultSessionCookieName, token: raw)
}

// Extract a supported session cookie from a full cookie string.
if let cookie = self.extractSessionCookie(from: raw) {
return cookie
}

return nil
}

private static func extractSessionCookie(from raw: String) -> PerplexityCookieOverride? {
let pairs = raw.split(separator: ";")
var cookieMap: [String: (name: String, value: String)] = [:]
for pair in pairs {
let trimmed = pair.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
guard let separator = trimmed.firstIndex(of: "=") else { continue }
let key = String(trimmed[..<separator]).trimmingCharacters(in: .whitespacesAndNewlines)
let value = String(trimmed[trimmed.index(after: separator)...]).trimmingCharacters(
in: .whitespacesAndNewlines)
guard !key.isEmpty, !value.isEmpty else { continue }
cookieMap[key.lowercased()] = (name: key, value: value)
}

for expected in self.supportedSessionCookieNames {
if let match = cookieMap[expected.lowercased()] {
return PerplexityCookieOverride(name: match.name, token: match.value)
}
}
return nil
}
}
Loading