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
15 changes: 5 additions & 10 deletions Sources/ClaudeUsageApp/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,6 @@ struct ClaudeUsageApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

var body: some Scene {
MenuBarExtra {
MenuContentView(
viewModel: appDelegate.viewModel,
widgetController: appDelegate.widgetController
)
} label: {
MenuBarLabel(viewModel: appDelegate.viewModel)
}
.menuBarExtraStyle(.window)

Settings {
SettingsRootView(viewModel: appDelegate.viewModel)
}
Expand All @@ -34,9 +24,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
)

lazy var widgetController = FloatingWidgetController(viewModel: viewModel)
private var statusBarController: StatusBarController?

func applicationDidFinishLaunching(_ notification: Notification) {
NSApplication.shared.setActivationPolicy(.accessory)
statusBarController = StatusBarController(
viewModel: viewModel,
widgetController: widgetController
)
notificationService.requestPermission()
viewModel.startPolling()

Expand Down
9 changes: 6 additions & 3 deletions Sources/ClaudeUsageApp/MenuBarLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct MenuBarLabel: View {
@AppStorage("warningThreshold") private var warningThreshold = 50.0
@AppStorage("criticalThreshold") private var criticalThreshold = 80.0
@AppStorage("colorMode") private var colorMode = "traffic_light"
@AppStorage("menuBarLayout") private var menuBarLayout = MenuBarLayoutOption.fiveHourOnly.rawValue

var body: some View {
if viewModel.isEnterprise {
Expand All @@ -21,9 +22,11 @@ struct MenuBarLabel: View {
let secondary = MenuBarMetrics.consumerSecondaryPercent(from: viewModel.usage)
return HStack(spacing: 2) {
percentText(primary)
Text("·")
.foregroundStyle(.secondary)
percentText(secondary)
if menuBarLayout == MenuBarLayoutOption.dual.rawValue {
Text("·")
.foregroundStyle(.secondary)
percentText(secondary)
}
}
.font(.system(size: 11, weight: .medium, design: .rounded))
.monospacedDigit()
Expand Down
154 changes: 122 additions & 32 deletions Sources/ClaudeUsageApp/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,45 @@ enum ColorMode: String, CaseIterable {
}
}

enum MenuBarLayoutOption: String, CaseIterable {
case fiveHourOnly // 5h% only
case dual // 5h% · 7d%

var label: LocalizedStringKey {
switch self {
case .fiveHourOnly: return "5-hour"
case .dual: return "5-hour + 7-day"
}
}
}

enum EnterpriseDisplayOption: String, CaseIterable {
case dollars
case percent

var label: LocalizedStringKey {
switch self {
case .dollars: return "Dollars"
case .percent: return "Percent"
}
}
}

struct SettingsRootView: View {
@ObservedObject var viewModel: UsageViewModel

var body: some View {
TabView {
GeneralTab(viewModel: viewModel)
.tabItem { Label("General", systemImage: "gearshape") }
ThresholdsTab()
.tabItem { Label("Thresholds", systemImage: "slider.horizontal.3") }
DisplayTab(viewModel: viewModel)
.tabItem { Label("Display", systemImage: "paintbrush") }
NotificationsTab()
.tabItem { Label("Notifications", systemImage: "bell") }
AccountTab(viewModel: viewModel)
.tabItem { Label("Account", systemImage: "person.crop.circle") }
}
.frame(width: 420, height: 260)
.frame(width: 440, height: 340)
}
}

Expand Down Expand Up @@ -56,48 +82,112 @@ private struct GeneralTab: View {
}
}

// MARK: - Thresholds
// MARK: - Display

private struct ThresholdsTab: View {
private struct DisplayTab: View {
@ObservedObject var viewModel: UsageViewModel
@AppStorage("menuBarLayout") private var menuBarLayout = MenuBarLayoutOption.fiveHourOnly.rawValue
@AppStorage("colorMode") private var colorMode = ColorMode.trafficLight
@AppStorage("warningThreshold") private var warningThreshold = 50.0
@AppStorage("criticalThreshold") private var criticalThreshold = 80.0
@AppStorage("colorMode") private var colorMode = ColorMode.trafficLight
@AppStorage("enterpriseShowPct") private var enterpriseShowPct = false

var body: some View {
Form {
Picker("Color mode", selection: $colorMode) {
ForEach(ColorMode.allCases, id: \.self) { mode in
Text(mode.label).tag(mode)
Section {
Picker("Menu bar", selection: $menuBarLayout) {
ForEach(MenuBarLayoutOption.allCases, id: \.rawValue) { option in
Text(option.label).tag(option.rawValue)
}
}
.pickerStyle(.segmented)

if viewModel.isEnterprise {
Picker("Enterprise display", selection: enterpriseDisplayBinding) {
ForEach(EnterpriseDisplayOption.allCases, id: \.rawValue) { option in
Text(option.label).tag(option.rawValue)
}
}
.pickerStyle(.segmented)
}
}
.pickerStyle(.segmented)

HStack {
Text("Warning")
Slider(value: $warningThreshold, in: 10...90, step: 5)
.tint(.orange)
Text("\(Int(warningThreshold))%")
.monospacedDigit()
.frame(width: 40, alignment: .trailing)
}
.onChange(of: warningThreshold) { _, newValue in
if newValue >= criticalThreshold {
criticalThreshold = min(newValue + 10, 100)
Section {
Picker("Color mode", selection: $colorMode) {
ForEach(ColorMode.allCases, id: \.self) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)

HStack {
Text("Warning")
Slider(value: $warningThreshold, in: 10...90, step: 5)
.tint(.orange)
Text("\(Int(warningThreshold))%")
.monospacedDigit()
.frame(width: 40, alignment: .trailing)
}
.onChange(of: warningThreshold) { _, newValue in
if newValue >= criticalThreshold {
criticalThreshold = min(newValue + 10, 100)
}
}

HStack {
Text("Critical")
Slider(value: $criticalThreshold, in: 20...100, step: 5)
.tint(.red)
Text("\(Int(criticalThreshold))%")
.monospacedDigit()
.frame(width: 40, alignment: .trailing)
}
.onChange(of: criticalThreshold) { _, newValue in
if newValue <= warningThreshold {
warningThreshold = max(newValue - 10, 0)
}
}
} footer: {
Text("Used for menu bar colors and notification triggers.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}

HStack {
Text("Critical")
Slider(value: $criticalThreshold, in: 20...100, step: 5)
.tint(.red)
Text("\(Int(criticalThreshold))%")
.monospacedDigit()
.frame(width: 40, alignment: .trailing)
/// `@AppStorage` stores Bool; the picker takes `EnterpriseDisplayOption.rawValue`.
/// Bridge the two so the picker selects the right segment.
private var enterpriseDisplayBinding: Binding<String> {
Binding(
get: { enterpriseShowPct ? EnterpriseDisplayOption.percent.rawValue : EnterpriseDisplayOption.dollars.rawValue },
set: { enterpriseShowPct = ($0 == EnterpriseDisplayOption.percent.rawValue) }
)
}
}

// MARK: - Notifications

private struct NotificationsTab: View {
@AppStorage("notificationThresholdAlerts") private var thresholdAlerts = true
@AppStorage("notificationBurnRateAlerts") private var burnRateAlerts = true

var body: some View {
Form {
Section {
Toggle("Threshold alerts", isOn: $thresholdAlerts)
} footer: {
Text("Notify when 5-hour usage crosses the warning or critical level set in Display.")
.font(.caption)
.foregroundStyle(.secondary)
}
.onChange(of: criticalThreshold) { _, newValue in
if newValue <= warningThreshold {
warningThreshold = max(newValue - 10, 0)
}

Section {
Toggle("Burn rate alert", isOn: $burnRateAlerts)
} footer: {
Text("Notify once when projected to exhaust the 5-hour bucket within 60 minutes.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
Expand Down
104 changes: 104 additions & 0 deletions Sources/ClaudeUsageApp/StatusBarController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import AppKit
import Combine
import SwiftUI
import ClaudeUsageCore

@MainActor
final class StatusBarController: NSObject {
private let statusItem: NSStatusItem
private let popover: NSPopover
private let viewModel: UsageViewModel
private let widgetController: FloatingWidgetController
private var labelHostingView: NSHostingView<MenuBarLabel>?
private var cancellables = Set<AnyCancellable>()

init(viewModel: UsageViewModel, widgetController: FloatingWidgetController) {
self.viewModel = viewModel
self.widgetController = widgetController
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
self.popover = NSPopover()
super.init()

configureStatusItem()
configurePopover()
observeLabelSizeChanges()
}

private func configureStatusItem() {
guard let button = statusItem.button else { return }

let hostingView = NSHostingView(rootView: MenuBarLabel(viewModel: viewModel))
hostingView.translatesAutoresizingMaskIntoConstraints = false
button.addSubview(hostingView)
NSLayoutConstraint.activate([
hostingView.leadingAnchor.constraint(equalTo: button.leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: button.trailingAnchor),
hostingView.centerYAnchor.constraint(equalTo: button.centerYAnchor),
])
labelHostingView = hostingView

button.target = self
button.action = #selector(statusItemClicked(_:))

updateStatusItemLength()
}

private func configurePopover() {
let content = MenuContentView(
viewModel: viewModel,
widgetController: widgetController
)
popover.contentViewController = NSHostingController(rootView: content)
popover.behavior = .transient
popover.animates = true
}

/// The status button's intrinsic width is zero (no title/image), so
/// `NSStatusItem.variableLength` would clip the SwiftUI label. Push the
/// hosting view's measured width back into `statusItem.length` whenever
/// the model — or the enterprise $/% display preference — changes.
private func observeLabelSizeChanges() {
viewModel.objectWillChange
.sink { [weak self] _ in
DispatchQueue.main.async { [weak self] in
self?.updateStatusItemLength()
}
}
.store(in: &cancellables)

NotificationCenter.default
.publisher(for: UserDefaults.didChangeNotification)
.sink { [weak self] _ in
DispatchQueue.main.async { [weak self] in
self?.updateStatusItemLength()
}
}
.store(in: &cancellables)
}

private func updateStatusItemLength() {
guard let hostingView = labelHostingView else { return }
let width = hostingView.intrinsicContentSize.width
guard width.isFinite, width > 0 else { return }
statusItem.length = ceil(width)
}

@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
if popover.isShown {
popover.performClose(sender)
} else {
showPopover(from: sender)
}
}

private func showPopover(from button: NSStatusBarButton) {
// Activating the app ensures focusable controls (e.g. the OAuth code field)
// receive keyboard input — without this, .accessory apps swallow text events.
NSApp.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
// AppKit auto-focuses the first focusable subview (the update Link) on
// open, which paints a focus ring around `v1.2.0`. Clear it so the
// popover opens with no focus ring; clicking a control still focuses it.
popover.contentViewController?.view.window?.makeFirstResponder(nil)
}
}
Loading
Loading