diff --git a/.gitignore b/.gitignore index 01ae2d5c..d4f2dce9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # Xcode user/session xcuserdata/ +.swiftpm/xcode/xcshareddata/ +.codexbar/config.json +*.env +*.local # Build products .build/ diff --git a/Sources/CodexBar/IconRenderer.swift b/Sources/CodexBar/IconRenderer.swift index df879514..f2dcc8a0 100644 --- a/Sources/CodexBar/IconRenderer.swift +++ b/Sources/CodexBar/IconRenderer.swift @@ -135,16 +135,22 @@ enum IconRenderer { addGeminiTwist: Bool = false, addAntigravityTwist: Bool = false, addFactoryTwist: Bool = false, - blink: CGFloat = 0) + addWarpTwist: Bool = false, + blink: CGFloat = 0, + drawTrackFill: Bool = true, + warpEyesFilled: Bool = false) { let rect = rectPx.rect() // Claude reads better as a blockier critter; Codex stays as a capsule. - let cornerRadiusPx = addNotches ? 0 : rectPx.h / 2 + // Warp uses small corner radius for rounded rectangle (matching logo style) + let cornerRadiusPx = addNotches ? 0 : (addWarpTwist ? 3 : rectPx.h / 2) let radius = Self.grid.pt(cornerRadiusPx) let trackPath = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) - baseFill.withAlphaComponent(trackFillAlpha * alpha).setFill() - trackPath.fill() + if drawTrackFill { + baseFill.withAlphaComponent(trackFillAlpha * alpha).setFill() + trackPath.fill() + } // Crisp outline: stroke an inset path so the stroke stays within pixel bounds. let strokeWidthPx = 2 // 1 pt == 2 px at 2× @@ -569,22 +575,83 @@ enum IconRenderer { drawBlinkAsterisk(cx: rdCx, cy: yCy) } } + + // Warp twist: "Warp" style face with a diagonal slash + if addWarpTwist { + let ctx = NSGraphicsContext.current?.cgContext + let centerXPx = rectPx.midXPx + let eyeCenterYPx = rectPx.y + rectPx.h / 2 + + ctx?.saveGState() + ctx?.setShouldAntialias(true) // Smooth edges for tilted ellipse eyes + + // 1. Draw Eyes (Tilted ellipse cutouts - "fox eye" / "cat eye" style) + // Eyes are elliptical and tilted outward (outer corners pointing up) + let eyeWidthPx: CGFloat = 5.3125 // Scaled up 125% to match rounded rect face + let eyeHeightPx: CGFloat = 8.5 // Scaled up 125% to match rounded rect face + let eyeOffsetPx: CGFloat = 7 + let eyeTiltAngle: CGFloat = .pi / 3 // 60 degrees tilt + + let leftEyeCx = Self.grid.pt(centerXPx) - Self.grid.pt(Int(eyeOffsetPx)) + let rightEyeCx = Self.grid.pt(centerXPx) + Self.grid.pt(Int(eyeOffsetPx)) + let eyeCy = Self.grid.pt(eyeCenterYPx) + let eyeW = Self.grid.pt(Int(eyeWidthPx)) + let eyeH = Self.grid.pt(Int(eyeHeightPx)) + + // Helper to draw a tilted ellipse eye + func drawTiltedEyeCutout(cx: CGFloat, cy: CGFloat, tiltAngle: CGFloat) { + let eyeRect = CGRect( + x: -eyeW / 2, + y: -eyeH / 2, + width: eyeW, + height: eyeH) + let eyePath = NSBezierPath(ovalIn: eyeRect) + + var transform = AffineTransform.identity + transform.translate(x: cx, y: cy) + transform.rotate(byRadians: tiltAngle) + eyePath.transform(using: transform) + eyePath.fill() + } + + if warpEyesFilled { + fillColor.withAlphaComponent(alpha).setFill() + drawTiltedEyeCutout(cx: leftEyeCx, cy: eyeCy, tiltAngle: eyeTiltAngle) + drawTiltedEyeCutout(cx: rightEyeCx, cy: eyeCy, tiltAngle: -eyeTiltAngle) + } else { + // Clear eyes using blend mode + ctx?.setBlendMode(.clear) + drawTiltedEyeCutout(cx: leftEyeCx, cy: eyeCy, tiltAngle: eyeTiltAngle) + drawTiltedEyeCutout(cx: rightEyeCx, cy: eyeCy, tiltAngle: -eyeTiltAngle) + ctx?.setBlendMode(.normal) + } + ctx?.restoreGState() // Restore graphics state + } } + let effectiveWeeklyRemaining: Double? = { + if style == .warp, let weeklyRemaining, weeklyRemaining <= 0 { + return nil + } + return weeklyRemaining + }() let topValue = primaryRemaining - let bottomValue = weeklyRemaining + let bottomValue = effectiveWeeklyRemaining let creditsRatio = creditsRemaining.map { min($0 / Self.creditsCap * 100, 100) } - let hasWeekly = (weeklyRemaining != nil) - let weeklyAvailable = hasWeekly && (weeklyRemaining ?? 0) > 0 + let hasWeekly = (bottomValue != nil) + let weeklyAvailable = hasWeekly && (bottomValue ?? 0) > 0 let creditsAlpha: CGFloat = 1.0 let topRectPx = RectPx(x: barXPx, y: 19, w: barWidthPx, h: 12) let bottomRectPx = RectPx(x: barXPx, y: 5, w: barWidthPx, h: 8) let creditsRectPx = RectPx(x: barXPx, y: 14, w: barWidthPx, h: 16) let creditsBottomRectPx = RectPx(x: barXPx, y: 4, w: barWidthPx, h: 6) + // Warp special case: when no bonus or bonus exhausted, show "top full, bottom=monthly" + let warpNoBonus = style == .warp && !weeklyAvailable + if weeklyAvailable { - // Normal: top=5h, bottom=weekly, no credits. + // Normal: top=primary, bottom=secondary (bonus/weekly). drawBar( rectPx: topRectPx, remaining: topValue, @@ -593,35 +660,59 @@ enum IconRenderer { addGeminiTwist: style == .gemini || style == .antigravity, addAntigravityTwist: style == .antigravity, addFactoryTwist: style == .factory, + addWarpTwist: style == .warp, blink: blink) drawBar(rectPx: bottomRectPx, remaining: bottomValue) - } 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 { + } else if !hasWeekly || warpNoBonus { + if style == .warp { + // Warp: no bonus or bonus exhausted -> top=full, bottom=monthly credits + if topValue != nil { + drawBar( + rectPx: topRectPx, + remaining: 100, + addWarpTwist: true, + blink: blink) + } else { + drawBar( + rectPx: topRectPx, + remaining: nil, + addWarpTwist: true, + blink: blink) + } drawBar( - rectPx: topRectPx, + rectPx: bottomRectPx, remaining: topValue, - 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. @@ -635,6 +726,7 @@ enum IconRenderer { addGeminiTwist: style == .gemini || style == .antigravity, addAntigravityTwist: style == .antigravity, addFactoryTwist: style == .factory, + addWarpTwist: style == .warp, blink: blink) } else { // No credits available; fall back to 5h if present. @@ -646,6 +738,7 @@ enum IconRenderer { addGeminiTwist: style == .gemini || style == .antigravity, addAntigravityTwist: style == .antigravity, addFactoryTwist: style == .factory, + addWarpTwist: style == .warp, blink: blink) } drawBar(rectPx: creditsBottomRectPx, remaining: bottomValue) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5e92b4b8..93d53389 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -781,13 +781,22 @@ extension UsageMenuCardView.Model { window: weekly, now: input.now, showUsed: input.usageBarsShowUsed) + var weeklyResetText = Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now) + var weeklyDetailText: String? = input.provider == .zai ? zaiTimeDetail : nil + if input.provider == .warp, + let detail = weekly.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + weeklyResetText = nil + weeklyDetailText = detail + } metrics.append(Metric( id: "secondary", title: input.metadata.weeklyLabel, percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, - resetText: Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now), - detailText: input.provider == .zai ? zaiTimeDetail : nil, + resetText: weeklyResetText, + detailText: weeklyDetailText, detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 6330782b..1cc8e000 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -123,12 +123,18 @@ struct MenuDescriptor { showUsed: settings.usageBarsShowUsed) } if let weekly = snap.secondary { + let weeklyResetOverride: String? = { + guard provider == .warp else { return nil } + let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) + return (detail?.isEmpty ?? true) ? nil : detail + }() Self.appendRateWindow( entries: &entries, title: meta.weeklyLabel, window: weekly, resetStyle: resetStyle, - showUsed: settings.usageBarsShowUsed) + showUsed: settings.usageBarsShowUsed, + resetOverride: weeklyResetOverride) if let paceSummary = UsagePaceText.weeklySummary(provider: provider, window: weekly) { entries.append(.text(paceSummary, .secondary)) } @@ -343,12 +349,15 @@ struct MenuDescriptor { title: String, window: RateWindow, resetStyle: ResetTimeDisplayStyle, - showUsed: Bool) + showUsed: Bool, + resetOverride: String? = nil) { let line = UsageFormatter .usageLine(remaining: window.remainingPercent, used: window.usedPercent, showUsed: showUsed) entries.append(.text("\(title): \(line)", .primary)) - if let reset = UsageFormatter.resetLine(for: window, style: resetStyle) { + if let resetOverride { + entries.append(.text(resetOverride, .secondary)) + } else if let reset = UsageFormatter.resetLine(for: window, style: resetStyle) { entries.append(.text(reset, .secondary)) } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index f6a9b2a3..fef37cef 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -30,6 +30,7 @@ enum ProviderImplementationRegistry { case .kimik2: KimiK2ProviderImplementation() case .amp: AmpProviderImplementation() case .synthetic: SyntheticProviderImplementation() + case .warp: WarpProviderImplementation() } } diff --git a/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift b/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift new file mode 100644 index 00000000..d1f2872b --- /dev/null +++ b/Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift @@ -0,0 +1,41 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct WarpProviderImplementation: ProviderImplementation { + let id: UsageProvider = .warp + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.warpAPIToken + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "warp-api-token", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Generate one at app.warp.dev.", + kind: .secure, + placeholder: "wk-...", + binding: context.stringBinding(\.warpAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "warp-open-api-keys", + title: "Open Warp Settings", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://app.warp.dev/settings/account") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: { context.settings.ensureWarpAPITokenLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/Warp/WarpSettingsStore.swift b/Sources/CodexBar/Providers/Warp/WarpSettingsStore.swift new file mode 100644 index 00000000..ed6a6d1f --- /dev/null +++ b/Sources/CodexBar/Providers/Warp/WarpSettingsStore.swift @@ -0,0 +1,16 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var warpAPIToken: String { + get { self.configSnapshot.providerConfig(for: .warp)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .warp) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .warp, field: "apiKey", value: newValue) + } + } + + func ensureWarpAPITokenLoaded() {} +} diff --git a/Sources/CodexBar/Resources/ProviderIcon-warp.svg b/Sources/CodexBar/Resources/ProviderIcon-warp.svg new file mode 100644 index 00000000..30a992a0 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-warp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 6c69f6a7..4fa5640a 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -53,6 +53,7 @@ extension SettingsStore { _ = self.augmentCookieHeader _ = self.ampCookieHeader _ = self.copilotAPIToken + _ = self.warpAPIToken _ = self.tokenAccountsByProvider _ = self.debugLoadingPattern _ = self.selectedMenuProvider diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 217db0a9..04e0ef6c 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -304,7 +304,14 @@ extension StatusItemController { } let style: IconStyle = self.store.style(for: provider) - let blink = self.blinkAmount(for: provider) + let isLoading = phase != nil && self.shouldAnimate(provider: provider) + let blink: CGFloat = { + guard isLoading, style == .warp, let phase else { + return self.blinkAmount(for: provider) + } + let normalized = (sin(phase * 3) + 1) / 2 + return CGFloat(max(0, min(normalized, 1))) + }() let wiggle = self.wiggleAmount(for: provider) let tilt = self.tiltAmount(for: provider) * .pi / 28 // limit to ~6.4° if let morphProgress { @@ -421,6 +428,9 @@ extension StatusItemController { let isStale = self.store.isStale(provider: provider) let hasData = self.store.snapshot(for: provider) != nil + if provider == .warp, !hasData, self.store.refreshingProviders.contains(provider) { + return true + } return !hasData && !isStale } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 69491056..71e49a6f 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1229,6 +1229,13 @@ extension UsageStore { 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 + let source = resolution?.source.rawValue ?? "none" + let text = "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + await MainActor.run { self.probeLogs[.warp] = text } + return text } }.value } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index a0a88371..206fab16 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: + case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .warp: return nil } } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index a05b2dd2..1969ba6a 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -21,6 +21,10 @@ public enum ProviderConfigEnvironment { } case .synthetic: env[SyntheticSettingsReader.apiKeyKey] = apiKey + case .warp: + if let key = WarpSettingsReader.apiKeyEnvironmentKeys.first { + env[key] = apiKey + } default: break } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index d3d31c8f..550ffc81 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -54,6 +54,7 @@ public enum LogCategories { public static let tokenCost = "token-cost" public static let ttyRunner = "tty-runner" public static let vertexAIFetcher = "vertexai-fetcher" + public static let warpUsage = "warp-usage" public static let webkitTeardown = "webkit-teardown" public static let zaiSettings = "zai-settings" public static let zaiTokenStore = "zai-token-store" diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 6aff8369..80e55222 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -71,6 +71,7 @@ public enum ProviderDescriptorRegistry { .kimik2: KimiK2ProviderDescriptor.descriptor, .amp: AmpProviderDescriptor.descriptor, .synthetic: SyntheticProviderDescriptor.descriptor, + .warp: WarpProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index 6b978775..4134d67b 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -45,6 +45,10 @@ public enum ProviderTokenResolver { self.kimiK2Resolution(environment: environment)?.token } + public static func warpToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.warpResolution(environment: environment)?.token + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -100,6 +104,12 @@ public enum ProviderTokenResolver { self.resolveEnv(KimiK2SettingsReader.apiKey(environment: environment)) } + public static func warpResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(WarpSettingsReader.apiKey(environment: environment)) + } + private static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index a267fb95..3fc0de98 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -21,6 +21,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case kimik2 case amp case synthetic + case warp } // swiftformat:enable sortDeclarations @@ -44,6 +45,7 @@ public enum IconStyle: Sendable, CaseIterable { case jetbrains case amp case synthetic + case warp case combined } diff --git a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift new file mode 100644 index 00000000..3c22d75d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift @@ -0,0 +1,67 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum WarpProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .warp, + metadata: ProviderMetadata( + id: .warp, + displayName: "Warp", + sessionLabel: "Credits", + weeklyLabel: "Add-on credits", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Warp usage", + cliName: "warp", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://app.warp.dev/settings/account", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .warp, + iconResourceName: "ProviderIcon-warp", + color: ProviderColor(red: 147 / 255, green: 139 / 255, blue: 180 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Warp cost summary is not available." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WarpAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "warp", + aliases: ["warp-ai", "warp-terminal"], + versionDetector: nil)) + } +} + +struct WarpAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "warp.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw WarpUsageError.missingCredentials + } + let usage = try await WarpUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { false } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.warpToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift b/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift new file mode 100644 index 00000000..cd3c639c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift @@ -0,0 +1,36 @@ +import Foundation + +public struct WarpSettingsReader: Sendable { + public static let apiKeyEnvironmentKeys = [ + "WARP_API_KEY", + "WARP_TOKEN", + ] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift new file mode 100644 index 00000000..e6f57e47 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift @@ -0,0 +1,335 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct WarpUsageSnapshot: Sendable { + public let requestLimit: Int + public let requestsUsed: Int + public let nextRefreshTime: Date? + public let isUnlimited: Bool + public let updatedAt: Date + // Combined bonus credits (user-level + workspace-level) + public let bonusCreditsRemaining: Int + public let bonusCreditsTotal: Int + // Earliest expiring bonus batch with remaining credits + public let bonusNextExpiration: Date? + public let bonusNextExpirationRemaining: Int + + public init( + requestLimit: Int, + requestsUsed: Int, + nextRefreshTime: Date?, + isUnlimited: Bool, + updatedAt: Date, + bonusCreditsRemaining: Int = 0, + bonusCreditsTotal: Int = 0, + bonusNextExpiration: Date? = nil, + bonusNextExpirationRemaining: Int = 0 + ) { + self.requestLimit = requestLimit + self.requestsUsed = requestsUsed + self.nextRefreshTime = nextRefreshTime + self.isUnlimited = isUnlimited + self.updatedAt = updatedAt + self.bonusCreditsRemaining = bonusCreditsRemaining + self.bonusCreditsTotal = bonusCreditsTotal + self.bonusNextExpiration = bonusNextExpiration + self.bonusNextExpirationRemaining = bonusNextExpirationRemaining + } + + public func toUsageSnapshot() -> UsageSnapshot { + let usedPercent: Double + if self.isUnlimited { + usedPercent = 0 + } else if self.requestLimit > 0 { + usedPercent = min(100, max(0, Double(self.requestsUsed) / Double(self.requestLimit) * 100)) + } else { + usedPercent = 0 + } + + let resetDescription: String? + if self.isUnlimited { + resetDescription = "Unlimited" + } else { + resetDescription = "\(self.requestsUsed)/\(self.requestLimit) credits" + } + + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: self.nextRefreshTime, + resetDescription: resetDescription) + + // Secondary: combined bonus/add-on credits (user + workspace) + let bonusUsedPercent: Double = { + guard self.bonusCreditsTotal > 0 else { + return self.bonusCreditsRemaining > 0 ? 0 : 100 + } + let used = self.bonusCreditsTotal - self.bonusCreditsRemaining + return min(100, max(0, Double(used) / Double(self.bonusCreditsTotal) * 100)) + }() + + var bonusDetail: String? + if self.bonusCreditsRemaining > 0, + let expiry = self.bonusNextExpiration, + self.bonusNextExpirationRemaining > 0 + { + let dateText = expiry.formatted(date: .abbreviated, time: .shortened) + bonusDetail = "\(self.bonusNextExpirationRemaining) credits expires on \(dateText)" + } + + let secondary = RateWindow( + usedPercent: bonusUsedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: bonusDetail) + + let identity = ProviderIdentitySnapshot( + providerID: .warp, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +public enum WarpUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(Int, String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Warp API key." + case let .networkError(message): + "Warp network error: \(message)" + case let .apiError(code, message): + "Warp API error (\(code)): \(message)" + case let .parseFailed(message): + "Failed to parse Warp response: \(message)" + } + } +} + +public struct WarpUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.warpUsage) + private static let apiURL = URL(string: "https://app.warp.dev/graphql/v2?op=GetRequestLimitInfo")! + private static let clientID = "warp-app" + + private static let graphQLQuery = """ + query GetRequestLimitInfo($requestContext: RequestContext!) { + user(requestContext: $requestContext) { + __typename + ... on UserOutput { + user { + requestLimitInfo { + isUnlimited + nextRefreshTime + requestLimit + requestsUsedSinceLastRefresh + } + bonusGrants { + requestCreditsGranted + requestCreditsRemaining + expiration + } + workspaces { + bonusGrantsInfo { + grants { + requestCreditsGranted + requestCreditsRemaining + expiration + } + } + } + } + } + } + } + """ + + public static func fetchUsage(apiKey: String) async throws -> WarpUsageSnapshot { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw WarpUsageError.missingCredentials + } + + var request = URLRequest(url: self.apiURL) + request.httpMethod = "POST" + request.timeoutInterval = 15 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(self.clientID, forHTTPHeaderField: "x-warp-client-id") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let variables: [String: Any] = [ + "requestContext": [ + "clientContext": [:] as [String: Any], + "osContext": [ + "category": "macOS", + "name": "macOS", + "version": "15.0", + ] as [String: Any], + ] as [String: Any], + ] + + let body: [String: Any] = [ + "query": self.graphQLQuery, + "variables": variables, + "operationName": "GetRequestLimitInfo", + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw WarpUsageError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "HTTP \(httpResponse.statusCode)" + Self.log.error("Warp API returned \(httpResponse.statusCode): \(body)") + throw WarpUsageError.apiError(httpResponse.statusCode, body) + } + + if let jsonString = String(data: data, encoding: .utf8) { + Self.log.debug("Warp API response: \(jsonString)") + } + + return try Self.parseResponse(data: data) + } + + private static func parseResponse(data: Data) throws -> WarpUsageSnapshot { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataObj = json["data"] as? [String: Any], + let userObj = dataObj["user"] as? [String: Any], + let innerUserObj = userObj["user"] as? [String: Any], + let limitInfo = innerUserObj["requestLimitInfo"] as? [String: Any] + else { + throw WarpUsageError.parseFailed("Unable to extract requestLimitInfo from response.") + } + + let isUnlimited = limitInfo["isUnlimited"] as? Bool ?? false + let requestLimit = limitInfo["requestLimit"] as? Int ?? 0 + let requestsUsed = limitInfo["requestsUsedSinceLastRefresh"] as? Int ?? 0 + + var nextRefreshTime: Date? + if let nextRefreshTimeString = limitInfo["nextRefreshTime"] as? String { + nextRefreshTime = Self.parseDate(nextRefreshTimeString) + } + + // Parse and combine bonus credits from user-level and workspace-level + let bonus = Self.parseBonusCredits(from: innerUserObj) + + return WarpUsageSnapshot( + requestLimit: requestLimit, + requestsUsed: requestsUsed, + nextRefreshTime: nextRefreshTime, + isUnlimited: isUnlimited, + updatedAt: Date(), + bonusCreditsRemaining: bonus.remaining, + bonusCreditsTotal: bonus.total, + bonusNextExpiration: bonus.nextExpiration, + bonusNextExpirationRemaining: bonus.nextExpirationRemaining) + } + + private struct BonusGrant: Sendable { + let granted: Int + let remaining: Int + let expiration: Date? + } + + private struct BonusSummary: Sendable { + let remaining: Int + let total: Int + let nextExpiration: Date? + let nextExpirationRemaining: Int + } + + private static func parseBonusCredits(from userObj: [String: Any]) -> BonusSummary { + var grants: [BonusGrant] = [] + + // User-level bonus grants + if let bonusGrants = userObj["bonusGrants"] as? [[String: Any]] { + for grant in bonusGrants { + grants.append(Self.parseBonusGrant(from: grant)) + } + } + + // Workspace-level bonus grants + if let workspaces = userObj["workspaces"] as? [[String: Any]] { + for workspace in workspaces { + if let bonusGrantsInfo = workspace["bonusGrantsInfo"] as? [String: Any], + let workspaceGrants = bonusGrantsInfo["grants"] as? [[String: Any]] + { + for grant in workspaceGrants { + grants.append(Self.parseBonusGrant(from: grant)) + } + } + } + } + + let totalRemaining = grants.reduce(0) { $0 + $1.remaining } + let totalGranted = grants.reduce(0) { $0 + $1.granted } + + let expiring = grants.compactMap { grant -> (date: Date, remaining: Int)? in + guard grant.remaining > 0, let expiration = grant.expiration else { return nil } + return (expiration, grant.remaining) + } + + let nextExpiration: Date? + let nextExpirationRemaining: Int + if let earliest = expiring.min(by: { $0.date < $1.date }) { + let earliestKey = Int(earliest.date.timeIntervalSince1970) + let remaining = expiring.reduce(0) { result, item in + let key = Int(item.date.timeIntervalSince1970) + return result + (key == earliestKey ? item.remaining : 0) + } + nextExpiration = earliest.date + nextExpirationRemaining = remaining + } else { + nextExpiration = nil + nextExpirationRemaining = 0 + } + + return BonusSummary( + remaining: totalRemaining, + total: totalGranted, + nextExpiration: nextExpiration, + nextExpirationRemaining: nextExpirationRemaining) + } + + private static func parseBonusGrant(from grant: [String: Any]) -> BonusGrant { + let granted = self.intValue(grant["requestCreditsGranted"]) + let remaining = self.intValue(grant["requestCreditsRemaining"]) + let expiration = (grant["expiration"] as? String).flatMap(Self.parseDate) + return BonusGrant(granted: granted, remaining: remaining, expiration: expiration) + } + + private static func intValue(_ value: Any?) -> Int { + if let int = value as? Int { return int } + if let num = value as? NSNumber { return num.intValue } + if let text = value as? String, let int = Int(text) { return int } + return 0 + } + + private static func parseDate(_ dateString: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: dateString) { + return date + } + let fallback = ISO8601DateFormatter() + fallback.formatOptions = [.withInternetDateTime] + return fallback.date(from: dateString) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index c50b106c..492088ae 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -99,6 +99,8 @@ enum CostUsageScanner { return CostUsageDailyReport(data: [], summary: nil) case .synthetic: return CostUsageDailyReport(data: [], summary: nil) + case .warp: + return CostUsageDailyReport(data: [], summary: nil) } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 1634611e..0d46a051 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -59,6 +59,7 @@ enum ProviderChoice: String, AppEnum { case .kimik2: return nil // Kimi K2 not yet supported in widgets case .amp: return nil // Amp not yet supported in widgets case .synthetic: return nil // Synthetic not yet supported in widgets + case .warp: return nil // Warp not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index ed39b450..85ae62f4 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -275,6 +275,7 @@ private struct ProviderSwitchChip: View { case .kimik2: "Kimi K2" case .amp: "Amp" case .synthetic: "Synthetic" + case .warp: "Warp" } } } @@ -605,6 +606,8 @@ enum WidgetColors { Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red case .synthetic: Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal + case .warp: + Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) } } } diff --git a/Tests/CodexBarTests/CLIProviderSelectionTests.swift b/Tests/CodexBarTests/CLIProviderSelectionTests.swift index ec855180..66b3afa2 100644 --- a/Tests/CodexBarTests/CLIProviderSelectionTests.swift +++ b/Tests/CodexBarTests/CLIProviderSelectionTests.swift @@ -20,6 +20,7 @@ struct CLIProviderSelectionTests { "|copilot|", "|synthetic|", "|kiro|", + "|warp|", "|both|", "|all]", ] diff --git a/Tests/CodexBarTests/ProviderTokenResolverTests.swift b/Tests/CodexBarTests/ProviderTokenResolverTests.swift index 004b63b1..867b3ad4 100644 --- a/Tests/CodexBarTests/ProviderTokenResolverTests.swift +++ b/Tests/CodexBarTests/ProviderTokenResolverTests.swift @@ -17,4 +17,26 @@ struct ProviderTokenResolverTests { let resolution = ProviderTokenResolver.copilotResolution(environment: env) #expect(resolution?.token == "token") } + + @Test + func warpResolutionUsesEnvironmentToken() { + let env = ["WARP_API_KEY": "wk-test-token"] + let resolution = ProviderTokenResolver.warpResolution(environment: env) + #expect(resolution?.token == "wk-test-token") + #expect(resolution?.source == .environment) + } + + @Test + func warpResolutionTrimsToken() { + let env = ["WARP_API_KEY": " wk-token "] + let resolution = ProviderTokenResolver.warpResolution(environment: env) + #expect(resolution?.token == "wk-token") + } + + @Test + func warpResolutionReturnsNilWhenMissing() { + let env: [String: String] = [:] + let resolution = ProviderTokenResolver.warpResolution(environment: env) + #expect(resolution == nil) + } } diff --git a/docs/providers.md b/docs/providers.md index d25dfb88..3054b6ff 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Vertex AI, Augment, Amp, JetBrains AI)." +summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, JetBrains AI)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -34,6 +34,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Vertex AI | Google ADC OAuth (gcloud) → Cloud Monitoring quota usage (`oauth`). | | JetBrains AI | Local XML quota file (`local`). | | Amp | Web settings page via browser cookies (`web`). | +| Warp | API token (Keychain/env) → GraphQL request limits (`api`). | ## Codex - Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. @@ -121,6 +122,13 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: AWS Health Dashboard (manual link, no auto-polling). - Details: `docs/kiro.md`. +## Warp +- API token from Settings or `WARP_API_KEY` / `WARP_TOKEN` env var. +- GraphQL credit limits: `https://app.warp.dev/graphql/v2?op=GetRequestLimitInfo`. +- Shows monthly credits usage and next refresh time. +- Status: none yet. +- Details: `docs/warp.md`. + ## Vertex AI - OAuth credentials from `gcloud auth application-default login` (ADC). - Quota usage via Cloud Monitoring `consumer_quota` metrics for `aiplatform.googleapis.com`. diff --git a/docs/warp.md b/docs/warp.md new file mode 100644 index 00000000..3dd40782 --- /dev/null +++ b/docs/warp.md @@ -0,0 +1,46 @@ +--- +summary: "Warp provider notes: API token setup and request limit parsing." +read_when: + - Adding or modifying the Warp provider + - Debugging Warp API tokens or request limits + - Adjusting Warp usage labels or reset behavior +--- + +# Warp Provider + +The Warp provider reads credit limits from Warp's GraphQL API using an API token. + +## Features + +- **Monthly credits usage**: Shows credits used vs. plan limit. +- **Reset timing**: Displays the next refresh time when available. +- **Token-based auth**: Uses API key stored in Settings or env vars. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Warp** +3. Enter your API key from `https://app.warp.dev/settings/account` + +### Environment variables (optional) + +- `WARP_API_KEY` +- `WARP_TOKEN` + +## How it works + +- Endpoint: `https://app.warp.dev/graphql/v2?op=GetRequestLimitInfo` +- Query: `GetRequestLimitInfo` +- Fields used: `isUnlimited`, `nextRefreshTime`, `requestLimit`, `requestsUsedSinceLastRefresh` (API uses request-named fields for credits) + +If `isUnlimited` is true, the UI shows “Unlimited” and a full remaining bar. + +## Troubleshooting + +### “Missing Warp API key” + +Add a key in **Settings → Providers → Warp**, or set `WARP_API_KEY`. + +### “Warp API error” + +Confirm the token is valid and that your network can reach `app.warp.dev`.