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
8 changes: 7 additions & 1 deletion Sources/CodexBar/IconRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -652,8 +652,10 @@ enum IconRenderer {
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 monthly, bottom dimmed"
// Warp: when no bonus or bonus exhausted, show top=monthly, bottom=dimmed
let warpNoBonus = style == .warp && !weeklyAvailable
// Kilo: when no subscription data, dim both bars
let kiloNoData = style == .kilo && topValue == nil

if weeklyAvailable {
// Normal: top=primary, bottom=secondary (bonus/weekly).
Expand All @@ -668,6 +670,10 @@ enum IconRenderer {
addWarpTwist: style == .warp,
blink: blink)
drawBar(rectPx: bottomRectPx, remaining: bottomValue)
} else if kiloNoData {
// Kilo: no percentage data available, show cost in detail text instead
drawBar(rectPx: topRectPx, remaining: 0, alpha: 0.3)
drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45)
} else if !hasWeekly || warpNoBonus {
if style == .warp {
// Warp: no bonus or bonus exhausted -> top=monthly credits, bottom=dimmed track
Expand Down
180 changes: 170 additions & 10 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,25 @@ struct UsageMenuCardView: View {
let spendLine: String
}

struct CreditBlockItem: Identifiable, Sendable {
let id: String
let amountText: String
let balanceText: String
let dateText: String
let expiryText: String?
let percent: Double
let percentLabel: String
let isFree: Bool
}

let providerName: String
let email: String
let subtitleText: String
let subtitleStyle: SubtitleStyle
let planText: String?
let metrics: [Metric]
let creditBlocks: [CreditBlockItem]?
let autoTopUpText: String?
let creditsText: String?
let creditsRemaining: Double?
let creditsHintText: String?
Expand All @@ -89,14 +102,15 @@ struct UsageMenuCardView: View {
Divider()
}

if self.model.metrics.isEmpty {
if self.model.metrics.isEmpty, !self.hasExtraSections {
if let placeholder = self.model.placeholder {
Text(placeholder)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.font(.subheadline)
}
} else {
let hasUsage = !self.model.metrics.isEmpty
let hasCreditBlocks = !(self.model.creditBlocks ?? []).isEmpty
let hasCredits = self.model.creditsText != nil
let hasProviderCost = self.model.providerCost != nil
let hasCost = self.model.tokenUsage != nil || hasProviderCost
Expand All @@ -111,7 +125,16 @@ struct UsageMenuCardView: View {
}
}
}
if hasUsage, hasCredits || hasCost {
if hasUsage, hasCreditBlocks || hasCredits || hasCost {
Divider()
}
if let blocks = self.model.creditBlocks, !blocks.isEmpty {
CreditBlocksContent(
blocks: blocks,
autoTopUpText: self.model.autoTopUpText,
progressColor: self.model.progressColor)
}
if hasCreditBlocks, hasCredits || hasCost {
Divider()
}
if let credits = self.model.creditsText {
Expand Down Expand Up @@ -172,8 +195,13 @@ struct UsageMenuCardView: View {
}

private var hasDetails: Bool {
!self.model.metrics.isEmpty || self.model.placeholder != nil || self.model.tokenUsage != nil ||
self.model.providerCost != nil
!self.model.metrics.isEmpty || self.model.placeholder != nil || self.hasExtraSections
}

/// Whether the model has credit blocks, cost, or token usage data (independent of metrics).
private var hasExtraSections: Bool {
self.model.tokenUsage != nil || self.model.providerCost != nil ||
!(self.model.creditBlocks ?? []).isEmpty
}
}

Expand Down Expand Up @@ -277,6 +305,72 @@ private struct CopyIconButton: View {
}
}

private struct CreditBlocksContent: View {
let blocks: [UsageMenuCardView.Model.CreditBlockItem]
let autoTopUpText: String?
let progressColor: Color
@Environment(\.menuItemHighlighted) private var isHighlighted

var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text("Credits")
.font(.body)
.fontWeight(.medium)
Spacer()
if let autoTopUpText {
Text(autoTopUpText)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
}
}
ForEach(self.blocks) { block in
CreditBlockRow(block: block, progressColor: self.progressColor)
}
}
}
}

