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
24 changes: 24 additions & 0 deletions Muxy/Services/AIUsagePreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ enum AIUsageSettingsStore {
static let usageDisplayModeKey = "muxy.usage.displayMode"
static let usageEnabledKey = "muxy.usage.enabled"
static let showSecondaryLimitsKey = "muxy.usage.showSecondaryLimits"
static let sidebarPreviewProviderIDKey = "muxy.usage.sidebarPreviewProviderID"

static let defaultAutoRefreshInterval: AIUsageAutoRefreshInterval = .fiveMinutes
static let defaultUsageDisplayMode: AIUsageDisplayMode = .used
Expand Down Expand Up @@ -158,6 +159,29 @@ enum AIUsageSettingsStore {
static func setShowSecondaryLimits(_ show: Bool, defaults: UserDefaults = .standard) {
defaults.set(show, forKey: showSecondaryLimitsKey)
}

static func sidebarPreviewProviderID(defaults: UserDefaults = .standard) -> String? {
guard let raw = defaults.string(forKey: sidebarPreviewProviderIDKey),
!raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
return nil
}
return canonicalAIUsageProviderID(raw)
}

static func setSidebarPreviewProviderID(_ providerID: String?, defaults: UserDefaults = .standard) {
if let providerID {
defaults.set(canonicalAIUsageProviderID(providerID), forKey: sidebarPreviewProviderIDKey)
} else {
defaults.removeObject(forKey: sidebarPreviewProviderIDKey)
}
}

static func isSidebarPinned(providerID: String, pinnedRawValue: String) -> Bool {
let trimmed = pinnedRawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return canonicalAIUsageProviderID(trimmed) == canonicalAIUsageProviderID(providerID)
}
}

