Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Xcode user/session
xcuserdata/
.swiftpm/xcode/xcshareddata/
.codexbar/config.json
*.env
*.local

# Build products
.build/
Expand Down
137 changes: 110 additions & 27 deletions Sources/CodexBar/IconRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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×
Expand Down Expand Up @@ -569,6 +575,58 @@ 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 topValue = primaryRemaining
Expand All @@ -593,35 +651,58 @@ 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 {
if style == .warp {
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.
Expand All @@ -635,6 +716,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.
Expand All @@ -646,6 +728,7 @@ enum IconRenderer {
addGeminiTwist: style == .gemini || style == .antigravity,
addAntigravityTwist: style == .antigravity,
addFactoryTwist: style == .factory,
addWarpTwist: style == .warp,
blink: blink)
}
drawBar(rectPx: creditsBottomRectPx, remaining: bottomValue)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum ProviderImplementationRegistry {
case .kimik2: KimiK2ProviderImplementation()
case .amp: AmpProviderImplementation()
case .synthetic: SyntheticProviderImplementation()
case .warp: WarpProviderImplementation()
}
}

Expand Down
41 changes: 41 additions & 0 deletions Sources/CodexBar/Providers/Warp/WarpProviderImplementation.swift
Original file line number Diff line number Diff line change
@@ -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() }),
]
}
}
16 changes: 16 additions & 0 deletions Sources/CodexBar/Providers/Warp/WarpSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -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() {}
}
4 changes: 4 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-warp.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStore+MenuObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ extension SettingsStore {
_ = self.augmentCookieHeader
_ = self.ampCookieHeader
_ = self.copilotAPIToken
_ = self.warpAPIToken
_ = self.tokenAccountsByProvider
_ = self.debugLoadingPattern
_ = self.selectedMenuProvider
Expand Down
12 changes: 11 additions & 1 deletion Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBarCLI/TokenAccountCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Logging/LogCategories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Providers/ProviderDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
{
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCore/Providers/Providers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case kimik2
case amp
case synthetic
case warp
}

// swiftformat:enable sortDeclarations
Expand All @@ -44,6 +45,7 @@ public enum IconStyle: Sendable, CaseIterable {
case jetbrains
case amp
case synthetic
case warp
case combined
}

Expand Down
Loading