diff --git a/FUTURE.md b/FUTURE.md index 223f780..57e32c9 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -39,8 +39,9 @@ Phase 1 local alpha is implemented in `macos/` with: - Configurable executable path, refresh interval, metric, and scope - Explicit empty, error, timeout, and unavailable-cost states -Remaining work is packaging, signing, distribution, provider filtering, and -budget notifications. The alpha intentionally invokes the local status command +Remaining work is packaging, signing, distribution, and budget notifications. +Provider filtering is available from Preferences. The alpha intentionally +invokes the local status command directly and does not add a daemon, IPC layer, telemetry, or network service. ### MCP Server Integration @@ -56,7 +57,7 @@ Webview dashboard showing: ### System Tray Icon (Cross-platform) - Windows/Linux equivalent of menu bar app -- Settings UI for provider configuration +- More advanced provider configuration - Budget notifications ### Cloud Dashboard / Cross-machine Sync diff --git a/README.md b/README.md index 93794ab..09e995e 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,10 @@ source checkout and Rust CLI are unaffected. The menu shows a headline metric, scope, refresh time, and clear empty/error states. Open **Preferences…** to choose the executable path, metric (tokens, -cost, or combined), scope (today, week, month, or all time), and refresh interval. +cost, or combined), scope (today, week, month, or all time), refresh interval, +and an optional comma-separated provider allow-list. Leave the provider field +blank to include every provider. Names are trimmed and de-duplicated before +being passed as repeatable provider filters to the local status command. Cost is labelled unavailable when the status response has no pricing table rather than presenting an unpriced zero. **Open dashboard** launches the detailed local view in a terminal window. diff --git a/macos/Sources/TokemonMenuBar/AppModel.swift b/macos/Sources/TokemonMenuBar/AppModel.swift index 17be9cb..3afcd7c 100644 --- a/macos/Sources/TokemonMenuBar/AppModel.swift +++ b/macos/Sources/TokemonMenuBar/AppModel.swift @@ -213,7 +213,9 @@ public final class AppModel: ObservableObject { return .failure(executableError(path: preferences.executablePath)) } - let result = try runner.runOffline(arguments: StatusQuery(scope: preferences.scope).arguments(now: now)) + let result = try runner.runOffline( + arguments: StatusQuery(scope: preferences.scope, providers: preferences.providers).arguments(now: now) + ) return .success(result) } catch { return .failure(error.localizedDescription) diff --git a/macos/Sources/TokemonMenuBar/AppPreferences.swift b/macos/Sources/TokemonMenuBar/AppPreferences.swift index f483133..e062b19 100644 --- a/macos/Sources/TokemonMenuBar/AppPreferences.swift +++ b/macos/Sources/TokemonMenuBar/AppPreferences.swift @@ -8,17 +8,20 @@ public struct AppPreferences: Equatable, Sendable { public var metric: StatusMetric public var scope: StatusScopeSelection public var refreshInterval: TimeInterval + public var providers: [String] public init( executablePath: String = "", metric: StatusMetric = .combined, scope: StatusScopeSelection = .today, - refreshInterval: TimeInterval = 60 + refreshInterval: TimeInterval = 60, + providers: [String] = [] ) { self.executablePath = executablePath self.metric = metric self.scope = scope self.refreshInterval = Self.clampedRefreshInterval(refreshInterval) + self.providers = Self.normalizedProviders(providers) } public static func load(from defaults: UserDefaults = .standard) -> AppPreferences { @@ -26,7 +29,8 @@ public struct AppPreferences: Equatable, Sendable { executablePath: defaults.string(forKey: Keys.executablePath) ?? "", metric: StatusMetric(rawValue: defaults.string(forKey: Keys.metric) ?? "") ?? .combined, scope: StatusScopeSelection(rawValue: defaults.string(forKey: Keys.scope) ?? "") ?? .today, - refreshInterval: defaults.double(forKey: Keys.refreshInterval).nonZeroOr(60) + refreshInterval: defaults.double(forKey: Keys.refreshInterval).nonZeroOr(60), + providers: defaults.stringArray(forKey: Keys.providers) ?? [] ) } @@ -35,6 +39,16 @@ public struct AppPreferences: Equatable, Sendable { defaults.set(metric.rawValue, forKey: Keys.metric) defaults.set(scope.rawValue, forKey: Keys.scope) defaults.set(refreshInterval, forKey: Keys.refreshInterval) + defaults.set(Self.normalizedProviders(providers), forKey: Keys.providers) + } + + public static func normalizedProviders(_ providers: [String]) -> [String] { + var seen = Set() + return providers.compactMap { provider in + let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, seen.insert(trimmed.lowercased()).inserted else { return nil } + return trimmed + } } private static func clampedRefreshInterval(_ interval: TimeInterval) -> TimeInterval { @@ -46,6 +60,7 @@ public struct AppPreferences: Equatable, Sendable { static let metric = "tokemonMenuBar.metric" static let scope = "tokemonMenuBar.scope" static let refreshInterval = "tokemonMenuBar.refreshInterval" + static let providers = "tokemonMenuBar.providers" } } diff --git a/macos/Sources/TokemonMenuBar/PreferencesView.swift b/macos/Sources/TokemonMenuBar/PreferencesView.swift index 53afec2..25e82d3 100644 --- a/macos/Sources/TokemonMenuBar/PreferencesView.swift +++ b/macos/Sources/TokemonMenuBar/PreferencesView.swift @@ -4,10 +4,12 @@ struct PreferencesView: View { @ObservedObject var model: AppModel @Environment(\.dismiss) private var dismiss @State private var draft: AppPreferences + @State private var providerText: String init(model: AppModel) { self.model = model _draft = State(initialValue: model.preferences) + _providerText = State(initialValue: model.preferences.providers.joined(separator: ", ")) } var body: some View { @@ -40,6 +42,14 @@ struct PreferencesView: View { } } + Section("Providers") { + TextField("Providers", text: $providerText) + .textFieldStyle(.roundedBorder) + Text("Comma-separated provider names. Leave blank to include all providers.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Refresh") { HStack { Slider( @@ -62,6 +72,9 @@ struct PreferencesView: View { dismiss() } Button("Save") { + draft.providers = AppPreferences.normalizedProviders( + providerText.split(separator: ",", omittingEmptySubsequences: false).map(String.init) + ) model.apply(draft) dismiss() } diff --git a/macos/Sources/TokemonMenuBar/StatusQuery.swift b/macos/Sources/TokemonMenuBar/StatusQuery.swift index ab326e4..3c97385 100644 --- a/macos/Sources/TokemonMenuBar/StatusQuery.swift +++ b/macos/Sources/TokemonMenuBar/StatusQuery.swift @@ -2,9 +2,11 @@ import Foundation public struct StatusQuery: Equatable, Sendable { public let scope: StatusScopeSelection + public let providers: [String] - public init(scope: StatusScopeSelection) { + public init(scope: StatusScopeSelection, providers: [String] = []) { self.scope = scope + self.providers = AppPreferences.normalizedProviders(providers) } public func arguments(now: Date = Date()) -> [String] { @@ -12,14 +14,15 @@ public struct StatusQuery: Equatable, Sendable { let today = calendar.startOfDay(for: now) let todayString = Self.dateString(today) + let scopeArguments: [String] switch scope { case .today: - return ["--frequency", "daily", "--since", todayString, "--until", todayString] + scopeArguments = ["--frequency", "daily", "--since", todayString, "--until", todayString] case .week: let weekday = calendar.component(.weekday, from: today) let daysFromMonday = (weekday + 5) % 7 let start = calendar.date(byAdding: .day, value: -daysFromMonday, to: today) ?? today - return [ + scopeArguments = [ "--frequency", "weekly", "--since", Self.dateString(start), "--until", todayString @@ -27,14 +30,16 @@ public struct StatusQuery: Equatable, Sendable { case .month: let components = calendar.dateComponents([.year, .month], from: today) let start = calendar.date(from: components) ?? today - return [ + scopeArguments = [ "--frequency", "monthly", "--since", Self.dateString(start), "--until", todayString ] case .allTime: - return ["--frequency", "monthly"] + scopeArguments = ["--frequency", "monthly"] } + + return scopeArguments + providers.flatMap { ["--provider", $0] } } private static let utcCalendar: Calendar = { diff --git a/macos/Tests/TokemonMenuBarTests/AppModelTests.swift b/macos/Tests/TokemonMenuBarTests/AppModelTests.swift index cafab95..c4d3ed4 100644 --- a/macos/Tests/TokemonMenuBarTests/AppModelTests.swift +++ b/macos/Tests/TokemonMenuBarTests/AppModelTests.swift @@ -8,7 +8,7 @@ final class AppModelTests: XCTestCase { let runner = RecordingRunner(result: StatusCommandResult(report: report, stderr: "")) let fixedDate = ISO8601DateFormatter().date(from: "2026-08-12T15:30:00Z")! let model = AppModel( - preferences: AppPreferences(metric: .combined, scope: .week), + preferences: AppPreferences(metric: .combined, scope: .week, providers: [" codex ", "gemini"]), runner: runner, clock: { fixedDate } ) @@ -20,7 +20,10 @@ final class AppModelTests: XCTestCase { XCTAssertEqual(model.menuBarTitle, "15 tok · $0.25") XCTAssertEqual( runner.arguments, - ["--frequency", "weekly", "--since", "2026-08-10", "--until", "2026-08-12"] + [ + "--frequency", "weekly", "--since", "2026-08-10", "--until", "2026-08-12", + "--provider", "codex", "--provider", "gemini" + ] ) runner.fail(with: "temporary status failure") diff --git a/macos/Tests/TokemonMenuBarTests/AppPreferencesTests.swift b/macos/Tests/TokemonMenuBarTests/AppPreferencesTests.swift index aa49e8b..005c293 100644 --- a/macos/Tests/TokemonMenuBarTests/AppPreferencesTests.swift +++ b/macos/Tests/TokemonMenuBarTests/AppPreferencesTests.swift @@ -9,7 +9,8 @@ final class AppPreferencesTests: XCTestCase { executablePath: "/usr/local/bin/tokemon", metric: .cost, scope: .month, - refreshInterval: 30 + refreshInterval: 30, + providers: ["codex", "gemini"] ) expected.save(to: defaults) @@ -27,4 +28,21 @@ final class AppPreferencesTests: XCTestCase { AppPreferences.maximumRefreshInterval ) } + + func testNormalizesProviderNamesWithoutChangingStableOrder() { + XCTAssertEqual( + AppPreferences.normalizedProviders([" codex ", "GEMINI", "codex", "", " gemini "]), + ["codex", "GEMINI"] + ) + } + + func testSaveNormalizesMutatedProviderNames() { + var preferences = AppPreferences() + preferences.providers = [" codex ", "", "CODEX", " gemini "] + let defaults = UserDefaults(suiteName: #function)! + + preferences.save(to: defaults) + + XCTAssertEqual(AppPreferences.load(from: defaults).providers, ["codex", "gemini"]) + } } diff --git a/macos/Tests/TokemonMenuBarTests/StatusQueryTests.swift b/macos/Tests/TokemonMenuBarTests/StatusQueryTests.swift index 6c3746d..565a262 100644 --- a/macos/Tests/TokemonMenuBarTests/StatusQueryTests.swift +++ b/macos/Tests/TokemonMenuBarTests/StatusQueryTests.swift @@ -29,4 +29,16 @@ final class StatusQueryTests: XCTestCase { func testAllTimeUsesMonthlyRollupsWithoutDateFilters() { XCTAssertEqual(StatusQuery(scope: .allTime).arguments(now: now), ["--frequency", "monthly"]) } + + func testProviderFiltersUseRepeatableArguments() { + let query = StatusQuery(scope: .today, providers: [" codex ", "gemini", "codex"]) + + XCTAssertEqual( + query.arguments(now: now), + [ + "--frequency", "daily", "--since", "2026-08-12", "--until", "2026-08-12", + "--provider", "codex", "--provider", "gemini" + ] + ) + } }