diff --git a/README.md b/README.md
index 757fbf030..449cab65b 100644
--- a/README.md
+++ b/README.md
@@ -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, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); 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, Windsurf, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
@@ -36,6 +36,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
- [Cursor](docs/cursor.md) β Browser session cookies for plan + usage + billing resets.
- [Gemini](docs/gemini.md) β OAuth-backed quota API using Gemini CLI credentials (no browser cookies).
- [Antigravity](docs/antigravity.md) β Local language server probe (experimental); no external auth.
+- [Windsurf](docs/windsurf.md) β Local Windsurf plan cache (state.vscdb); messages/actions/credits.
- [Droid](docs/factory.md) β Browser cookies + WorkOS token flows for Factory usage + billing.
- [Copilot](docs/copilot.md) β GitHub device flow + Copilot internal usage API.
- [z.ai](docs/zai.md) β API token (Keychain) for quota + MCP windows.
diff --git a/Sources/CodexBar/IconRenderer.swift b/Sources/CodexBar/IconRenderer.swift
index 648a3fc5f..84cc0cd10 100644
--- a/Sources/CodexBar/IconRenderer.swift
+++ b/Sources/CodexBar/IconRenderer.swift
@@ -662,51 +662,39 @@ enum IconRenderer {
remaining: topValue,
addNotches: style == .claude,
addFace: style == .codex,
- addGeminiTwist: style == .gemini || style == .antigravity,
- addAntigravityTwist: style == .antigravity,
+ addGeminiTwist: style == .gemini || style == .antigravity || style == .windsurf,
+ addAntigravityTwist: style == .antigravity || style == .windsurf,
addFactoryTwist: style == .factory,
addWarpTwist: style == .warp,
blink: blink)
drawBar(rectPx: bottomRectPx, remaining: bottomValue)
- } else if !hasWeekly || warpNoBonus {
- if style == .warp {
- // Warp: no bonus or bonus exhausted -> top=monthly credits, bottom=dimmed track
+ } else if !hasWeekly {
+ // Weekly missing (e.g. Claude enterprise): keep normal layout but
+ // dim the bottom track to indicate N/A.
+ if topValue == nil, let ratio = creditsRatio {
+ // Credits-only: show credits prominently (e.g. credits loaded before usage).
+ drawBar(
+ rectPx: creditsRectPx,
+ remaining: ratio,
+ alpha: creditsAlpha,
+ addNotches: style == .claude,
+ addFace: style == .codex,
+ addGeminiTwist: style == .gemini || style == .antigravity,
+ addAntigravityTwist: style == .antigravity,
+ addFactoryTwist: style == .factory,
+ blink: blink)
+ drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45)
+ } else {
drawBar(
rectPx: topRectPx,
remaining: topValue,
- addWarpTwist: true,
+ addNotches: style == .claude,
+ addFace: style == .codex,
+ addGeminiTwist: style == .gemini || style == .antigravity,
+ addAntigravityTwist: style == .antigravity,
+ addFactoryTwist: style == .factory,
blink: blink)
drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45)
- } else {
- // Weekly missing (e.g. Claude enterprise): keep normal layout but
- // dim the bottom track to indicate N/A.
- if topValue == nil, let ratio = creditsRatio {
- // Credits-only: show credits prominently (e.g. credits loaded before usage).
- drawBar(
- rectPx: creditsRectPx,
- remaining: ratio,
- alpha: creditsAlpha,
- addNotches: style == .claude,
- addFace: style == .codex,
- addGeminiTwist: style == .gemini || style == .antigravity,
- addAntigravityTwist: style == .antigravity,
- addFactoryTwist: style == .factory,
- addWarpTwist: style == .warp,
- blink: blink)
- drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45)
- } else {
- drawBar(
- rectPx: topRectPx,
- remaining: topValue,
- addNotches: style == .claude,
- addFace: style == .codex,
- addGeminiTwist: style == .gemini || style == .antigravity,
- addAntigravityTwist: style == .antigravity,
- addFactoryTwist: style == .factory,
- addWarpTwist: style == .warp,
- blink: blink)
- drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45)
- }
}
} else {
// Weekly exhausted/missing: show credits on top (thicker), weekly (likely 0) on bottom.
@@ -717,8 +705,8 @@ enum IconRenderer {
alpha: creditsAlpha,
addNotches: style == .claude,
addFace: style == .codex,
- addGeminiTwist: style == .gemini || style == .antigravity,
- addAntigravityTwist: style == .antigravity,
+ addGeminiTwist: style == .gemini || style == .antigravity || style == .windsurf,
+ addAntigravityTwist: style == .antigravity || style == .windsurf,
addFactoryTwist: style == .factory,
addWarpTwist: style == .warp,
blink: blink)
@@ -729,8 +717,8 @@ enum IconRenderer {
remaining: topValue,
addNotches: style == .claude,
addFace: style == .codex,
- addGeminiTwist: style == .gemini || style == .antigravity,
- addAntigravityTwist: style == .antigravity,
+ addGeminiTwist: style == .gemini || style == .antigravity || style == .windsurf,
+ addAntigravityTwist: style == .antigravity || style == .windsurf,
addFactoryTwist: style == .factory,
addWarpTwist: style == .warp,
blink: blink)
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index fef37cef9..d95d8d6f9 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -19,6 +19,7 @@ enum ProviderImplementationRegistry {
case .factory: FactoryProviderImplementation()
case .gemini: GeminiProviderImplementation()
case .antigravity: AntigravityProviderImplementation()
+ case .windsurf: WindsurfProviderImplementation()
case .copilot: CopilotProviderImplementation()
case .zai: ZaiProviderImplementation()
case .minimax: MiniMaxProviderImplementation()
diff --git a/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift
new file mode 100644
index 000000000..763e72a6e
--- /dev/null
+++ b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift
@@ -0,0 +1,8 @@
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct WindsurfProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .windsurf
+}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg b/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg
new file mode 100644
index 000000000..3bc424679
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg
@@ -0,0 +1,3 @@
+
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index a763642dd..c3d916696 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -1150,6 +1150,8 @@ extension UsageStore {
let keepCLISessionsAlive = self.settings.debugKeepCLISessionsAlive
let cursorCookieSource = self.settings.cursorCookieSource
let cursorCookieHeader = self.settings.cursorCookieHeader
+ let ampCookieSource = self.settings.ampCookieSource
+ let ampCookieHeader = self.settings.ampCookieHeader
return await Task.detached(priority: .utility) { () -> String in
switch provider {
case .codex:
@@ -1179,32 +1181,12 @@ extension UsageStore {
let text = "SYNTHETIC_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
await MainActor.run { self.probeLogs[.synthetic] = text }
return text
- case .gemini:
- let text = "Gemini debug log not yet implemented"
- await MainActor.run { self.probeLogs[.gemini] = text }
- return text
- case .antigravity:
- let text = "Antigravity debug log not yet implemented"
- await MainActor.run { self.probeLogs[.antigravity] = text }
- return text
case .cursor:
let text = await self.debugCursorLog(
cursorCookieSource: cursorCookieSource,
cursorCookieHeader: cursorCookieHeader)
await MainActor.run { self.probeLogs[.cursor] = text }
return text
- case .opencode:
- let text = "OpenCode debug log not yet implemented"
- await MainActor.run { self.probeLogs[.opencode] = text }
- return text
- case .factory:
- let text = "Droid debug log not yet implemented"
- await MainActor.run { self.probeLogs[.factory] = text }
- return text
- case .copilot:
- let text = "Copilot debug log not yet implemented"
- await MainActor.run { self.probeLogs[.copilot] = text }
- return text
case .minimax:
let tokenResolution = ProviderTokenResolver.minimaxTokenResolution()
let cookieResolution = ProviderTokenResolver.minimaxCookieResolution()
@@ -1215,36 +1197,16 @@ extension UsageStore {
"source=\(cookieSource)"
await MainActor.run { self.probeLogs[.minimax] = text }
return text
- case .vertexai:
- let text = "Vertex AI debug log not yet implemented"
- await MainActor.run { self.probeLogs[.vertexai] = text }
- return text
- case .kiro:
- let text = "Kiro debug log not yet implemented"
- await MainActor.run { self.probeLogs[.kiro] = text }
- return text
case .augment:
let text = await self.debugAugmentLog()
await MainActor.run { self.probeLogs[.augment] = text }
return text
- case .kimi:
- let text = "Kimi debug log not yet implemented"
- await MainActor.run { self.probeLogs[.kimi] = text }
- return text
- case .kimik2:
- let text = "Kimi K2 debug log not yet implemented"
- await MainActor.run { self.probeLogs[.kimik2] = text }
- return text
case .amp:
let text = await self.debugAmpLog(
- ampCookieSource: self.settings.ampCookieSource,
- ampCookieHeader: self.settings.ampCookieHeader)
+ ampCookieSource: ampCookieSource,
+ ampCookieHeader: ampCookieHeader)
await MainActor.run { self.probeLogs[.amp] = text }
return text
- case .jetbrains:
- let text = "JetBrains AI debug log not yet implemented"
- await MainActor.run { self.probeLogs[.jetbrains] = text }
- return text
case .warp:
let resolution = ProviderTokenResolver.warpResolution()
let hasAny = resolution != nil
@@ -1252,10 +1214,44 @@ extension UsageStore {
let text = "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
await MainActor.run { self.probeLogs[.warp] = text }
return text
+ case .gemini, .antigravity, .windsurf, .opencode, .factory, .copilot, .vertexai, .kiro, .kimi, .kimik2,
+ .jetbrains:
+ let text = Self.notImplementedDebugLogText(for: provider)
+ await MainActor.run { self.probeLogs[provider] = text }
+ return text
}
}.value
}
+ private nonisolated static func notImplementedDebugLogText(for provider: UsageProvider) -> String {
+ switch provider {
+ case .gemini:
+ "Gemini debug log not yet implemented"
+ case .antigravity:
+ "Antigravity debug log not yet implemented"
+ case .windsurf:
+ "Windsurf debug log not yet implemented"
+ case .opencode:
+ "OpenCode debug log not yet implemented"
+ case .factory:
+ "Droid debug log not yet implemented"
+ case .copilot:
+ "Copilot debug log not yet implemented"
+ case .vertexai:
+ "Vertex AI debug log not yet implemented"
+ case .kiro:
+ "Kiro debug log not yet implemented"
+ case .kimi:
+ "Kimi debug log not yet implemented"
+ case .kimik2:
+ "Kimi K2 debug log not yet implemented"
+ case .jetbrains:
+ "JetBrains AI debug log not yet implemented"
+ case .codex, .claude, .cursor, .zai, .minimax, .augment, .amp, .synthetic, .warp:
+ "Debug log not yet implemented"
+ }
+ }
+
private func debugClaudeLog(
claudeWebExtrasEnabled: Bool,
claudeUsageDataSource: ClaudeUsageDataSource,
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index 1b0bc5c8a..864f6f78a 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -147,7 +147,7 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
- case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .warp:
+ case .gemini, .antigravity, .windsurf, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .warp:
return nil
}
}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 80e552223..964bf6a0c 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -60,6 +60,7 @@ public enum ProviderDescriptorRegistry {
.factory: FactoryProviderDescriptor.descriptor,
.gemini: GeminiProviderDescriptor.descriptor,
.antigravity: AntigravityProviderDescriptor.descriptor,
+ .windsurf: WindsurfProviderDescriptor.descriptor,
.copilot: CopilotProviderDescriptor.descriptor,
.zai: ZaiProviderDescriptor.descriptor,
.minimax: MiniMaxProviderDescriptor.descriptor,
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 3fc0de98c..3bbe37671 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -10,6 +10,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case factory
case gemini
case antigravity
+ case windsurf
case copilot
case zai
case minimax
@@ -33,6 +34,7 @@ public enum IconStyle: Sendable, CaseIterable {
case minimax
case gemini
case antigravity
+ case windsurf
case cursor
case opencode
case factory
diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift
new file mode 100644
index 000000000..c2dc7ff53
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift
@@ -0,0 +1,164 @@
+import Foundation
+
+#if os(macOS)
+import SQLite3
+#endif
+
+public enum WindsurfUsageError: LocalizedError, Sendable, Equatable {
+ case unsupportedPlatform
+ case dbMissing(URL)
+ case sqliteFailed(String)
+ case cachedPlanMissing
+ case decodeFailed
+
+ public var errorDescription: String? {
+ switch self {
+ case .unsupportedPlatform:
+ "Windsurf usage tracking is only supported on macOS."
+ case let .dbMissing(url):
+ "Windsurf data not found at \(url.path). Launch Windsurf once and sign in, then refresh."
+ case let .sqliteFailed(message):
+ "Failed to read Windsurf usage: \(message)"
+ case .cachedPlanMissing:
+ "Windsurf cached plan usage is missing. Open Windsurf, then refresh."
+ case .decodeFailed:
+ "Could not decode Windsurf usage. Update Windsurf and retry."
+ }
+ }
+}
+
+public enum WindsurfLocalStorageReader {
+ public static let envStateDBKey = "CODEXBAR_WINDSURF_STATE_DB"
+ public static let envStateDBFallbackKey = "WINDSURF_STATE_DB"
+
+ /// Default: ~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb
+ public static func stateDBURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL {
+ if let override = cleaned(environment[envStateDBKey]) ?? cleaned(environment[envStateDBFallbackKey]) {
+ return URL(fileURLWithPath: override)
+ }
+ let home = FileManager.default.homeDirectoryForCurrentUser
+ return home
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Application Support", isDirectory: true)
+ .appendingPathComponent("Windsurf", isDirectory: true)
+ .appendingPathComponent("User", isDirectory: true)
+ .appendingPathComponent("globalStorage", isDirectory: true)
+ .appendingPathComponent("state.vscdb", isDirectory: false)
+ }
+
+ public static func loadCachedPlanInfo(environment: [String: String]) throws -> WindsurfCachedPlanInfo {
+ #if os(macOS)
+ let dbURL = self.stateDBURL(environment: environment)
+ guard FileManager.default.fileExists(atPath: dbURL.path) else {
+ throw WindsurfUsageError.dbMissing(dbURL)
+ }
+
+ var db: OpaquePointer?
+ let open = sqlite3_open_v2(dbURL.path, &db, SQLITE_OPEN_READONLY, nil)
+ guard open == SQLITE_OK, let db else {
+ let msg = db.flatMap { sqlite3_errmsg($0) }.map { String(cString: $0) } ?? "sqlite open failed"
+ throw WindsurfUsageError.sqliteFailed(msg)
+ }
+ defer { sqlite3_close(db) }
+
+ sqlite3_busy_timeout(db, 250)
+
+ let sql = "SELECT value FROM ItemTable WHERE key = ? LIMIT 1;"
+ var stmt: OpaquePointer?
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else {
+ let msg = sqlite3_errmsg(db).map { String(cString: $0) } ?? "sqlite prepare failed"
+ throw WindsurfUsageError.sqliteFailed(msg)
+ }
+ defer { sqlite3_finalize(stmt) }
+
+ let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
+ sqlite3_bind_text(stmt, 1, "windsurf.settings.cachedPlanInfo", -1, transient)
+
+ let step = sqlite3_step(stmt)
+ guard step == SQLITE_ROW else {
+ if step != SQLITE_DONE {
+ let msg = sqlite3_errmsg(db).map { String(cString: $0) } ?? "sqlite step failed"
+ throw WindsurfUsageError.sqliteFailed(msg)
+ }
+ throw WindsurfUsageError.cachedPlanMissing
+ }
+
+ guard let bytes = sqlite3_column_blob(stmt, 0) else {
+ throw WindsurfUsageError.cachedPlanMissing
+ }
+ let byteCount = Int(sqlite3_column_bytes(stmt, 0))
+ guard byteCount > 0 else { throw WindsurfUsageError.cachedPlanMissing }
+
+ let data = Data(bytes: bytes, count: byteCount)
+ guard let text = String(data: data, encoding: .utf8), !text.isEmpty else {
+ throw WindsurfUsageError.decodeFailed
+ }
+ let decoded = try? JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: Data(text.utf8))
+ guard let decoded else { throw WindsurfUsageError.decodeFailed }
+ return decoded
+ #else
+ _ = environment
+ throw WindsurfUsageError.unsupportedPlatform
+ #endif
+ }
+
+ public static func parseEpoch(_ value: Int?) -> Date? {
+ guard let value else { return nil }
+ if value >= 1_000_000_000_000 {
+ return Date(timeIntervalSince1970: TimeInterval(value) / 1000.0)
+ }
+ if value > 0 {
+ return Date(timeIntervalSince1970: TimeInterval(value))
+ }
+ return nil
+ }
+
+ public static func makeUsageSnapshot(info: WindsurfCachedPlanInfo, now: Date = Date()) throws -> UsageSnapshot {
+ guard let usage = info.usage else {
+ throw WindsurfUsageError.cachedPlanMissing
+ }
+
+ let resetsAt = Self.parseEpoch(info.endTimestamp)
+
+ func window(total: Int?, used: Int?) -> RateWindow? {
+ guard let total, total > 0, let used else { return nil }
+ let ratio = max(0, min(1.0, Double(used) / Double(total)))
+ return RateWindow(
+ usedPercent: ratio * 100.0,
+ windowMinutes: nil,
+ resetsAt: resetsAt,
+ resetDescription: nil)
+ }
+
+ let primary = window(total: usage.messages, used: usage.usedMessages)
+ let secondary = window(total: usage.flowActions, used: usage.usedFlowActions)
+ let tertiary = window(total: usage.flexCredits, used: usage.usedFlexCredits)
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .windsurf,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: info.planName)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: tertiary,
+ updatedAt: now,
+ identity: identity)
+ }
+
+ private static func cleaned(_ raw: String?) -> String? {
+ guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return nil
+ }
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+ value = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? nil : value
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift
new file mode 100644
index 000000000..e725fea19
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift
@@ -0,0 +1,62 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum WindsurfProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .windsurf,
+ metadata: ProviderMetadata(
+ id: .windsurf,
+ displayName: "Windsurf",
+ sessionLabel: "Messages",
+ weeklyLabel: "Actions",
+ opusLabel: "Credits",
+ supportsOpus: true,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Windsurf usage",
+ cliName: "windsurf",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ dashboardURL: nil,
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .windsurf,
+ iconResourceName: "ProviderIcon-windsurf",
+ color: ProviderColor(red: 14 / 255, green: 165 / 255, blue: 166 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Windsurf cost summary is not supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .cli],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WindsurfLocalStorageFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "windsurf",
+ aliases: ["ws"],
+ versionDetector: nil))
+ }
+}
+
+struct WindsurfLocalStorageFetchStrategy: ProviderFetchStrategy {
+ let id: String = "windsurf.local"
+ let kind: ProviderFetchKind = .localProbe
+
+ func isAvailable(_: ProviderFetchContext) async -> Bool {
+ true
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let info = try WindsurfLocalStorageReader.loadCachedPlanInfo(environment: context.env)
+ let usage = try WindsurfLocalStorageReader.makeUsageSnapshot(info: info)
+ return self.makeResult(
+ usage: usage,
+ sourceLabel: "local")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageModels.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageModels.swift
new file mode 100644
index 000000000..e84cae73a
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageModels.swift
@@ -0,0 +1,60 @@
+import Foundation
+
+public struct WindsurfCachedPlanInfo: Codable, Sendable, Equatable {
+ public let planName: String?
+ public let startTimestamp: Int?
+ public let endTimestamp: Int?
+ public let usage: WindsurfPlanUsage?
+ public let hasBillingWritePermissions: Bool?
+
+ public init(
+ planName: String?,
+ startTimestamp: Int?,
+ endTimestamp: Int?,
+ usage: WindsurfPlanUsage?,
+ hasBillingWritePermissions: Bool?)
+ {
+ self.planName = planName
+ self.startTimestamp = startTimestamp
+ self.endTimestamp = endTimestamp
+ self.usage = usage
+ self.hasBillingWritePermissions = hasBillingWritePermissions
+ }
+}
+
+public struct WindsurfPlanUsage: Codable, Sendable, Equatable {
+ public let duration: Int?
+ public let messages: Int?
+ public let flowActions: Int?
+ public let flexCredits: Int?
+ public let usedMessages: Int?
+ public let usedFlowActions: Int?
+ public let usedFlexCredits: Int?
+ public let remainingMessages: Int?
+ public let remainingFlowActions: Int?
+ public let remainingFlexCredits: Int?
+
+ public init(
+ duration: Int?,
+ messages: Int?,
+ flowActions: Int?,
+ flexCredits: Int?,
+ usedMessages: Int?,
+ usedFlowActions: Int?,
+ usedFlexCredits: Int?,
+ remainingMessages: Int?,
+ remainingFlowActions: Int?,
+ remainingFlexCredits: Int?)
+ {
+ self.duration = duration
+ self.messages = messages
+ self.flowActions = flowActions
+ self.flexCredits = flexCredits
+ self.usedMessages = usedMessages
+ self.usedFlowActions = usedFlowActions
+ self.usedFlexCredits = usedFlexCredits
+ self.remainingMessages = remainingMessages
+ self.remainingFlowActions = remainingFlowActions
+ self.remainingFlexCredits = remainingFlexCredits
+ }
+}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 1936d7eff..b463b010b 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -63,43 +63,14 @@ enum CostUsageScanner {
return self.loadCodexDaily(range: range, now: now, options: options)
case .claude:
return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options)
- case .zai:
- return CostUsageDailyReport(data: [], summary: nil)
- case .gemini:
- return CostUsageDailyReport(data: [], summary: nil)
- case .antigravity:
- return CostUsageDailyReport(data: [], summary: nil)
- case .cursor:
- return CostUsageDailyReport(data: [], summary: nil)
- case .opencode:
- return CostUsageDailyReport(data: [], summary: nil)
- case .factory:
- return CostUsageDailyReport(data: [], summary: nil)
- case .copilot:
- return CostUsageDailyReport(data: [], summary: nil)
- case .minimax:
- return CostUsageDailyReport(data: [], summary: nil)
case .vertexai:
var filtered = options
if filtered.claudeLogProviderFilter == .all {
filtered.claudeLogProviderFilter = .vertexAIOnly
}
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
- case .kiro:
- return CostUsageDailyReport(data: [], summary: nil)
- case .kimi:
- return CostUsageDailyReport(data: [], summary: nil)
- case .kimik2:
- return CostUsageDailyReport(data: [], summary: nil)
- case .augment:
- return CostUsageDailyReport(data: [], summary: nil)
- case .jetbrains:
- return CostUsageDailyReport(data: [], summary: nil)
- case .amp:
- return CostUsageDailyReport(data: [], summary: nil)
- case .synthetic:
- return CostUsageDailyReport(data: [], summary: nil)
- case .warp:
+ case .zai, .gemini, .antigravity, .windsurf, .cursor, .opencode, .factory, .copilot, .minimax, .kiro,
+ .kimi, .kimik2, .augment, .jetbrains, .amp, .synthetic, .warp:
return CostUsageDailyReport(data: [], summary: nil)
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 0d46a0510..dd4117c6a 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -45,6 +45,7 @@ enum ProviderChoice: String, AppEnum {
case .claude: self = .claude
case .gemini: self = .gemini
case .antigravity: self = .antigravity
+ case .windsurf: return nil // Windsurf not yet supported in widgets
case .cursor: return nil // Cursor not yet supported in widgets
case .opencode: self = .opencode
case .zai: self = .zai
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 85ae62f42..2f270f048 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -261,6 +261,7 @@ private struct ProviderSwitchChip: View {
case .claude: "Claude"
case .gemini: "Gemini"
case .antigravity: "Anti"
+ case .windsurf: "Windsurf"
case .cursor: "Cursor"
case .opencode: "OpenCode"
case .zai: "z.ai"
@@ -578,6 +579,8 @@ enum WidgetColors {
Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255)
case .antigravity:
Color(red: 96 / 255, green: 186 / 255, blue: 126 / 255)
+ case .windsurf:
+ Color(red: 14 / 255, green: 165 / 255, blue: 166 / 255)
case .cursor:
Color(red: 0 / 255, green: 191 / 255, blue: 165 / 255) // #00BFA5 - Cursor teal
case .opencode:
diff --git a/Tests/CodexBarTests/CLIProviderSelectionTests.swift b/Tests/CodexBarTests/CLIProviderSelectionTests.swift
index 66b3afa22..ac4f5d248 100644
--- a/Tests/CodexBarTests/CLIProviderSelectionTests.swift
+++ b/Tests/CodexBarTests/CLIProviderSelectionTests.swift
@@ -17,6 +17,7 @@ struct CLIProviderSelectionTests {
"|cursor|",
"|gemini|",
"|antigravity|",
+ "|windsurf|",
"|copilot|",
"|synthetic|",
"|kiro|",
diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift
index e5c35d3b4..848bb3b6b 100644
--- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift
@@ -74,6 +74,8 @@ struct ClaudeOAuthCredentialsStoreTests {
func loadRecord_nonInteractiveRepairCanBeDisabled() throws {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ // Make the test independent of any persisted debugDisableKeychainAccess UserDefaults value.
+ try KeychainAccessGate.withTaskOverrideForTesting(false) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
@@ -127,6 +129,7 @@ struct ClaudeOAuthCredentialsStoreTests {
}
}
}
+ }
}
}
diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift
index 1953257b2..13845623d 100644
--- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift
@@ -157,6 +157,8 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests {
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
+ // Make the test independent of any persisted debugDisableKeychainAccess UserDefaults value.
+ try await KeychainAccessGate.withTaskOverrideForTesting(false) {
KeychainCacheStore.setTestStoreForTesting(true)
defer { KeychainCacheStore.setTestStoreForTesting(false) }
@@ -187,6 +189,7 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests {
#expect(available == true)
}
+ }
}
}
}
diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
index 7ecbe7b26..97abcb92c 100644
--- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift
+++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
@@ -19,6 +19,7 @@ struct ProviderIconResourcesTests {
"opencode",
"gemini",
"antigravity",
+ "windsurf",
"factory",
"copilot",
]
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index 77e1b767c..c1b29522e 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -353,6 +353,7 @@ struct SettingsStoreTests {
.opencode,
.factory,
.antigravity,
+ .windsurf,
.copilot,
.zai,
.minimax,
diff --git a/Tests/CodexBarTests/WindsurfUsageTests.swift b/Tests/CodexBarTests/WindsurfUsageTests.swift
new file mode 100644
index 000000000..bf2e803f6
--- /dev/null
+++ b/Tests/CodexBarTests/WindsurfUsageTests.swift
@@ -0,0 +1,73 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+@Suite
+struct WindsurfUsageTests {
+ @Test
+ func decodesCachedPlanInfoAndBuildsUsageSnapshot() throws {
+ // Build representative JSON without embedding large numeric literals in a multiline string,
+ // which SwiftFormat can lint for grouping.
+ let obj: [String: Any] = [
+ "planName": "Pro",
+ "startTimestamp": 1_735_689_600,
+ "endTimestamp": 1_738_368_000,
+ "usage": [
+ "messages": 500,
+ "flowActions": 200,
+ "flexCredits": 1000,
+ "usedMessages": 125,
+ "usedFlowActions": 50,
+ "usedFlexCredits": 250,
+ "remainingMessages": 375,
+ "remainingFlowActions": 150,
+ "remainingFlexCredits": 750,
+ ],
+ "hasBillingWritePermissions": true,
+ ]
+
+ let jsonData = try JSONSerialization.data(withJSONObject: obj)
+ let info = try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: jsonData)
+ let snap = try WindsurfLocalStorageReader.makeUsageSnapshot(info: info, now: Date(timeIntervalSince1970: 0))
+
+ #expect(snap.primary?.usedPercent.rounded() == 25)
+ #expect(snap.secondary?.usedPercent.rounded() == 25)
+ #expect(snap.tertiary?.usedPercent.rounded() == 25)
+ #expect(snap.loginMethod(for: .windsurf) == "Pro")
+ #expect(snap.accountEmail(for: .windsurf) == nil)
+ }
+
+ @Test
+ func parseEpochSupportsSecondsAndMilliseconds() {
+ let seconds = WindsurfLocalStorageReader.parseEpoch(1_738_368_000)
+ let millis = WindsurfLocalStorageReader.parseEpoch(1_738_368_000_000)
+ #expect(seconds != nil)
+ #expect(millis != nil)
+ #expect(abs((seconds?.timeIntervalSince1970 ?? 0) - (millis?.timeIntervalSince1970 ?? 0)) < 1.0)
+ }
+
+ @Test
+ func omitsWindowsWhenTotalsAreZero() throws {
+ let info = WindsurfCachedPlanInfo(
+ planName: "Free",
+ startTimestamp: 1,
+ endTimestamp: 2,
+ usage: WindsurfPlanUsage(
+ duration: nil,
+ messages: 0,
+ flowActions: 0,
+ flexCredits: 0,
+ usedMessages: 0,
+ usedFlowActions: 0,
+ usedFlexCredits: 0,
+ remainingMessages: 0,
+ remainingFlowActions: 0,
+ remainingFlexCredits: 0),
+ hasBillingWritePermissions: nil)
+
+ let snap = try WindsurfLocalStorageReader.makeUsageSnapshot(info: info, now: Date())
+ #expect(snap.primary == nil)
+ #expect(snap.secondary == nil)
+ #expect(snap.tertiary == nil)
+ }
+}
diff --git a/docs/configuration.md b/docs/configuration.md
index 467207988..f2f4958af 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -72,7 +72,7 @@ All provider fields are optional unless noted.
## Provider IDs
Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`):
-`codex`, `claude`, `cursor`, `opencode`, `factory`, `gemini`, `antigravity`, `copilot`, `zai`, `minimax`, `kimi`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `synthetic`.
+`codex`, `claude`, `cursor`, `opencode`, `factory`, `gemini`, `antigravity`, `windsurf`, `copilot`, `zai`, `minimax`, `kimi`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `synthetic`.
## Ordering
The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering.
diff --git a/docs/providers.md b/docs/providers.md
index dd21169bf..df1563c40 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -22,6 +22,7 @@ until the session is invalid, to avoid repeated Keychain prompts.
| Claude | App Auto: OAuth API (`oauth`) β CLI PTY (`claude`) β Web API (`web`). CLI Auto: Web API (`web`) β CLI PTY (`claude`). |
| Gemini | OAuth API via Gemini CLI credentials (`api`). |
| Antigravity | Local LSP/HTTP probe (`local`). |
+| Windsurf | Local storage plan cache from `state.vscdb` (`local`). |
| Cursor | Web API via cookies β stored WebKit session (`web`). |
| OpenCode | Web dashboard via cookies (`web`). |
| Droid/Factory | Web cookies β stored tokens β local storage β WorkOS cookies (`web`). |
@@ -91,6 +92,14 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Status: Google Workspace incidents (Gemini product).
- Details: `docs/antigravity.md`.
+## Windsurf
+- Reads Windsurfβs local VS Code-style global storage DB:
+ - `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb`
+ - Key: `windsurf.settings.cachedPlanInfo`
+- Shows up to three meters (Messages, Flow Actions, Flex Credits) and uses `endTimestamp` as reset time.
+- No network requests and no cookies required.
+- Details: `docs/windsurf.md`.
+
## Cursor
- Web API via browser cookies (`cursor.com` + `cursor.sh`).
- Fallback: stored WebKit session.
diff --git a/docs/windsurf.md b/docs/windsurf.md
new file mode 100644
index 000000000..32e2bde1f
--- /dev/null
+++ b/docs/windsurf.md
@@ -0,0 +1,53 @@
+---
+summary: "Windsurf provider notes: local state.vscdb parsing, fields, and troubleshooting."
+read_when:
+ - Adding or modifying the Windsurf provider
+ - Debugging missing Windsurf usage
+ - Explaining Windsurf usage data sources
+---
+
+# Windsurf provider
+
+Windsurf usage tracking is **local-only**. CodexBar reads Windsurf's cached plan usage from Windsurf's VS Code-style
+global storage SQLite database.
+
+## Data source
+
+- Default path (macOS):
+ - `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb`
+- Table: `ItemTable`
+- Key: `windsurf.settings.cachedPlanInfo`
+
+The value is JSON that includes:
+- `planName`
+- `startTimestamp` / `endTimestamp` (epoch seconds or milliseconds)
+- `usage` totals and used counts for:
+ - messages
+ - flowActions
+ - flexCredits
+
+CodexBar maps these into:
+- Primary: Messages
+- Secondary: Flow Actions
+- Tertiary: Flex Credits
+- Reset time: `endTimestamp`
+
+## Overrides
+
+For debugging/tests you can override the DB path:
+- `CODEXBAR_WINDSURF_STATE_DB=/absolute/path/to/state.vscdb`
+- `WINDSURF_STATE_DB=/absolute/path/to/state.vscdb` (fallback)
+
+## Troubleshooting
+
+If CodexBar shows "Windsurf data not found" or "cached plan usage is missing":
+1. Launch Windsurf.
+2. Sign in (if needed).
+3. Open the Windsurf settings/plan page once so the plan cache is populated.
+4. Refresh CodexBar.
+
+## Privacy
+
+CodexBar reads a single SQLite value from a local DB. It does not send this data anywhere and does not write back to
+Windsurf's files.
+