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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions FUTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion macos/Sources/TokemonMenuBar/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 17 additions & 2 deletions macos/Sources/TokemonMenuBar/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,29 @@ 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 {
AppPreferences(
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) ?? []
)
}

Expand All @@ -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<String>()
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 {
Expand All @@ -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"
}
}

Expand Down
13 changes: 13 additions & 0 deletions macos/Sources/TokemonMenuBar/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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()
}
Expand Down
15 changes: 10 additions & 5 deletions macos/Sources/TokemonMenuBar/StatusQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,44 @@ 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] {
let calendar = Self.utcCalendar
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
]
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 = {
Expand Down
7 changes: 5 additions & 2 deletions macos/Tests/TokemonMenuBarTests/AppModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
)
Expand All @@ -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")
Expand Down
20 changes: 19 additions & 1 deletion macos/Tests/TokemonMenuBarTests/AppPreferencesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"])
}
}
12 changes: 12 additions & 0 deletions macos/Tests/TokemonMenuBarTests/StatusQueryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
)
}
}
Loading