private struct CreditBlockRow: View {
let block: UsageMenuCardView.Model.CreditBlockItem
let progressColor: Color
@Environment(\.menuItemHighlighted) private var isHighlighted

var body: some View {
VStack(alignment: .leading, spacing: 4) {
UsageProgressBar(
percent: self.block.percent,
tint: self.progressColor,
accessibilityLabel: "Credit block usage")
HStack(alignment: .firstTextBaseline) {
Text(self.block.percentLabel)
.font(.footnote)
Spacer()
if self.block.isFree {
Text("Free")
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
} else if self.block.expiryText != nil {
Text(self.block.dateText)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
}
}
HStack(alignment: .firstTextBaseline) {
Text("\(self.block.balanceText) / \(self.block.amountText)")
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
if let expiry = self.block.expiryText {
Spacer()
Text(expiry)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
}
}
}
}
}

private struct ProviderCostContent: View {
let section: UsageMenuCardView.Model.ProviderCostSection
let progressColor: Color
Expand Down Expand Up @@ -615,7 +709,10 @@ extension UsageMenuCardView.Model {
provider: input.provider,
enabled: input.tokenCostUsageEnabled,
snapshot: input.tokenSnapshot,
error: input.tokenError)
error: input.tokenError,
inputSnapshot: input.snapshot)
let creditBlockItems = Self.creditBlockItems(snapshot: input.snapshot, showUsed: input.usageBarsShowUsed)
let autoTopUpText = input.snapshot?.kiloAutoTopUpText
let subtitle = Self.subtitle(
snapshot: input.snapshot,
isRefreshing: input.isRefreshing,
Expand All @@ -630,6 +727,8 @@ extension UsageMenuCardView.Model {
subtitleStyle: subtitle.style,
planText: planText,
metrics: metrics,
creditBlocks: creditBlockItems,
autoTopUpText: autoTopUpText,
creditsText: creditsText,
creditsRemaining: input.credits?.remaining,
creditsHintText: redacted.creditsHintText,
Expand Down Expand Up @@ -745,15 +844,20 @@ extension UsageMenuCardView.Model {
if let primary = snapshot.primary {
var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil
var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now)
if input.provider == .warp,
if input.provider == .warp || input.provider == .kilo,
let detail = primary.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
primaryDetailText = detail
}
if input.provider == .warp, primary.resetsAt == nil {
if input.provider == .warp || input.provider == .kilo, primary.resetsAt == nil {
primaryResetText = nil
}
// Show base/bonus boundary marker for Kilo
var primaryPacePercent: Double? = nil
if let marker = primary.markerPercent {
primaryPacePercent = input.usageBarsShowUsed ? marker : (100 - marker)
}
metrics.append(Metric(
id: "primary",
title: input.metadata.sessionLabel,
Expand All @@ -764,7 +868,7 @@ extension UsageMenuCardView.Model {
detailText: primaryDetailText,
detailLeftText: nil,
detailRightText: nil,
pacePercent: nil,
pacePercent: primaryPacePercent,
paceOnTop: true))
}
if let weekly = snapshot.secondary {
Expand All @@ -775,7 +879,7 @@ extension UsageMenuCardView.Model {
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,
if input.provider == .warp || input.provider == .kilo,
let detail = weekly.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
Expand Down Expand Up @@ -894,8 +998,24 @@ extension UsageMenuCardView.Model {
provider: UsageProvider,
enabled: Bool,
snapshot: CostUsageTokenSnapshot?,
error: String?) -> TokenUsageSection?
error: String?,
inputSnapshot: UsageSnapshot?) -> TokenUsageSection?
{
// Handle Kilo specially - show CLI cost stats if available
if provider == .kilo {
guard enabled else { return nil }
guard let kiloSnapshot = inputSnapshot else { return nil }
guard let cost = kiloSnapshot.providerCost, cost.used > 0 else { return nil }

let costLine = UsageFormatter.usdString(cost.used)
return TokenUsageSection(
sessionLine: "Total: \(costLine)",
monthLine: cost.period ?? "",
hintLine: nil,
errorLine: nil,
errorCopyText: nil)
}

guard provider == .codex || provider == .claude || provider == .vertexai else { return nil }
guard enabled else { return nil }
guard let snapshot else { return nil }
Expand Down Expand Up @@ -958,6 +1078,46 @@ extension UsageMenuCardView.Model {
spendLine: "\(periodLabel): \(used) / \(limit)")
}

private static let creditBlockDateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .medium
df.timeStyle = .none
return df
}()

private static func creditBlockItems(snapshot: UsageSnapshot?, showUsed: Bool) -> [CreditBlockItem]? {
guard let blocks = snapshot?.kiloCreditBlocks, !blocks.isEmpty else { return nil }

let dateFormatter = creditBlockDateFormatter

let items = blocks.map { block -> CreditBlockItem in
let dateText: String = {
if let d = block.effectiveDate { return dateFormatter.string(from: d) }
return block.effectiveDateString
}()
let expiryText: String? = {
guard let d = block.expiryDate else { return nil }
return "Expires \(dateFormatter.string(from: d))"
}()
let remaining = block.remainingFraction * 100
let percent = showUsed ? (100 - remaining) : remaining
let suffix = showUsed ? "used" : "left"
let percentLabel = String(format: "%.0f%% %@", percent, suffix)

return CreditBlockItem(
id: block.id,
amountText: UsageFormatter.usdString(block.amountDollars),
balanceText: UsageFormatter.usdString(block.balanceDollars),
dateText: dateText,
expiryText: expiryText,
percent: percent,
percentLabel: percentLabel,
isFree: block.isFree)
}

return items
}

private static func clamped(_ value: Double) -> Double {
min(100, max(0, value))
}
Expand Down
8 changes: 4 additions & 4 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ struct MenuDescriptor {
if let snap = store.snapshot(for: provider) {
let resetStyle = settings.resetTimeDisplayStyle
if let primary = snap.primary {
let primaryWindow = if provider == .warp {
// Warp primary uses resetDescription for non-reset detail (e.g., "Unlimited", "X/Y credits").
let primaryWindow = if provider == .warp || provider == .kilo {
// Warp and Kilo use resetDescription for non-reset detail (e.g., "Unlimited", "$X.XX").
// Avoid rendering it as a "Resets ..." line.
RateWindow(
usedPercent: primary.usedPercent,
Expand All @@ -132,7 +132,7 @@ struct MenuDescriptor {
window: primaryWindow,
resetStyle: resetStyle,
showUsed: settings.usageBarsShowUsed)
if provider == .warp,
if provider == .warp || provider == .kilo,
let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
!detail.isEmpty
{
Expand All @@ -141,7 +141,7 @@ struct MenuDescriptor {
}
if let weekly = snap.secondary {
let weeklyResetOverride: String? = {
guard provider == .warp else { return nil }
guard provider == .warp || provider == .kilo else { return nil }
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
return (detail?.isEmpty ?? true) ? nil : detail
}()
Expand Down
31 changes: 31 additions & 0 deletions Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import CodexBarCore
import CodexBarMacroSupport
import Foundation

@ProviderImplementationRegistration
struct KiloProviderImplementation: ProviderImplementation {
let id: UsageProvider = .kilo

@MainActor
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
ProviderPresentation { _ in "api" }
}

@MainActor
func observeSettings(_ settings: SettingsStore) {}

@MainActor
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
.kilo(ProviderSettingsSnapshot.KiloProviderSettings())
}

@MainActor
func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
[]
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ enum ProviderImplementationRegistry {
case .ollama: OllamaProviderImplementation()
case .synthetic: SyntheticProviderImplementation()
case .warp: WarpProviderImplementation()
case .kilo: KiloProviderImplementation()
}
}

Expand Down
6 changes: 6 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-kilo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading