From 9954e4e15aad6f4f41d7719a9d90e09e08032ba4 Mon Sep 17 00:00:00 2001 From: Marco Buono Date: Mon, 16 Feb 2026 13:27:29 -0300 Subject: [PATCH 1/5] Add Kilo provider --- Sources/CodexBar/IconRenderer.swift | 8 +- Sources/CodexBar/MenuCardView.swift | 171 ++++++- Sources/CodexBar/MenuDescriptor.swift | 8 +- .../Kilo/KiloProviderImplementation.swift | 31 ++ .../ProviderImplementationRegistry.swift | 1 + .../CodexBar/Resources/ProviderIcon-kilo.svg | 6 + .../StatusItemController+Animation.swift | 6 +- .../CodexBar/StatusItemController+Menu.swift | 6 +- Sources/CodexBar/UsageStore.swift | 8 + Sources/CodexBarCLI/CLIRenderer.swift | 19 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../Config/ProviderConfigEnvironment.swift | 2 + .../CodexBarCore/Logging/LogCategories.swift | 3 + .../Kilo/KiloProviderDescriptor.swift | 435 ++++++++++++++++++ .../Providers/Kilo/KiloSettingsReader.swift | 53 +++ .../Providers/Kilo/KiloUsageSnapshot.swift | 188 ++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 13 + .../Providers/ProviderTokenResolver.swift | 10 + .../CodexBarCore/Providers/Providers.swift | 2 + Sources/CodexBarCore/UsageFetcher.swift | 21 +- .../Vendored/CostUsage/CostUsageScanner.swift | 2 + .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + Tests/CodexBarTests/KiloProviderTests.swift | 50 ++ 25 files changed, 1020 insertions(+), 30 deletions(-) create mode 100644 Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-kilo.svg create mode 100644 Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Kilo/KiloSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/Kilo/KiloUsageSnapshot.swift create mode 100644 Tests/CodexBarTests/KiloProviderTests.swift diff --git a/Sources/CodexBar/IconRenderer.swift b/Sources/CodexBar/IconRenderer.swift index 648a3fc5f..8dbe319de 100644 --- a/Sources/CodexBar/IconRenderer.swift +++ b/Sources/CodexBar/IconRenderer.swift @@ -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). @@ -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 diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 89a930dc1..9f872e452 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -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? @@ -97,6 +110,7 @@ struct UsageMenuCardView: View { } } 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 @@ -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 { @@ -173,7 +196,7 @@ 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.providerCost != nil || !(self.model.creditBlocks ?? []).isEmpty } } @@ -277,6 +300,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 @@ -615,7 +704,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, @@ -630,6 +722,8 @@ extension UsageMenuCardView.Model { subtitleStyle: subtitle.style, planText: planText, metrics: metrics, + creditBlocks: creditBlockItems, + autoTopUpText: autoTopUpText, creditsText: creditsText, creditsRemaining: input.credits?.remaining, creditsHintText: redacted.creditsHintText, @@ -745,15 +839,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, @@ -764,7 +863,7 @@ extension UsageMenuCardView.Model { detailText: primaryDetailText, detailLeftText: nil, detailRightText: nil, - pacePercent: nil, + pacePercent: primaryPacePercent, paceOnTop: true)) } if let weekly = snapshot.secondary { @@ -775,7 +874,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 { @@ -894,8 +993,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 } @@ -958,6 +1073,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)) } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 495cbd7ef..6b6315544 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -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, @@ -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 { @@ -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 }() diff --git a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift new file mode 100644 index 000000000..cce90b2dd --- /dev/null +++ b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift @@ -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] { + [] + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index fef37cef9..440073138 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -31,6 +31,7 @@ enum ProviderImplementationRegistry { case .amp: AmpProviderImplementation() case .synthetic: SyntheticProviderImplementation() case .warp: WarpProviderImplementation() + case .kilo: KiloProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-kilo.svg b/Sources/CodexBar/Resources/ProviderIcon-kilo.svg new file mode 100644 index 000000000..609668fd7 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-kilo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 3c77d03e0..cdc4f5ea9 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -297,15 +297,15 @@ extension StatusItemController { var primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent var weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent if showUsed, - provider == .warp, + provider == .warp || provider == .kilo, let remaining = snapshot?.secondary?.remainingPercent, remaining <= 0 { - // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. + // Preserve Warp/Kilo "no bonus/exhausted bonus" layout even in show-used mode. weekly = 0 } if showUsed, - provider == .warp, + provider == .warp || provider == .kilo, let remaining = snapshot?.secondary?.remainingPercent, remaining > 0, weekly == 0 diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2508e25f0..31c7745a5 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -753,15 +753,15 @@ extension StatusItemController { let primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent var weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent if showUsed, - provider == .warp, + provider == .warp || provider == .kilo, let remaining = snapshot?.secondary?.remainingPercent, remaining <= 0 { - // Preserve Warp "no bonus/exhausted bonus" layout even in show-used mode. + // Preserve Warp/Kilo "no bonus/exhausted bonus" layout even in show-used mode. weekly = 0 } if showUsed, - provider == .warp, + provider == .warp || provider == .kilo, let remaining = snapshot?.secondary?.remainingPercent, remaining > 0, weekly == 0 diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index a763642dd..23273d0f8 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1252,6 +1252,14 @@ extension UsageStore { let text = "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" await MainActor.run { self.probeLogs[.warp] = text } return text + case .kilo: + let resolution = ProviderTokenResolver.kiloResolution() + let authFilePath = NSHomeDirectory() + "/.local/share/kilo/auth.json" + let hasAuthFile = FileManager.default.fileExists(atPath: authFilePath) + let envLabel = resolution != nil ? "present" : "missing" + let text = "KILO_API_KEY=\(envLabel) auth.json=\(hasAuthFile ? "present" : "missing")" + await MainActor.run { self.probeLogs[.kilo] = text } + return text } }.value } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 649ba00cc..32586f62b 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -21,11 +21,11 @@ enum CLIRenderer { if let primary = snapshot.primary { lines.append(self.rateLine(title: meta.sessionLabel, window: primary, useColor: context.useColor)) - if provider == .warp { - if let reset = self.resetLineForWarp(window: primary, style: context.resetStyle, now: now) { + if provider == .warp || provider == .kilo { + if let reset = self.resetLineStrippingDescription(window: primary, style: context.resetStyle, now: now) { lines.append(self.subtleLine(reset, useColor: context.useColor)) } - if let detail = self.detailLineForWarp(window: primary) { + if let detail = self.descriptionLine(window: primary) { lines.append(self.subtleLine(detail, useColor: context.useColor)) } } else if let reset = self.resetLine(for: primary, style: context.resetStyle, now: now) { @@ -43,11 +43,11 @@ enum CLIRenderer { if let pace = self.paceLine(provider: provider, window: weekly, useColor: context.useColor, now: now) { lines.append(pace) } - if provider == .warp { - if let reset = self.resetLineForWarp(window: weekly, style: context.resetStyle, now: now) { + if provider == .warp || provider == .kilo { + if let reset = self.resetLineStrippingDescription(window: weekly, style: context.resetStyle, now: now) { lines.append(self.subtleLine(reset, useColor: context.useColor)) } - if let detail = self.detailLineForWarp(window: weekly) { + if let detail = self.descriptionLine(window: weekly) { lines.append(self.subtleLine(detail, useColor: context.useColor)) } } else if let reset = self.resetLine(for: weekly, style: context.resetStyle, now: now) { @@ -98,8 +98,9 @@ enum CLIRenderer { UsageFormatter.resetLine(for: window, style: style, now: now) } - private static func resetLineForWarp(window: RateWindow, style: ResetTimeDisplayStyle, now: Date) -> String? { - // Warp uses resetDescription for non-reset detail. Only render "Resets ..." when a concrete reset date exists. + private static func resetLineStrippingDescription(window: RateWindow, style: ResetTimeDisplayStyle, now: Date) -> String? { + // Warp/Kilo use resetDescription for usage detail text, not reset info. + // Only render "Resets ..." when a concrete reset date exists. guard window.resetsAt != nil else { return nil } let resetOnlyWindow = RateWindow( usedPercent: window.usedPercent, @@ -109,7 +110,7 @@ enum CLIRenderer { return UsageFormatter.resetLine(for: resetOnlyWindow, style: style, now: now) } - private static func detailLineForWarp(window: RateWindow) -> String? { + private static func descriptionLine(window: RateWindow) -> String? { guard let desc = window.resetDescription else { return nil } let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 1b0bc5c8a..373de7ed5 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, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .warp, .kilo: return nil } } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 1969ba6ab..e0b47da97 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -25,6 +25,8 @@ public enum ProviderConfigEnvironment { if let key = WarpSettingsReader.apiKeyEnvironmentKeys.first { env[key] = apiKey } + case .kilo: + env[KiloSettingsReader.apiTokenKey] = apiKey default: break } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 550ffc814..dcc31628b 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -29,6 +29,9 @@ public enum LogCategories { public static let kimiTokenStore = "kimi-token-store" public static let kimiWeb = "kimi-web" public static let kiro = "kiro" + public static let kilo = "kilo" + public static let kiloAPI = "kilo-api" + public static let kiloCLI = "kilo-cli" public static let launchAtLogin = "launch-at-login" public static let login = "login" public static let logging = "logging" diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift new file mode 100644 index 000000000..d47b41233 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift @@ -0,0 +1,435 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum KiloProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .kilo, + metadata: ProviderMetadata( + id: .kilo, + displayName: "Kilo", + sessionLabel: "Kilo Pass", + weeklyLabel: "Plan", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "Kilo credits", + toggleTitle: "Show Kilo usage", + cliName: "kilo", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://app.kilo.ai/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .kilo, + iconResourceName: "ProviderIcon-kilo", + color: ProviderColor(red: 249 / 255, green: 247 / 255, blue: 110 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: true, + noDataMessage: { "Kilo cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api, .cli], + pipeline: ProviderFetchPipeline(resolveStrategies: { context in + if context.sourceMode == .cli { + return [KiloCLIFetchStrategy()] + } + // Default: try web API first, then CLI fallback + return [KiloWebAPIFetchStrategy(), KiloCLIFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "kilo", + aliases: ["kilo-ai"], + versionDetector: nil)) + } +} + +struct KiloWebAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "kilo.webapi" + let kind: ProviderFetchKind = .apiToken + + private static let batchedURL = URL(string: "https://app.kilo.ai/api/trpc/user.getCreditBlocks,kiloPass.getState,user.getAutoTopUpPaymentMethod?batch=1&input=%7B%220%22%3A%7B%7D%7D")! + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveAuthToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let token = Self.resolveAuthToken(environment: context.env) else { + throw KiloAPIError.missingToken + } + + var request = URLRequest(url: Self.batchedURL) + request.httpMethod = "GET" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw KiloAPIError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + throw KiloAPIError.apiError("HTTP \(httpResponse.statusCode)") + } + + // The batched response is an array, use JSONSerialization for flexible parsing + guard let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + json.count >= 2 else { + throw KiloAPIError.invalidResponse + } + + // Response[0] = user.getCreditBlocks -> result.data has creditBlocks, totalBalance_mUsd + // Response[1] = kiloPass.getState -> result.data has subscription + + let creditBlocksResponse = json[0] + let creditBlocksResult = creditBlocksResponse["result"] as? [String: Any] + let creditBlocksData = creditBlocksResult?["data"] as? [String: Any] + let totalBalanceMUsd = creditBlocksData?["totalBalance_mUsd"] as? Int ?? 0 + + // Parse individual credit blocks + var creditBlocks: [KiloCreditBlock] = [] + if let blocksArray = creditBlocksData?["creditBlocks"] as? [[String: Any]] { + let blockData = try JSONSerialization.data(withJSONObject: blocksArray) + creditBlocks = (try? JSONDecoder().decode([KiloCreditBlock].self, from: blockData)) ?? [] + } + + let kiloPassResponse = json[1] + let kiloPassResult = kiloPassResponse["result"] as? [String: Any] + let kiloPassData = kiloPassResult?["data"] as? [String: Any] + let subscriptionData = kiloPassData?["subscription"] as? [String: Any] + + // Parse auto-top-up from credit blocks response + let autoTopUpEnabled = creditBlocksData?["autoTopUpEnabled"] as? Bool ?? false + + // Try to parse response[2] for auto-top-up payment method details + var autoTopUpAmountDollars: Double = 0 + if json.count >= 3 { + let autoTopUpResponse = json[2] + let autoTopUpResult = autoTopUpResponse["result"] as? [String: Any] + let autoTopUpData = autoTopUpResult?["data"] as? [String: Any] + if let amountCents = autoTopUpData?["amountCents"] as? Int { + autoTopUpAmountDollars = Double(amountCents) / 100.0 + } else if let amount = autoTopUpData?["amount"] as? Double { + autoTopUpAmountDollars = amount + } + } + + let autoTopUp: KiloAutoTopUp? = autoTopUpEnabled + ? KiloAutoTopUp(enabled: true, amountDollars: autoTopUpAmountDollars) + : nil + + let totalBalanceDollars = Double(totalBalanceMUsd) / 1_000_000.0 + + // Parse subscription data + var periodUsageDollars: Double = 0 + var periodBaseCredits: Double = 0 + var periodBonusCredits: Double = 0 + var periodResetsAt: Date? = nil + var planName: String? = nil + + if let sub = subscriptionData { + periodUsageDollars = sub["currentPeriodUsageUsd"] as? Double ?? 0 + periodBaseCredits = sub["currentPeriodBaseCreditsUsd"] as? Double ?? 0 + periodBonusCredits = sub["currentPeriodBonusCreditsUsd"] as? Double ?? 0 + + // Derive plan name from tier (tier_19 → Starter, tier_49 → Pro, tier_199 → Expert) + let tier = sub["tier"] as? String + let knownTiers: [String: String] = [ + "tier_19": "Starter", + "tier_49": "Pro", + "tier_199": "Expert", + ] + if let tier { + planName = knownTiers[tier] ?? tier + } else { + planName = "Kilo Pass" + } + + // Parse next billing date + if let nextBilling = sub["nextBillingAt"] as? String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: nextBilling) { + periodResetsAt = date + } else { + // Try without fractional seconds + formatter.formatOptions = [.withInternetDateTime] + periodResetsAt = formatter.date(from: nextBilling) + } + } + } + + // Try to get CLI stats for additional info + var cliCost: Double = 0 + var cliSessions: Int = 0 + var cliMessages: Int = 0 + + var cliInputTokens: Int = 0 + var cliOutputTokens: Int = 0 + var cliCacheReadTokens: Int = 0 + + if let cliOutput = try? await KiloCLIFetchStrategy.runKiloStatsInternal(env: [:]) { + let parsed = KiloCLIFetchStrategy.parseCLIStatsOutputInternal(cliOutput) + cliCost = parsed.totalCost + cliSessions = parsed.sessions + cliMessages = parsed.messages + cliInputTokens = parsed.inputTokens + cliOutputTokens = parsed.outputTokens + cliCacheReadTokens = parsed.cacheReadTokens + } + + let snapshot = KiloUsageSnapshot( + balanceDollars: totalBalanceDollars, + periodBaseCredits: periodBaseCredits, + periodBonusCredits: periodBonusCredits, + periodUsageDollars: periodUsageDollars, + periodResetsAt: periodResetsAt, + hasSubscription: subscriptionData != nil, + planName: planName, + creditBlocks: creditBlocks, + autoTopUp: autoTopUp, + cliCostDollars: cliCost, + cliSessions: cliSessions, + cliMessages: cliMessages, + cliInputTokens: cliInputTokens, + cliOutputTokens: cliOutputTokens, + cliCacheReadTokens: cliCacheReadTokens, + updatedAt: Date()) + + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + true + } + + private static func resolveAuthToken(environment: [String: String]) -> String? { + // KILO_API_KEY env var (or settings override) takes priority + if let token = ProviderTokenResolver.kiloToken(environment: environment) { + return token + } + // Fall back to Kilo CLI's auth.json session token + let authFilePath = NSHomeDirectory() + "/.local/share/kilo/auth.json" + guard let data = FileManager.default.contents(atPath: authFilePath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let kilo = json["kilo"] as? [String: Any], + let access = kilo["access"] as? String + else { + return nil + } + return access + } + +} + +struct KiloCLIFetchStrategy: ProviderFetchStrategy { + let id: String = "kilo.cli" + let kind: ProviderFetchKind = .cli + + private static let logger = CodexBarLog.logger(LogCategories.kiloCLI) + + func isAvailable(_: ProviderFetchContext) async -> Bool { + Self.locateKiloBinary() != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let output = try await Self.runKiloStatsInternal(env: context.env) + let snapshot = try Self.parseStatsOutput(output) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "cli") + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + if error is KiloCLIError { + return false + } + return true + } + + static func runKiloStatsInternal(env: [String: String]) async throws -> String { + var fullEnv = env + fullEnv["PATH"] = PathBuilder.effectivePATH( + purposes: [.tty, .nodeTooling], + env: fullEnv) + + let result = try await SubprocessRunner.run( + binary: "/usr/bin/env", + arguments: ["kilo", "stats"], + environment: fullEnv, + timeout: 10.0, + label: "kilo-stats") + + guard result.stdout.isEmpty == false else { + throw KiloCLIError.parseError("Empty output") + } + + return result.stdout + } + + struct CLIStats { + var totalCost: Double = 0 + var sessions: Int = 0 + var messages: Int = 0 + var inputTokens: Int = 0 + var outputTokens: Int = 0 + var cacheReadTokens: Int = 0 + } + + static func parseCLIStatsOutputInternal(_ output: String) -> CLIStats { + let lines = output.components(separatedBy: .newlines) + var stats = CLIStats() + + for line in lines { + let asciiLine = line.unicodeScalars + .filter { $0.value < 128 } + .map { String($0) } + .joined() + + if asciiLine.contains("Total Cost") { + if let range = asciiLine.range(of: "\\$([0-9.]+)", options: .regularExpression) { + stats.totalCost = Double(String(asciiLine[range].dropFirst())) ?? 0 + } + } else if asciiLine.contains("Sessions") { + stats.sessions = Self.parseIntField(asciiLine, key: "Sessions") + } else if asciiLine.contains("Messages") { + stats.messages = Self.parseIntField(asciiLine, key: "Messages") + } else if Self.lineStartsWith(asciiLine, key: "Input") { + stats.inputTokens = Self.parseSuffixedNumber(asciiLine) + } else if Self.lineStartsWith(asciiLine, key: "Output") { + stats.outputTokens = Self.parseSuffixedNumber(asciiLine) + } else if asciiLine.contains("Cache Read") { + stats.cacheReadTokens = Self.parseSuffixedNumber(asciiLine) + } + } + + return stats + } + + /// Parse "1.4M" / "46.1K" / "20.2M" / "0" style token counts. + private static func parseSuffixedNumber(_ line: String) -> Int { + let components = line.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + guard let last = components.last else { return 0 } + return Self.parseTokenCount(last) + } + + static func parseTokenCount(_ text: String) -> Int { + let trimmed = text.trimmingCharacters(in: .whitespaces) + if trimmed == "0" { return 0 } + let multipliers: [(String, Double)] = [("B", 1_000_000_000), ("M", 1_000_000), ("K", 1_000)] + for (suffix, mult) in multipliers { + if trimmed.hasSuffix(suffix), let num = Double(trimmed.dropLast(suffix.count)) { + return Int(num * mult) + } + } + return Int(trimmed) ?? 0 + } + + /// Check if a line's first non-whitespace word matches the key exactly. + private static func lineStartsWith(_ line: String, key: String) -> Bool { + let components = line.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + return components.first == key + } + + private static func parseIntField(_ line: String, key: String) -> Int { + let components = line.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + if let idx = components.firstIndex(of: key), idx + 1 < components.count { + return Int(components[idx + 1]) ?? 0 + } + return 0 + } + + private static func locateKiloBinary() -> String? { + if let path = TTYCommandRunner.which("kilo") { + return path + } + return ShellCommandLocator.commandV("kilo", "/bin/zsh", 2.0, FileManager.default) + } + + private static func parseStatsOutput(_ output: String) throws -> KiloUsageSnapshot { + let stats = Self.parseCLIStatsOutputInternal(output) + return KiloUsageSnapshot( + cliCostDollars: stats.totalCost, + cliSessions: stats.sessions, + cliMessages: stats.messages, + cliInputTokens: stats.inputTokens, + cliOutputTokens: stats.outputTokens, + cliCacheReadTokens: stats.cacheReadTokens, + updatedAt: Date()) + } +} + +public enum KiloCLIError: LocalizedError, Sendable { + case cliNotFound + case cliFailed(String) + case parseError(String) + case timeout + + public var errorDescription: String? { + switch self { + case .cliNotFound: + "kilo CLI not found. Install it with npm install -g @kilocode/cli" + case let .cliFailed(message): + message + case let .parseError(message): + "Failed to parse Kilo stats: \(message)" + case .timeout: + "Kilo CLI timed out." + } + } +} + +public struct KiloCreditBlock: Codable, Sendable { + public let id: String + public let effectiveDateString: String + public let expiryDateString: String? + public let balanceMUsd: Int + public let amountMUsd: Int + public let isFree: Bool + + private enum CodingKeys: String, CodingKey { + case id + case effectiveDateString = "effective_date" + case expiryDateString = "expiry_date" + case balanceMUsd = "balance_mUsd" + case amountMUsd = "amount_mUsd" + case isFree = "is_free" + } + + /// Balance in dollars. + public var balanceDollars: Double { Double(balanceMUsd) / 1_000_000.0 } + /// Original amount in dollars. + public var amountDollars: Double { Double(amountMUsd) / 1_000_000.0 } + /// Remaining fraction (0…1). + public var remainingFraction: Double { + guard amountMUsd > 0 else { return 0 } + return Double(balanceMUsd) / Double(amountMUsd) + } + + /// Parsed effective date. + public var effectiveDate: Date? { Self.parseDate(effectiveDateString) } + /// Parsed expiry date. + public var expiryDate: Date? { expiryDateString.flatMap { Self.parseDate($0) } } + + private static func parseDate(_ string: String) -> Date? { + // "2026-02-15 21:33:29.596883+00" or ISO8601 + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = iso.date(from: string) { return d } + iso.formatOptions = [.withInternetDateTime] + if let d = iso.date(from: string) { return d } + // Postgres-style with space separator + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POSIX") + df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSSZZZ" + if let d = df.date(from: string) { return d } + df.dateFormat = "yyyy-MM-dd HH:mm:ssZZZ" + return df.date(from: string) + } +} diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloSettingsReader.swift b/Sources/CodexBarCore/Providers/Kilo/KiloSettingsReader.swift new file mode 100644 index 000000000..537a1963a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kilo/KiloSettingsReader.swift @@ -0,0 +1,53 @@ +import Foundation + +public enum KiloSettingsReader { + public static let apiTokenKey = "KILO_API_KEY" + + public static func apiToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.apiTokenKey]) + } + + 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 + } +} + +public enum KiloAPIError: LocalizedError, Sendable { + case missingToken + case invalidToken + case networkError(String) + case apiError(String) + case parseFailed(String) + case invalidResponse + + public var errorDescription: String? { + switch self { + case .missingToken: + "Kilo API token not found. Set KILO_API_KEY environment variable." + case .invalidToken: + "Invalid Kilo API token." + case let .networkError(message): + "Kilo network error: \(message)" + case let .apiError(message): + "Kilo API error: \(message)" + case let .parseFailed(message): + "Failed to parse Kilo response: \(message)" + case .invalidResponse: + "Invalid response from Kilo API" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Kilo/KiloUsageSnapshot.swift new file mode 100644 index 000000000..a1c46c59e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kilo/KiloUsageSnapshot.swift @@ -0,0 +1,188 @@ +import Foundation + +public struct KiloAutoTopUp: Sendable { + public let enabled: Bool + public let amountDollars: Double + + public init(enabled: Bool, amountDollars: Double) { + self.enabled = enabled + self.amountDollars = amountDollars + } +} + +public struct KiloUsageSnapshot: Sendable { + public let balanceDollars: Double // Current credit balance from API + public let periodBaseCredits: Double // Base credits in plan ($19) + public let periodBonusCredits: Double // Bonus credits ($9.50) + public let periodUsageDollars: Double // Usage this period + public let periodResetsAt: Date? // Next billing date + public let hasSubscription: Bool // Whether user has active Kilo Pass + public let planName: String? // Subscription plan name (e.g. "Starter") + public let creditBlocks: [KiloCreditBlock] // Individual credit blocks from API + public let autoTopUp: KiloAutoTopUp? // Auto top-up settings + public let cliCostDollars: Double // CLI total cost + public let cliSessions: Int // CLI sessions + public let cliMessages: Int // CLI messages + public let cliInputTokens: Int // CLI input tokens + public let cliOutputTokens: Int // CLI output tokens + public let cliCacheReadTokens: Int // CLI cache read tokens + public let updatedAt: Date + + public init( + balanceDollars: Double = 0, + periodBaseCredits: Double = 0, + periodBonusCredits: Double = 0, + periodUsageDollars: Double = 0, + periodResetsAt: Date? = nil, + hasSubscription: Bool = false, + planName: String? = nil, + creditBlocks: [KiloCreditBlock] = [], + autoTopUp: KiloAutoTopUp? = nil, + cliCostDollars: Double = 0, + cliSessions: Int = 0, + cliMessages: Int = 0, + cliInputTokens: Int = 0, + cliOutputTokens: Int = 0, + cliCacheReadTokens: Int = 0, + updatedAt: Date) + { + self.balanceDollars = balanceDollars + self.periodBaseCredits = periodBaseCredits + self.periodBonusCredits = periodBonusCredits + self.periodUsageDollars = periodUsageDollars + self.periodResetsAt = periodResetsAt + self.hasSubscription = hasSubscription + self.planName = planName + self.creditBlocks = creditBlocks + self.autoTopUp = autoTopUp + self.cliCostDollars = cliCostDollars + self.cliSessions = cliSessions + self.cliMessages = cliMessages + self.cliInputTokens = cliInputTokens + self.cliOutputTokens = cliOutputTokens + self.cliCacheReadTokens = cliCacheReadTokens + self.updatedAt = updatedAt + } +} + +extension KiloUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let identity = ProviderIdentitySnapshot( + providerID: .kilo, + accountEmail: nil, + accountOrganization: nil, + loginMethod: planName) + + // Only show Kilo Pass usage bar if user has an active subscription + var primary: RateWindow? = nil + if hasSubscription { + let totalCredits = periodBaseCredits + periodBonusCredits + var usedPercent: Double = 0 + var resetDesc: String + var bonusMarkerPercent: Double? = nil + + if totalCredits > 0 { + usedPercent = min(100, (periodUsageDollars / totalCredits) * 100) + if periodBonusCredits > 0 { + resetDesc = String(format: "$%.2f / $%.2f (+ $%.2f bonus)", periodUsageDollars, periodBaseCredits, periodBonusCredits) + // Marker at the boundary between bonus (consumed first) and base credits + bonusMarkerPercent = (periodBonusCredits / totalCredits) * 100 + } else { + resetDesc = String(format: "$%.2f / $%.2f", periodUsageDollars, periodBaseCredits) + } + } else { + resetDesc = String(format: "$%.2f / $%.2f", periodUsageDollars, periodBaseCredits) + } + + primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: periodResetsAt, + resetDescription: resetDesc, + markerPercent: bonusMarkerPercent) + } + + // Provider cost carries CLI stats for display in the "Cost" section + var providerCost: ProviderCostSnapshot? = nil + if cliCostDollars > 0 || cliSessions > 0 { + var detailParts: [String] = [] + if cliSessions > 0 { detailParts.append("\(cliSessions) sessions") } + if cliMessages > 0 { detailParts.append("\(cliMessages) messages") } + let totalTokens = cliInputTokens + cliOutputTokens + cliCacheReadTokens + if totalTokens > 0 { detailParts.append("\(UsageFormatter.tokenCountString(totalTokens)) tokens") } + let detailLine = detailParts.isEmpty ? nil : detailParts.joined(separator: " · ") + + // limit: 0 ensures providerCostSection() skips the "Extra usage" progress bar. + // The cost data is instead read by tokenUsageSection() for a text-only "Cost" display. + providerCost = ProviderCostSnapshot( + used: cliCostDollars, + limit: 0, + currencyCode: "USD", + period: detailLine, + resetsAt: nil, + updatedAt: updatedAt) + } + + // Consolidate credit blocks: merge non-expiring into one, keep expiring separate + let consolidatedBlocks = Self.consolidateCreditBlocks(creditBlocks) + + // Auto top-up text + var autoTopUpText: String? = nil + if let topUp = autoTopUp, topUp.enabled { + if topUp.amountDollars > 0 { + autoTopUpText = String(format: "Auto top-up: $%.0f", topUp.amountDollars) + } else { + autoTopUpText = "Auto top-up: On" + } + } + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: providerCost, + kiloCreditBlocks: consolidatedBlocks.isEmpty ? nil : consolidatedBlocks, + kiloAutoTopUpText: autoTopUpText, + updatedAt: updatedAt, + identity: identity) + } + + /// Merge all non-expiring credit blocks into a single entry; keep expiring ones individual. + private static func consolidateCreditBlocks(_ blocks: [KiloCreditBlock]) -> [KiloCreditBlock] { + var expiring: [KiloCreditBlock] = [] + var permanentBalance: Int = 0 + var permanentAmount: Int = 0 + var permanentDate: String = "" + var permanentCount: Int = 0 + var permanentFreeCount: Int = 0 + + for block in blocks { + if block.expiryDateString != nil { + expiring.append(block) + } else { + permanentBalance += block.balanceMUsd + permanentAmount += block.amountMUsd + permanentCount += 1 + if block.isFree { permanentFreeCount += 1 } + // Use the earliest effective date for the consolidated entry + if permanentDate.isEmpty || block.effectiveDateString < permanentDate { + permanentDate = block.effectiveDateString + } + } + } + + var result: [KiloCreditBlock] = [] + if permanentAmount > 0 { + let allFree = permanentCount > 0 && permanentFreeCount == permanentCount + result.append(KiloCreditBlock( + id: "consolidated-permanent", + effectiveDateString: permanentDate, + expiryDateString: nil, + balanceMUsd: permanentBalance, + amountMUsd: permanentAmount, + isFree: allFree)) + } + result.append(contentsOf: expiring) + return result + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 80e552223..4ffefc46b 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -72,6 +72,7 @@ public enum ProviderDescriptorRegistry { .amp: AmpProviderDescriptor.descriptor, .synthetic: SyntheticProviderDescriptor.descriptor, .warp: WarpProviderDescriptor.descriptor, + .kilo: KiloProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index f83cb9fd1..b3e70209d 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -15,6 +15,7 @@ public struct ProviderSettingsSnapshot: Sendable { kimi: KimiProviderSettings? = nil, augment: AugmentProviderSettings? = nil, amp: AmpProviderSettings? = nil, + kilo: KiloProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( @@ -31,6 +32,7 @@ public struct ProviderSettingsSnapshot: Sendable { kimi: kimi, augment: augment, amp: amp, + kilo: kilo, jetbrains: jetbrains) } @@ -167,6 +169,10 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct KiloProviderSettings: Sendable { + public init() {} + } + public let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? @@ -180,6 +186,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let kimi: KimiProviderSettings? public let augment: AugmentProviderSettings? public let amp: AmpProviderSettings? + public let kilo: KiloProviderSettings? public let jetbrains: JetBrainsProviderSettings? public var jetbrainsIDEBasePath: String? { @@ -200,6 +207,7 @@ public struct ProviderSettingsSnapshot: Sendable { kimi: KimiProviderSettings?, augment: AugmentProviderSettings?, amp: AmpProviderSettings?, + kilo: KiloProviderSettings?, jetbrains: JetBrainsProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled @@ -215,6 +223,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.kimi = kimi self.augment = augment self.amp = amp + self.kilo = kilo self.jetbrains = jetbrains } } @@ -231,6 +240,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case kimi(ProviderSettingsSnapshot.KimiProviderSettings) case augment(ProviderSettingsSnapshot.AugmentProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) + case kilo(ProviderSettingsSnapshot.KiloProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) } @@ -248,6 +258,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var kimi: ProviderSettingsSnapshot.KimiProviderSettings? public var augment: ProviderSettingsSnapshot.AugmentProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? + public var kilo: ProviderSettingsSnapshot.KiloProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { @@ -268,6 +279,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .kimi(value): self.kimi = value case let .augment(value): self.augment = value case let .amp(value): self.amp = value + case let .kilo(value): self.kilo = value case let .jetbrains(value): self.jetbrains = value } } @@ -287,6 +299,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { kimi: self.kimi, augment: self.augment, amp: self.amp, + kilo: self.kilo, jetbrains: self.jetbrains) } } diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index 4134d67bf..14af5e0a5 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -49,6 +49,10 @@ public enum ProviderTokenResolver { self.warpResolution(environment: environment)?.token } + public static func kiloToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.kiloResolution(environment: environment)?.token + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -110,6 +114,12 @@ public enum ProviderTokenResolver { self.resolveEnv(WarpSettingsReader.apiKey(environment: environment)) } + public static func kiloResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(KiloSettingsReader.apiToken(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 3fc0de98c..21e981a32 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -22,6 +22,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case amp case synthetic case warp + case kilo } // swiftformat:enable sortDeclarations @@ -46,6 +47,7 @@ public enum IconStyle: Sendable, CaseIterable { case amp case synthetic case warp + case kilo case combined } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index ca300ea9f..a4083fe74 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -6,12 +6,21 @@ public struct RateWindow: Codable, Equatable, Sendable { public let resetsAt: Date? /// Optional textual reset description (used by Claude CLI UI scrape). public let resetDescription: String? + /// Optional marker percent for the progress bar (e.g. base/bonus credit boundary). + public let markerPercent: Double? - public init(usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, resetDescription: String?) { + public init( + usedPercent: Double, + windowMinutes: Int?, + resetsAt: Date?, + resetDescription: String?, + markerPercent: Double? = nil) + { self.usedPercent = usedPercent self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription + self.markerPercent = markerPercent } public var remainingPercent: Double { @@ -55,6 +64,8 @@ public struct UsageSnapshot: Codable, Sendable { public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? public let cursorRequests: CursorRequestUsage? + public let kiloCreditBlocks: [KiloCreditBlock]? + public let kiloAutoTopUpText: String? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? @@ -78,6 +89,8 @@ public struct UsageSnapshot: Codable, Sendable { zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, + kiloCreditBlocks: [KiloCreditBlock]? = nil, + kiloAutoTopUpText: String? = nil, updatedAt: Date, identity: ProviderIdentitySnapshot? = nil) { @@ -88,6 +101,8 @@ public struct UsageSnapshot: Codable, Sendable { self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage self.cursorRequests = cursorRequests + self.kiloCreditBlocks = kiloCreditBlocks + self.kiloAutoTopUpText = kiloAutoTopUpText self.updatedAt = updatedAt self.identity = identity } @@ -101,6 +116,8 @@ public struct UsageSnapshot: Codable, Sendable { self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time self.cursorRequests = nil // Not persisted, fetched fresh each time + self.kiloCreditBlocks = nil // Not persisted, fetched fresh each time + self.kiloAutoTopUpText = nil // Not persisted, fetched fresh each time self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { self.identity = identity @@ -184,6 +201,8 @@ public struct UsageSnapshot: Codable, Sendable { zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, cursorRequests: self.cursorRequests, + kiloCreditBlocks: self.kiloCreditBlocks, + kiloAutoTopUpText: self.kiloAutoTopUpText, updatedAt: self.updatedAt, identity: scopedIdentity) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 1936d7eff..8d9872b41 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -101,6 +101,8 @@ enum CostUsageScanner { return CostUsageDailyReport(data: [], summary: nil) case .warp: return CostUsageDailyReport(data: [], summary: nil) + case .kilo: + return CostUsageDailyReport(data: [], summary: nil) } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 0d46a0510..42b834bd1 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -60,6 +60,7 @@ enum ProviderChoice: String, AppEnum { 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 + case .kilo: return nil // Kilo not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 85ae62f42..ec5c9846d 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -276,6 +276,7 @@ private struct ProviderSwitchChip: View { case .amp: "Amp" case .synthetic: "Synthetic" case .warp: "Warp" + case .kilo: "Kilo" } } } @@ -608,6 +609,8 @@ enum WidgetColors { Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) + case .kilo: + Color(red: 249 / 255, green: 247 / 255, blue: 110 / 255) } } } diff --git a/Tests/CodexBarTests/KiloProviderTests.swift b/Tests/CodexBarTests/KiloProviderTests.swift new file mode 100644 index 000000000..dc7a47350 --- /dev/null +++ b/Tests/CodexBarTests/KiloProviderTests.swift @@ -0,0 +1,50 @@ +import CodexBarCore +import Testing + +@Suite +struct KiloSettingsReaderTests { + @Test + func readsTokenFromEnvironmentVariable() { + let env = ["KILO_API_KEY": "test-api-key"] + let token = KiloSettingsReader.apiToken(environment: env) + #expect(token == "test-api-key") + } + + @Test + func returnsNilWhenMissing() { + let env: [String: String] = [:] + let token = KiloSettingsReader.apiToken(environment: env) + #expect(token == nil) + } + + @Test + func apiTokenStripsQuotes() { + let env = ["KILO_API_KEY": "\"quoted-token\""] + let token = KiloSettingsReader.apiToken(environment: env) + #expect(token == "quoted-token") + } + + @Test + func normalizesQuotedToken() { + let env = ["KILO_API_KEY": "'single-quoted'"] + let token = KiloSettingsReader.apiToken(environment: env) + #expect(token == "single-quoted") + } +} + +@Suite +struct KiloTokenResolverTests { + @Test + func resolvesTokenFromEnvironment() { + let env = ["KILO_API_KEY": "test-api-key"] + let token = ProviderTokenResolver.kiloToken(environment: env) + #expect(token == "test-api-key") + } + + @Test + func returnsNilWhenMissing() { + let env: [String: String] = [:] + let token = ProviderTokenResolver.kiloToken(environment: env) + #expect(token == nil) + } +} From c348790ce6e7a7ce936177e4761cd6ad14f30d70 Mon Sep 17 00:00:00 2001 From: Marco Buono Date: Mon, 16 Feb 2026 13:41:23 -0300 Subject: [PATCH 2/5] Improve test coverage for Kilo --- .../Kilo/KiloProviderDescriptor.swift | 113 ++--- Tests/CodexBarTests/KiloProviderTests.swift | 446 ++++++++++++++++++ 2 files changed, 496 insertions(+), 63 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift index d47b41233..970af5b7a 100644 --- a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift @@ -76,55 +76,78 @@ struct KiloWebAPIFetchStrategy: ProviderFetchStrategy { throw KiloAPIError.apiError("HTTP \(httpResponse.statusCode)") } - // The batched response is an array, use JSONSerialization for flexible parsing + let snapshot = try Self._parseBatchedResponse(data, now: Date()) + + // Try to enrich with CLI stats + var enriched = snapshot + if let cliOutput = try? await KiloCLIFetchStrategy.runKiloStatsInternal(env: [:]) { + let parsed = KiloCLIFetchStrategy.parseCLIStatsOutputInternal(cliOutput) + enriched = KiloUsageSnapshot( + balanceDollars: snapshot.balanceDollars, + periodBaseCredits: snapshot.periodBaseCredits, + periodBonusCredits: snapshot.periodBonusCredits, + periodUsageDollars: snapshot.periodUsageDollars, + periodResetsAt: snapshot.periodResetsAt, + hasSubscription: snapshot.hasSubscription, + planName: snapshot.planName, + creditBlocks: snapshot.creditBlocks, + autoTopUp: snapshot.autoTopUp, + cliCostDollars: parsed.totalCost, + cliSessions: parsed.sessions, + cliMessages: parsed.messages, + cliInputTokens: parsed.inputTokens, + cliOutputTokens: parsed.outputTokens, + cliCacheReadTokens: parsed.cacheReadTokens, + updatedAt: snapshot.updatedAt) + } + + return self.makeResult( + usage: enriched.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + true + } + + /// Parse a batched tRPC response into a KiloUsageSnapshot (without CLI stats). + /// Exposed as internal for testing. + static func _parseBatchedResponse(_ data: Data, now: Date = Date()) throws -> KiloUsageSnapshot { guard let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], json.count >= 2 else { throw KiloAPIError.invalidResponse } - // Response[0] = user.getCreditBlocks -> result.data has creditBlocks, totalBalance_mUsd - // Response[1] = kiloPass.getState -> result.data has subscription - - let creditBlocksResponse = json[0] - let creditBlocksResult = creditBlocksResponse["result"] as? [String: Any] - let creditBlocksData = creditBlocksResult?["data"] as? [String: Any] - let totalBalanceMUsd = creditBlocksData?["totalBalance_mUsd"] as? Int ?? 0 + // Response[0] = user.getCreditBlocks + let creditBlocksResult = (json[0]["result"] as? [String: Any])?["data"] as? [String: Any] + let totalBalanceMUsd = creditBlocksResult?["totalBalance_mUsd"] as? Int ?? 0 - // Parse individual credit blocks var creditBlocks: [KiloCreditBlock] = [] - if let blocksArray = creditBlocksData?["creditBlocks"] as? [[String: Any]] { + if let blocksArray = creditBlocksResult?["creditBlocks"] as? [[String: Any]] { let blockData = try JSONSerialization.data(withJSONObject: blocksArray) creditBlocks = (try? JSONDecoder().decode([KiloCreditBlock].self, from: blockData)) ?? [] } - let kiloPassResponse = json[1] - let kiloPassResult = kiloPassResponse["result"] as? [String: Any] - let kiloPassData = kiloPassResult?["data"] as? [String: Any] + // Response[1] = kiloPass.getState + let kiloPassData = ((json[1]["result"] as? [String: Any])?["data"] as? [String: Any]) let subscriptionData = kiloPassData?["subscription"] as? [String: Any] - // Parse auto-top-up from credit blocks response - let autoTopUpEnabled = creditBlocksData?["autoTopUpEnabled"] as? Bool ?? false - - // Try to parse response[2] for auto-top-up payment method details + // Auto-top-up from credit blocks response + optional response[2] + let autoTopUpEnabled = creditBlocksResult?["autoTopUpEnabled"] as? Bool ?? false var autoTopUpAmountDollars: Double = 0 if json.count >= 3 { - let autoTopUpResponse = json[2] - let autoTopUpResult = autoTopUpResponse["result"] as? [String: Any] - let autoTopUpData = autoTopUpResult?["data"] as? [String: Any] + let autoTopUpData = ((json[2]["result"] as? [String: Any])?["data"] as? [String: Any]) if let amountCents = autoTopUpData?["amountCents"] as? Int { autoTopUpAmountDollars = Double(amountCents) / 100.0 } else if let amount = autoTopUpData?["amount"] as? Double { autoTopUpAmountDollars = amount } } - let autoTopUp: KiloAutoTopUp? = autoTopUpEnabled ? KiloAutoTopUp(enabled: true, amountDollars: autoTopUpAmountDollars) : nil - let totalBalanceDollars = Double(totalBalanceMUsd) / 1_000_000.0 - - // Parse subscription data + // Parse subscription var periodUsageDollars: Double = 0 var periodBaseCredits: Double = 0 var periodBonusCredits: Double = 0 @@ -136,7 +159,6 @@ struct KiloWebAPIFetchStrategy: ProviderFetchStrategy { periodBaseCredits = sub["currentPeriodBaseCreditsUsd"] as? Double ?? 0 periodBonusCredits = sub["currentPeriodBonusCreditsUsd"] as? Double ?? 0 - // Derive plan name from tier (tier_19 → Starter, tier_49 → Pro, tier_199 → Expert) let tier = sub["tier"] as? String let knownTiers: [String: String] = [ "tier_19": "Starter", @@ -149,41 +171,20 @@ struct KiloWebAPIFetchStrategy: ProviderFetchStrategy { planName = "Kilo Pass" } - // Parse next billing date if let nextBilling = sub["nextBillingAt"] as? String { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: nextBilling) { periodResetsAt = date } else { - // Try without fractional seconds formatter.formatOptions = [.withInternetDateTime] periodResetsAt = formatter.date(from: nextBilling) } } } - // Try to get CLI stats for additional info - var cliCost: Double = 0 - var cliSessions: Int = 0 - var cliMessages: Int = 0 - - var cliInputTokens: Int = 0 - var cliOutputTokens: Int = 0 - var cliCacheReadTokens: Int = 0 - - if let cliOutput = try? await KiloCLIFetchStrategy.runKiloStatsInternal(env: [:]) { - let parsed = KiloCLIFetchStrategy.parseCLIStatsOutputInternal(cliOutput) - cliCost = parsed.totalCost - cliSessions = parsed.sessions - cliMessages = parsed.messages - cliInputTokens = parsed.inputTokens - cliOutputTokens = parsed.outputTokens - cliCacheReadTokens = parsed.cacheReadTokens - } - - let snapshot = KiloUsageSnapshot( - balanceDollars: totalBalanceDollars, + return KiloUsageSnapshot( + balanceDollars: Double(totalBalanceMUsd) / 1_000_000.0, periodBaseCredits: periodBaseCredits, periodBonusCredits: periodBonusCredits, periodUsageDollars: periodUsageDollars, @@ -192,21 +193,7 @@ struct KiloWebAPIFetchStrategy: ProviderFetchStrategy { planName: planName, creditBlocks: creditBlocks, autoTopUp: autoTopUp, - cliCostDollars: cliCost, - cliSessions: cliSessions, - cliMessages: cliMessages, - cliInputTokens: cliInputTokens, - cliOutputTokens: cliOutputTokens, - cliCacheReadTokens: cliCacheReadTokens, - updatedAt: Date()) - - return self.makeResult( - usage: snapshot.toUsageSnapshot(), - sourceLabel: "api") - } - - func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { - true + updatedAt: now) } private static func resolveAuthToken(environment: [String: String]) -> String? { diff --git a/Tests/CodexBarTests/KiloProviderTests.swift b/Tests/CodexBarTests/KiloProviderTests.swift index dc7a47350..dcedad470 100644 --- a/Tests/CodexBarTests/KiloProviderTests.swift +++ b/Tests/CodexBarTests/KiloProviderTests.swift @@ -1,5 +1,9 @@ import CodexBarCore +import Foundation import Testing +@testable import CodexBarCore + +// MARK: - Settings & Token Resolver @Suite struct KiloSettingsReaderTests { @@ -48,3 +52,445 @@ struct KiloTokenResolverTests { #expect(token == nil) } } + +// MARK: - CLI Stats Parser + +@Suite +struct KiloCLIStatsParserTests { + @Test + func parsesRealCLIOutput() { + let output = """ + ┌────────────────────────────────────────────────────────┐ + │ OVERVIEW │ + ├────────────────────────────────────────────────────────┤ + │Sessions 2 │ + │Messages 285 │ + │Days 1 │ + └────────────────────────────────────────────────────────┘ + + ┌────────────────────────────────────────────────────────┐ + │ COST & TOKENS │ + ├────────────────────────────────────────────────────────┤ + │Total Cost $1.19 │ + │Input 1.4M │ + │Output 46.1K │ + │Cache Read 20.2M │ + │Cache Write 0 │ + └────────────────────────────────────────────────────────┘ + """ + + let stats = KiloCLIFetchStrategy.parseCLIStatsOutputInternal(output) + #expect(stats.sessions == 2) + #expect(stats.messages == 285) + #expect(stats.totalCost == 1.19) + #expect(stats.inputTokens == 1_400_000) + #expect(stats.outputTokens == 46_100) + #expect(stats.cacheReadTokens == 20_200_000) + } + + @Test + func parsesEmptyOutput() { + let stats = KiloCLIFetchStrategy.parseCLIStatsOutputInternal("") + #expect(stats.sessions == 0) + #expect(stats.messages == 0) + #expect(stats.totalCost == 0) + #expect(stats.inputTokens == 0) + #expect(stats.outputTokens == 0) + #expect(stats.cacheReadTokens == 0) + } + + @Test + func parseTokenCountSuffixes() { + #expect(KiloCLIFetchStrategy.parseTokenCount("1.4M") == 1_400_000) + #expect(KiloCLIFetchStrategy.parseTokenCount("46.1K") == 46_100) + #expect(KiloCLIFetchStrategy.parseTokenCount("20.2M") == 20_200_000) + #expect(KiloCLIFetchStrategy.parseTokenCount("1.5B") == 1_500_000_000) + #expect(KiloCLIFetchStrategy.parseTokenCount("0") == 0) + #expect(KiloCLIFetchStrategy.parseTokenCount("500") == 500) + } +} + +// MARK: - Batched API Response Parser + +@Suite +struct KiloBatchedResponseParserTests { + @Test + func parsesSubscriptionWithCreditBlocks() throws { + let json = """ + [ + { + "result": { + "data": { + "creditBlocks": [ + { + "id": "block-1", + "effective_date": "2026-02-01T00:00:00Z", + "expiry_date": null, + "balance_mUsd": 15000000, + "amount_mUsd": 19000000, + "is_free": false + } + ], + "totalBalance_mUsd": 15000000, + "autoTopUpEnabled": false + } + } + }, + { + "result": { + "data": { + "subscription": { + "tier": "tier_19", + "currentPeriodUsageUsd": 3.50, + "currentPeriodBaseCreditsUsd": 19.0, + "currentPeriodBonusCreditsUsd": 9.50, + "nextBillingAt": "2026-03-15T00:00:00Z" + } + } + } + } + ] + """ + + let snapshot = try KiloWebAPIFetchStrategy._parseBatchedResponse(Data(json.utf8)) + #expect(snapshot.balanceDollars == 15.0) + #expect(snapshot.hasSubscription == true) + #expect(snapshot.planName == "Starter") + #expect(snapshot.periodBaseCredits == 19.0) + #expect(snapshot.periodBonusCredits == 9.50) + #expect(snapshot.periodUsageDollars == 3.50) + #expect(snapshot.periodResetsAt != nil) + #expect(snapshot.creditBlocks.count == 1) + #expect(snapshot.creditBlocks[0].balanceMUsd == 15_000_000) + #expect(snapshot.autoTopUp == nil) + } + + @Test + func parsesProTier() throws { + let json = """ + [ + {"result": {"data": {"creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": false}}}, + {"result": {"data": {"subscription": {"tier": "tier_49"}}}} + ] + """ + let snapshot = try KiloWebAPIFetchStrategy._parseBatchedResponse(Data(json.utf8)) + #expect(snapshot.planName == "Pro") + } + + @Test + func parsesExpertTier() throws { + let json = """ + [ + {"result": {"data": {"creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": false}}}, + {"result": {"data": {"subscription": {"tier": "tier_199"}}}} + ] + """ + let snapshot = try KiloWebAPIFetchStrategy._parseBatchedResponse(Data(json.utf8)) + #expect(snapshot.planName == "Expert") + } + + @Test + func fallsBackToRawTierName() throws { + let json = """ + [ + {"result": {"data": {"creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": false}}}, + {"result": {"data": {"subscription": {"tier": "tier_999"}}}} + ] + """ + let snapshot = try KiloWebAPIFetchStrategy._parseBatchedResponse(Data(json.utf8)) + #expect(snapshot.planName == "tier_999") + } + + @Test + func subscriptionWithoutTierDefaultsToKiloPass() throws { + let json = """ + [ + {"result": {"data": {"creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": false}}}, + {"result": {"data": {"subscription": {"currentPeriodUsageUsd": 1.0, "currentPeriodBaseCreditsUsd": 19.0}}}} + ] + """ + let snapshot = try KiloWebAPIFetchStrategy._parseBatchedResponse(Data(json.utf8)) + #expect(snapshot.hasSubscription == true) + #expect(snapshot.planName == "Kilo Pass") + #expect(snapshot.periodBaseCredits == 19.0) + } + + @Test + func noSubscriptionSetsDefaults() throws { + let json = """ + [ + {"result": {"data": {"creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": false}}}, + {"result": {"data": {}}} + ] + """ + let snapshot = try KiloWebAPIFetchStrategy._parseBatchedResponse(Data(json.utf8)) + #expect(snapshot.hasSubscription == false) + #expect(snapshot.planName == nil) + #expect(snapshot.periodBaseCredits == 0) + } + + @Test + func parsesAutoTopUpEnabled() throws { + let json = """ + [ + {"result": {"data": {"creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": true}}}, + {"result": {"data": {}}}, + {"result": {"data": {"amountCents": 5000}}} + ] + """ + let snapshot = try KiloWebAPIFetchStrategy._parseBatchedResponse(Data(json.utf8)) + #expect(snapshot.autoTopUp != nil) + #expect(snapshot.autoTopUp?.enabled == true) + #expect(snapshot.autoTopUp?.amountDollars == 50.0) + } + + @Test + func autoTopUpDisabledReturnsNil() throws { + let json = """ + [ + {"result": {"data": {"creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": false}}}, + {"result": {"data": {}}} + ] + """ + let snapshot = try KiloWebAPIFetchStrategy._parseBatchedResponse(Data(json.utf8)) + #expect(snapshot.autoTopUp == nil) + } + + @Test + func invalidResponseThrows() { + let json = """ + {"not": "an array"} + """ + #expect(throws: KiloAPIError.self) { + try KiloWebAPIFetchStrategy._parseBatchedResponse(Data(json.utf8)) + } + } +} + +// MARK: - Credit Block Consolidation (via toUsageSnapshot) + +@Suite +struct KiloCreditBlockConsolidationTests { + @Test + func consolidatesNonExpiringBlocks() { + let snapshot = KiloUsageSnapshot( + creditBlocks: [ + KiloCreditBlock(id: "a", effectiveDateString: "2026-01-01T00:00:00Z", expiryDateString: nil, + balanceMUsd: 5_000_000, amountMUsd: 10_000_000, isFree: false), + KiloCreditBlock(id: "b", effectiveDateString: "2026-02-01T00:00:00Z", expiryDateString: nil, + balanceMUsd: 3_000_000, amountMUsd: 8_000_000, isFree: false), + ], + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + let blocks = usage.kiloCreditBlocks! + #expect(blocks.count == 1) + #expect(blocks[0].id == "consolidated-permanent") + #expect(blocks[0].balanceMUsd == 8_000_000) + #expect(blocks[0].amountMUsd == 18_000_000) + #expect(blocks[0].effectiveDateString == "2026-01-01T00:00:00Z") + } + + @Test + func keepsExpiringBlocksSeparate() { + let snapshot = KiloUsageSnapshot( + creditBlocks: [ + KiloCreditBlock(id: "a", effectiveDateString: "2026-01-01T00:00:00Z", expiryDateString: nil, + balanceMUsd: 5_000_000, amountMUsd: 10_000_000, isFree: false), + KiloCreditBlock(id: "b", effectiveDateString: "2026-02-01T00:00:00Z", expiryDateString: "2026-06-01T00:00:00Z", + balanceMUsd: 3_000_000, amountMUsd: 5_000_000, isFree: false), + ], + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + let blocks = usage.kiloCreditBlocks! + #expect(blocks.count == 2) + #expect(blocks[0].id == "consolidated-permanent") + #expect(blocks[1].id == "b") + #expect(blocks[1].expiryDateString == "2026-06-01T00:00:00Z") + } + + @Test + func allFreeBlocksPreserveIsFree() { + let snapshot = KiloUsageSnapshot( + creditBlocks: [ + KiloCreditBlock(id: "a", effectiveDateString: "2026-01-01T00:00:00Z", expiryDateString: nil, + balanceMUsd: 1_000_000, amountMUsd: 5_000_000, isFree: true), + KiloCreditBlock(id: "b", effectiveDateString: "2026-02-01T00:00:00Z", expiryDateString: nil, + balanceMUsd: 2_000_000, amountMUsd: 5_000_000, isFree: true), + ], + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + let blocks = usage.kiloCreditBlocks! + #expect(blocks.count == 1) + #expect(blocks[0].isFree == true) + } + + @Test + func mixedFreeAndPaidMarksNotFree() { + let snapshot = KiloUsageSnapshot( + creditBlocks: [ + KiloCreditBlock(id: "a", effectiveDateString: "2026-01-01T00:00:00Z", expiryDateString: nil, + balanceMUsd: 1_000_000, amountMUsd: 5_000_000, isFree: true), + KiloCreditBlock(id: "b", effectiveDateString: "2026-02-01T00:00:00Z", expiryDateString: nil, + balanceMUsd: 2_000_000, amountMUsd: 10_000_000, isFree: false), + ], + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + let blocks = usage.kiloCreditBlocks! + #expect(blocks[0].isFree == false) + } + + @Test + func onlyExpiringBlocksKeptIndividually() { + let snapshot = KiloUsageSnapshot( + creditBlocks: [ + KiloCreditBlock(id: "exp-1", effectiveDateString: "2026-01-01T00:00:00Z", expiryDateString: "2026-06-01T00:00:00Z", + balanceMUsd: 5_000_000, amountMUsd: 10_000_000, isFree: false), + KiloCreditBlock(id: "exp-2", effectiveDateString: "2026-02-01T00:00:00Z", expiryDateString: "2026-07-01T00:00:00Z", + balanceMUsd: 3_000_000, amountMUsd: 8_000_000, isFree: false), + ], + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + let blocks = usage.kiloCreditBlocks! + #expect(blocks.count == 2) + #expect(blocks[0].id == "exp-1") + #expect(blocks[1].id == "exp-2") + // No consolidated-permanent entry + #expect(blocks.allSatisfy { $0.id != "consolidated-permanent" }) + } + + @Test + func emptyBlocksReturnsNil() { + let snapshot = KiloUsageSnapshot(creditBlocks: [], updatedAt: Date()) + let usage = snapshot.toUsageSnapshot() + #expect(usage.kiloCreditBlocks == nil) + } +} + +// MARK: - toUsageSnapshot Conversion + +@Suite +struct KiloUsageSnapshotConversionTests { + @Test + func withSubscriptionHasPrimaryWindow() { + let snapshot = KiloUsageSnapshot( + periodBaseCredits: 19.0, + periodBonusCredits: 9.50, + periodUsageDollars: 5.0, + hasSubscription: true, + planName: "Starter", + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary != nil) + let primary = usage.primary! + let totalCredits = 19.0 + 9.50 + let expectedUsed = (5.0 / totalCredits) * 100 + #expect(abs(primary.usedPercent - expectedUsed) < 0.01) + } + + @Test + func withoutSubscriptionNoPrimaryWindow() { + let snapshot = KiloUsageSnapshot( + hasSubscription: false, + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + } + + @Test + func markerPercentSetAtBonusBoundary() { + let snapshot = KiloUsageSnapshot( + periodBaseCredits: 19.0, + periodBonusCredits: 9.50, + hasSubscription: true, + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + let expectedMarker = (9.50 / 28.50) * 100 + #expect(usage.primary?.markerPercent != nil) + #expect(abs(usage.primary!.markerPercent! - expectedMarker) < 0.01) + } + + @Test + func noBonusCreditsNoMarker() { + let snapshot = KiloUsageSnapshot( + periodBaseCredits: 19.0, + periodBonusCredits: 0, + hasSubscription: true, + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.markerPercent == nil) + } + + @Test + func providerCostCarriesCLIStats() { + let snapshot = KiloUsageSnapshot( + cliCostDollars: 2.50, + cliSessions: 3, + cliMessages: 100, + cliInputTokens: 500_000, + cliOutputTokens: 50_000, + cliCacheReadTokens: 1_000_000, + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.providerCost != nil) + #expect(usage.providerCost?.used == 2.50) + #expect(usage.providerCost?.limit == 0) + #expect(usage.providerCost?.period?.contains("3 sessions") == true) + #expect(usage.providerCost?.period?.contains("100 messages") == true) + #expect(usage.providerCost?.period?.contains("tokens") == true) + } + + @Test + func noCLIStatsNoCostSection() { + let snapshot = KiloUsageSnapshot(updatedAt: Date()) + let usage = snapshot.toUsageSnapshot() + #expect(usage.providerCost == nil) + } + + @Test + func autoTopUpTextGenerated() { + let snapshot = KiloUsageSnapshot( + autoTopUp: KiloAutoTopUp(enabled: true, amountDollars: 50), + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.kiloAutoTopUpText == "Auto top-up: $50") + } + + @Test + func autoTopUpWithoutAmountShowsOn() { + let snapshot = KiloUsageSnapshot( + autoTopUp: KiloAutoTopUp(enabled: true, amountDollars: 0), + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.kiloAutoTopUpText == "Auto top-up: On") + } + + @Test + func noAutoTopUpNoText() { + let snapshot = KiloUsageSnapshot(updatedAt: Date()) + let usage = snapshot.toUsageSnapshot() + #expect(usage.kiloAutoTopUpText == nil) + } + + @Test + func identityCarriesPlanName() { + let snapshot = KiloUsageSnapshot( + hasSubscription: true, + planName: "Pro", + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.loginMethod(for: .kilo) == "Pro") + } +} From 73d7a7d889f2212252a60e34118867378a9e70cc Mon Sep 17 00:00:00 2001 From: Marco Buono Date: Mon, 16 Feb 2026 14:14:23 -0300 Subject: [PATCH 3/5] Nudge icon down slightly --- Sources/CodexBar/Resources/ProviderIcon-kilo.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBar/Resources/ProviderIcon-kilo.svg b/Sources/CodexBar/Resources/ProviderIcon-kilo.svg index 609668fd7..d1656c4cc 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-kilo.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-kilo.svg @@ -1,6 +1,6 @@ - + From 628c17fd5508367c1139840a3201f2f8029ac5ad Mon Sep 17 00:00:00 2001 From: Marco Buono Date: Mon, 16 Feb 2026 14:19:44 -0300 Subject: [PATCH 4/5] Add status link --- .../CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift index 970af5b7a..214ee6102 100644 --- a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift @@ -23,7 +23,8 @@ public enum KiloProviderDescriptor { usesAccountFallback: false, browserCookieOrder: nil, dashboardURL: "https://app.kilo.ai/usage", - statusPageURL: nil), + statusPageURL: nil, + statusLinkURL: "https://status.kilo.ai"), branding: ProviderBranding( iconStyle: .kilo, iconResourceName: "ProviderIcon-kilo", From 72ee6f99f9812c102777503b57d9cda315d6a5c7 Mon Sep 17 00:00:00 2001 From: Marco Buono Date: Mon, 16 Feb 2026 14:56:57 -0300 Subject: [PATCH 5/5] Address PR feedback --- Sources/CodexBar/MenuCardView.swift | 11 ++++++++--- Sources/CodexBarCLI/CLIRenderer.swift | 7 ++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 9f872e452..d09fa4975 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -102,7 +102,7 @@ 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)) @@ -195,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.creditBlocks ?? []).isEmpty + !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 } } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 32586f62b..cfbf6dce6 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -34,7 +34,12 @@ enum CLIRenderer { } else if let cost = snapshot.providerCost { // Fallback to cost/quota display if no primary rate window let label = cost.currencyCode == "Quota" ? "Quota" : "Cost" - let value = "\(String(format: "%.1f", cost.used)) / \(String(format: "%.1f", cost.limit))" + let value: String + if cost.limit > 0 { + value = "\(String(format: "%.1f", cost.used)) / \(String(format: "%.1f", cost.limit))" + } else { + value = String(format: "$%.2f", cost.used) + } lines.append(self.labelValueLine(label, value: value, useColor: context.useColor)) }