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
157 changes: 125 additions & 32 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,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,
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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)
Expand Down
13 changes: 11 additions & 2 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 12 additions & 3 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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))
}
}
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
Loading