enum AIUsageAutoRefreshInterval: Int, CaseIterable, Identifiable {
Expand Down
41 changes: 25 additions & 16 deletions Muxy/Services/AIUsageService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,31 +186,40 @@ final class AIUsageService {
}

var previewProviderSnapshot: AIProviderUsageSnapshot? {
if previousSnapshotsCache.isEmpty {
return mostUsedProviderSnapshot
previewProviderSnapshot(pinnedRawValue: UserDefaults.standard
.string(forKey: AIUsageSettingsStore.sidebarPreviewProviderIDKey) ?? "")
}

func previewProviderSnapshot(pinnedRawValue: String) -> AIProviderUsageSnapshot? {
if let pinned = snapshots.first(where: { isPinnedCandidate($0, pinnedRawValue: pinnedRawValue) }) {
return pinned
}
return mostActiveProviderSnapshot ?? mostUsedProviderSnapshot
}

private func isPinnedCandidate(_ snapshot: AIProviderUsageSnapshot, pinnedRawValue: String) -> Bool {
guard AIUsageSettingsStore.isSidebarPinned(providerID: snapshot.providerID, pinnedRawValue: pinnedRawValue) else {
return false
}
guard case .available = snapshot.state else { return false }
return snapshot.rows.contains { $0.percent != nil }
}

var mostActiveProviderSnapshot: AIProviderUsageSnapshot? {
guard !snapshots.isEmpty else { return nil }
guard !snapshots.isEmpty, !previousSnapshotsCache.isEmpty else { return nil }

var maxScore: Double = 0
var maxDelta: Double = 0
var mostActive: AIProviderUsageSnapshot?

for current in snapshots {
guard let currentPercent = usedPercent(for: current) else { continue }

let score: Double = if let previous = previousSnapshotsCache.first(where: { $0.providerID == current.providerID }),
let previousPercent = usedPercent(for: previous)
{
abs(currentPercent - previousPercent)
} else {
currentPercent
}

if score > maxScore {
maxScore = score
guard let currentPercent = usedPercent(for: current),
let previous = previousSnapshotsCache.first(where: { $0.providerID == current.providerID }),
let previousPercent = usedPercent(for: previous)
else { continue }

let delta = abs(currentPercent - previousPercent)
if delta > maxDelta {
maxDelta = delta
mostActive = current
}
}
Expand Down
3 changes: 2 additions & 1 deletion Muxy/Views/Sidebar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ struct SidebarFooter: View {
@AppStorage(AIUsageSettingsStore.usageEnabledKey) private var usageEnabled = false
@AppStorage(AIUsageSettingsStore.usageDisplayModeKey) private var usageDisplayModeRaw = AIUsageSettingsStore.defaultUsageDisplayMode
.rawValue
@AppStorage(AIUsageSettingsStore.sidebarPreviewProviderIDKey) private var pinnedPreviewProviderID: String = ""
@State private var showThemePicker = false
@State private var showNotifications = false
@State private var showAIUsagePopover = false
Expand Down Expand Up @@ -305,7 +306,7 @@ struct SidebarFooter: View {
}

private var previewProviderDisplay: (percent: Int, iconName: String)? {
guard let snapshot = usageService.previewProviderSnapshot,
guard let snapshot = usageService.previewProviderSnapshot(pinnedRawValue: pinnedPreviewProviderID),
case .available = snapshot.state
else { return nil }

Expand Down
82 changes: 59 additions & 23 deletions Muxy/Views/Sidebar/AIUsagePanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,81 +72,117 @@ struct AIUsagePanel: View {
}()

var body: some View {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.system(size: 10, weight: .semibold))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(MuxyTheme.fgMuted)
Text("AI Usage")
.font(.system(size: 10, weight: .semibold))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(MuxyTheme.fgMuted)
Spacer()
Button(action: onRefresh) {
Group {
if isRefreshing {
ProgressView()
.controlSize(.mini)
.controlSize(.small)
} else {
Image(systemName: "arrow.clockwise")
.font(.system(size: 9, weight: .semibold))
.font(.system(size: 11, weight: .semibold))
}
}
.frame(width: 10, height: 10)
.frame(width: 14, height: 14)
}
.buttonStyle(.plain)
.foregroundStyle(MuxyTheme.fgMuted)
.disabled(isRefreshing)
.help("Refresh usage")
if let lastRefreshDate {
Text(Self.relativeFormatter.localizedString(for: lastRefreshDate, relativeTo: Date()))
.font(.system(size: 9))
.font(.system(size: 11))
.foregroundStyle(MuxyTheme.fgDim)
}
}

if snapshots.isEmpty {
Text(isRefreshing ? "Refreshing usage data..." : "No usage data yet.")
.font(.system(size: 10))
.font(.system(size: 12))
.foregroundStyle(MuxyTheme.fgDim)
}

if !snapshots.isEmpty {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 10) {
ForEach(snapshots) { snapshot in
AIProviderUsageView(snapshot: snapshot)
}
}
}
}
.padding(.horizontal, 8)
.padding(.vertical, 7)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(MuxyTheme.surface, in: RoundedRectangle(cornerRadius: 8))
}
}

struct AIProviderUsageView: View {
let snapshot: AIProviderUsageSnapshot

@AppStorage(AIUsageSettingsStore.sidebarPreviewProviderIDKey) private var pinnedProviderID: String = ""
@State private var pinHovered = false

private var isAvailable: Bool {
if case .available = snapshot.state { return true }
return false
}

private var isPinned: Bool {
AIUsageSettingsStore.isSidebarPinned(providerID: snapshot.providerID, pinnedRawValue: pinnedProviderID)
}

private func togglePin() {
if isPinned {
AIUsageSettingsStore.setSidebarPreviewProviderID(nil)
} else {
AIUsageSettingsStore.setSidebarPreviewProviderID(snapshot.providerID)
}
}

var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
ProviderIconView(iconName: snapshot.providerIconName, size: 12, style: .monochrome(MuxyTheme.fg))
ProviderIconView(iconName: snapshot.providerIconName, size: 14, style: .monochrome(MuxyTheme.fg))
Text(snapshot.providerName)
.font(.system(size: 10, weight: .medium))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(MuxyTheme.fg)

Spacer(minLength: 4)

if isAvailable {
Button(action: togglePin) {
Image(systemName: isPinned ? "pin.fill" : "pin")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(isPinned ? MuxyTheme.accent : (pinHovered ? MuxyTheme.fg : MuxyTheme.fgMuted))
.rotationEffect(.degrees(45))
.frame(width: 14, height: 14)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { pinHovered = $0 }
.help(isPinned ? "Unpin from sidebar" : "Show this provider in the sidebar")
}
}

switch snapshot.state {
case .available:
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 6) {
ForEach(snapshot.rows) { row in
AIUsageMetricRowView(row: row, fetchedAt: snapshot.fetchedAt)
}
}
case let .unavailable(message),
let .error(message):
Text(message)
.font(.system(size: 10))
.font(.system(size: 12))
.foregroundStyle(MuxyTheme.fgDim)
}
}
Expand Down Expand Up @@ -317,47 +353,47 @@ struct AIUsageMetricRowView: View {
}()

var body: some View {
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) {
Text(row.label)
.font(.system(size: 10))
.font(.system(size: 12))
.foregroundStyle(MuxyTheme.fgMuted)

if paceDetailText != nil {
Circle()
.fill(paceIndicatorColor)
.frame(width: 5, height: 5)
.frame(width: 6, height: 6)
}
Spacer()
if let percent = displayPercent {
Text("\(Int(percent.rounded()))%")
.font(.system(size: 10, weight: .medium))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(MuxyTheme.fg)
}
if let detail = displayDetail {
Text(detail)
.font(.system(size: 9))
.font(.system(size: 11))
.foregroundStyle(MuxyTheme.fgDim)
}
}

if let percent = displayPercent {
ProgressView(value: percent, total: 100)
.tint(MuxyTheme.accent)
.controlSize(.mini)
.controlSize(.small)
}

if let resetDate = row.resetDate {
HStack(spacing: 6) {
Text("Resets \(Self.resetFormatter.string(from: resetDate))")
.font(.system(size: 9))
.font(.system(size: 11))
.foregroundStyle(MuxyTheme.fgDim)

Spacer(minLength: 0)

if let paceDetailText {
Text(paceDetailText)
.font(.system(size: 9))
.font(.system(size: 11))
.foregroundStyle(MuxyTheme.fgDim)
.lineLimit(1)
}
Expand Down
Loading