Skip to content
Open
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
4 changes: 4 additions & 0 deletions CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
SYNTHETIC1111111111111111 /* SyntheticProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = SYNTHETIC2222222222222222 /* SyntheticProvider.swift */; };
SYNTHTEST2222222222222222 /* SyntheticProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SYNTHTEST1111111111111111 /* SyntheticProviderTests.swift */; };
NANOGPTTESTBF1111111111 /* NanoGptProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NANOGPTTESTFR1111111111 /* NanoGptProviderTests.swift */; };
ZAITESTBF1111111111111 /* ZaiCodingPlanProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ZAITESTFR1111111111111 /* ZaiCodingPlanProviderTests.swift */; };
TOKENTESTBF1111111111111 /* TokenManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TOKENTESTFR1111111111111 /* TokenManagerTests.swift */; };
CODEXTESTBF111111111111 /* CodexProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CODEXTESTFR111111111111 /* CodexProviderTests.swift */; };
OCAUTHTESTBF11111111111 /* OpenCodeAuthDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OCAUTHTESTFR11111111111 /* OpenCodeAuthDecodingTests.swift */; };
Expand Down Expand Up @@ -233,6 +234,7 @@
SYNTHETIC2222222222222222 /* SyntheticProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntheticProvider.swift; sourceTree = "<group>"; };
SYNTHTEST1111111111111111 /* SyntheticProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntheticProviderTests.swift; sourceTree = "<group>"; };
NANOGPTTESTFR1111111111 /* NanoGptProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NanoGptProviderTests.swift; sourceTree = "<group>"; };
ZAITESTFR1111111111111 /* ZaiCodingPlanProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZaiCodingPlanProviderTests.swift; sourceTree = "<group>"; };
TOKENTESTFR1111111111111 /* TokenManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManagerTests.swift; sourceTree = "<group>"; };
CODEXTESTFR111111111111 /* CodexProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexProviderTests.swift; sourceTree = "<group>"; };
OCAUTHTESTFR11111111111 /* OpenCodeAuthDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeAuthDecodingTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -455,6 +457,7 @@
54353FD130DDE0500F6B367F /* MenuResultBuilderTests.swift */,
SYNTHTEST1111111111111111 /* SyntheticProviderTests.swift */,
NANOGPTTESTFR1111111111 /* NanoGptProviderTests.swift */,
ZAITESTFR1111111111111 /* ZaiCodingPlanProviderTests.swift */,
MINIMAXTESTFR11111111111 /* MiniMaxProviderTests.swift */,
OCGOTESTFR11111111111 /* OpenCodeGoProviderTests.swift */,
GROKTESTFR11111111111 /* GrokProviderTests.swift */,
Expand Down Expand Up @@ -715,6 +718,7 @@
B58BAD3BFD97973070A2A892 /* MenuResultBuilderTests.swift in Sources */,
SYNTHTEST2222222222222222 /* SyntheticProviderTests.swift in Sources */,
NANOGPTTESTBF1111111111 /* NanoGptProviderTests.swift in Sources */,
ZAITESTBF1111111111111 /* ZaiCodingPlanProviderTests.swift in Sources */,
MINIMAXTESTBF11111111111 /* MiniMaxProviderTests.swift in Sources */,
OCGOTESTBF11111111111 /* OpenCodeGoProviderTests.swift in Sources */,
GROKTESTBF11111111111 /* GrokProviderTests.swift in Sources */,
Expand Down
110 changes: 68 additions & 42 deletions CopilotMonitor/CopilotMonitor/App/StatusBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
case usage
}

private enum UsageDisplayWindowPriority: Int, CaseIterable {
enum UsageDisplayWindowPriority: Int, CaseIterable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style Test-only visibility: production API surface expands
[3라운드째 미합의]

These controller algorithms and candidate types are made module-visible solely for direct tests. Keep them private and test the existing behavior path, or expose one narrowly scoped seam instead of widening several production APIs.

case weekly = 0
case monthly = 1
case daily = 2
case hourly = 3
case fallback = 4
}

private struct UsagePercentCandidate {
struct UsagePercentCandidate {
let percent: Double
let priority: UsageDisplayWindowPriority
}
Expand Down Expand Up @@ -917,20 +917,20 @@
return ProviderIdentifier.allCases.first(where: { isProviderEnabled($0) })
}

private func normalizedUsagePercent(_ percent: Double?) -> Double? {
private static func normalizedUsagePercent(_ percent: Double?) -> Double? {
guard let percent, percent.isFinite else { return nil }
return min(max(percent, 0), 999)
}

private func dailyPercentFromDetails(_ details: DetailedUsage?) -> Double? {
private static func dailyPercentFromDetails(_ details: DetailedUsage?) -> Double? {
guard let details else { return nil }
if let limit = details.limit, limit > 0, let used = details.dailyUsage {
return (used / limit) * 100.0
}
return details.dailyUsage
}

private func priorityForWindowHours(
private static func priorityForWindowHours(
_ hours: Int?,
fallback: UsageDisplayWindowPriority
) -> UsageDisplayWindowPriority {
Expand All @@ -941,7 +941,7 @@
return .hourly
}

private func chutesMonthlyPercentFromDetails(_ details: DetailedUsage?) -> Double? {
private static func chutesMonthlyPercentFromDetails(_ details: DetailedUsage?) -> Double? {
guard let details else { return nil }

let configuredPlan = SubscriptionSettingsManager.shared.getPlan(for: .chutes)
Expand All @@ -959,14 +959,27 @@
return details.chutesMonthlyValueUsedPercent
}

private func usagePercentCandidates(
/// Window percentages shown on the Z.AI top-level quota/provider row.
/// Unlike the status-bar candidate list (priority-ordered), the top-level
/// row shows every active window side by side, so the Lite weekly window
/// must be included here too — omitting it makes the row diverge from the
/// usage windows (5h session, weekly, MCP monthly).
private static func zaiCodingPlanTopLevelPercents(details: DetailedUsage?) -> [Double] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style duplicated window lists: status-bar paths can drift
The top-level row, candidate selection, and change-detection paths each enumerate the Z.AI windows separately. Define one shared window descriptor with its field and display priority, then derive these lists from it while preserving each path's ordering; otherwise the next window addition needs synchronized edits and can silently drift.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style window lists: status-bar paths can drift
[2라운드째 미합의]

Z.AI windows are still enumerated separately for the top-level row, status-bar candidate selection, and change detection. Define one shared window descriptor with its field and display priority, then derive each ordered list from it; otherwise adding the next window needs synchronized edits and can silently make one path disagree with the others.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style window lists duplicated: status-bar paths can disagree
[3라운드째 미합의]

The top-level row now has its own Z.AI window list, while candidate selection and change detection still enumerate the fields separately. Define one ordered window descriptor and derive these consumer lists from it; otherwise the next quota window needs synchronized edits and one path can silently disagree.

[
details?.tokenUsagePercent,
details?.weeklyUsagePercent,
details?.mcpUsagePercent
].compactMap { $0 }
}

static func usagePercentCandidates(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style test-only helpers: controller API expands
These helpers and their candidate types are made module-visible solely for direct tests. Keep the production helpers private and assert the existing controller behavior, or expose one narrowly scoped seam; exposing both algorithms makes future refactors preserve a test-only API.

identifier: ProviderIdentifier,
usage: ProviderUsage,
details: DetailedUsage?
) -> [UsagePercentCandidate] {
var candidates: [UsagePercentCandidate] = []
func add(_ percent: Double?, priority: UsageDisplayWindowPriority) {
guard let normalized = normalizedUsagePercent(percent) else { return }
guard let normalized = Self.normalizedUsagePercent(percent) else { return }
candidates.append(UsagePercentCandidate(percent: normalized, priority: priority))
}

Expand Down Expand Up @@ -995,19 +1008,19 @@
case .codex:
add(
details?.secondaryUsage,
priority: priorityForWindowHours(details?.codexSecondaryWindowHours, fallback: .weekly)
priority: Self.priorityForWindowHours(details?.codexSecondaryWindowHours, fallback: .weekly)
)
add(
details?.sparkSecondaryUsage,
priority: priorityForWindowHours(details?.sparkSecondaryWindowHours, fallback: .weekly)
priority: Self.priorityForWindowHours(details?.sparkSecondaryWindowHours, fallback: .weekly)
)
add(
dailyPercentFromDetails(details),
priority: priorityForWindowHours(details?.codexPrimaryWindowHours, fallback: .daily)
Self.dailyPercentFromDetails(details),
priority: Self.priorityForWindowHours(details?.codexPrimaryWindowHours, fallback: .daily)
)
add(
details?.sparkUsage,
priority: priorityForWindowHours(details?.sparkPrimaryWindowHours, fallback: .hourly)
priority: Self.priorityForWindowHours(details?.sparkPrimaryWindowHours, fallback: .hourly)
)
case .commandCode:
add(usage.usagePercentage, priority: .monthly)
Expand All @@ -1024,11 +1037,12 @@
case .zaiCodingPlan:
add(details?.mcpUsagePercent, priority: .monthly)
add(details?.tokenUsagePercent, priority: .hourly)
add(details?.weeklyUsagePercent, priority: .weekly)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: Bug weekly window diverges: quota row omits Lite usage
[2라운드째 미합의]

This adds the weekly window to status-bar candidate selection, but the top-level Z.AI quota row still formats usedPercents from only token and MCP values at the two interaction sites below. For a Lite CREDIT_LIMIT account, the status bar can choose weekly while the provider row omits it. Add weeklyUsagePercent to both arrays (and a top-level-row regression assertion) so every display path agrees.

case .nanoGpt:
add(details?.sevenDayUsage, priority: .weekly)
case .chutes:
add(chutesMonthlyPercentFromDetails(details), priority: .monthly)
add(dailyPercentFromDetails(details), priority: .daily)
add(Self.chutesMonthlyPercentFromDetails(details), priority: .monthly)
add(Self.dailyPercentFromDetails(details), priority: .daily)
case .synthetic:
add(details?.fiveHourUsage, priority: .hourly)
case .tavilySearch, .braveSearch:
Expand All @@ -1046,7 +1060,7 @@
usage: ProviderUsage,
details: DetailedUsage?
) -> Double? {
let candidates = usagePercentCandidates(identifier: identifier, usage: usage, details: details)
let candidates = Self.usagePercentCandidates(identifier: identifier, usage: usage, details: details)
guard let selectedPriority = candidates.map(\.priority.rawValue).min() else {
return nil
}
Expand All @@ -1068,7 +1082,7 @@
// Main result candidates
if case .quotaBased = result.usage {
allCandidates.append(contentsOf:
usagePercentCandidates(identifier: identifier, usage: result.usage, details: result.details)
Self.usagePercentCandidates(identifier: identifier, usage: result.usage, details: result.details)
)
}

Expand All @@ -1077,15 +1091,15 @@
for account in accounts {
guard case .quotaBased = account.usage else { continue }
allCandidates.append(contentsOf:
usagePercentCandidates(identifier: identifier, usage: account.usage, details: account.details)
Self.usagePercentCandidates(identifier: identifier, usage: account.usage, details: account.details)
)
}
}

// Gemini CLI special case: add as fallback priority since these don't have window metadata
if identifier == .geminiCLI, let geminiAccounts = result.details?.geminiAccounts {
for account in geminiAccounts {
if let normalized = normalizedUsagePercent(100.0 - account.remainingPercentage) {
if let normalized = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) {
allCandidates.append(UsagePercentCandidate(percent: normalized, priority: .fallback))
}
}
Expand All @@ -1103,12 +1117,12 @@
.max()
}

private func usedPercentsForChangeDetection(identifier: ProviderIdentifier, result: ProviderResult) -> [Double] {
static func usedPercentsForChangeDetection(identifier: ProviderIdentifier, result: ProviderResult) -> [Double] {
var usedPercents: [Double] = []

func appendMetrics(usage: ProviderUsage, details: DetailedUsage?) {
guard case .quotaBased = usage else { return }
if let percent = normalizedUsagePercent(usage.usagePercentage) {
if let percent = Self.normalizedUsagePercent(usage.usagePercentage) {
usedPercents.append(percent)
}

Expand All @@ -1126,10 +1140,11 @@
details.cursorApiUsage,
details.tokenUsagePercent,
details.mcpUsagePercent,
details.weeklyUsagePercent,
details.openCodeGoMonthlyUsage
]
for percent in extraPercents {
if let normalized = normalizedUsagePercent(percent) {
if let normalized = Self.normalizedUsagePercent(percent) {
usedPercents.append(normalized)
}
}
Expand All @@ -1146,7 +1161,7 @@

if identifier == .geminiCLI, let geminiAccounts = result.details?.geminiAccounts {
for account in geminiAccounts {
if let percent = normalizedUsagePercent(100.0 - account.remainingPercentage) {
if let percent = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) {
usedPercents.append(percent)
}
}
Expand All @@ -1163,7 +1178,7 @@
kind: .cost
)
case .quotaBased:
let cappedPercents = usedPercentsForChangeDetection(identifier: identifier, result: result).map { min($0, 100.0) }
let cappedPercents = Self.usedPercentsForChangeDetection(identifier: identifier, result: result).map { min($0, 100.0) }
// Use aggregate quota usage for change detection so non-max windows/accounts can still trigger updates.
let aggregatePercent = cappedPercents.isEmpty
? min(max(result.usage.usagePercentage, 0.0), 100.0)
Expand Down Expand Up @@ -2136,10 +2151,10 @@
].compactMap { $0 }
usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents
} else if identifier == .zaiCodingPlan {
let percents = [account.details?.tokenUsagePercent, account.details?.mcpUsagePercent].compactMap { $0 }
let percents = Self.zaiCodingPlanTopLevelPercents(details: account.details)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style duplicated quota-row assembly: account and single paths can drift
The account-row and single-result branches repeat the full provider-specific percentage selection, so adding a window or correcting ordering requires two edits. Extract one helper that accepts DetailedUsage? and the fallback percentage, then use it from both branches.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style Duplicated quota-row assembly: account paths can drift
[2라운드째 미합의]

The account-row and single-result branches still duplicate the provider-specific percentage selection. Extract one helper accepting DetailedUsage? plus the fallback percentage and use it from both branches, so adding the next window needs one edit.

usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents
} else if identifier == .chutes {
let percents = [dailyPercentFromDetails(account.details), chutesMonthlyPercentFromDetails(account.details)].compactMap { $0 }
let percents = [Self.dailyPercentFromDetails(account.details), Self.chutesMonthlyPercentFromDetails(account.details)].compactMap { $0 }
usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents
} else if identifier == .nanoGpt {
let percents = [
Expand Down Expand Up @@ -2222,10 +2237,10 @@
].compactMap { $0 }
usedPercents = percents.isEmpty ? [singlePercent] : percents
} else if identifier == .zaiCodingPlan {
let percents = [result.details?.tokenUsagePercent, result.details?.mcpUsagePercent].compactMap { $0 }
let percents = Self.zaiCodingPlanTopLevelPercents(details: result.details)
usedPercents = percents.isEmpty ? [singlePercent] : percents
} else if identifier == .chutes {
let percents = [dailyPercentFromDetails(result.details), chutesMonthlyPercentFromDetails(result.details)].compactMap { $0 }
let percents = [Self.dailyPercentFromDetails(result.details), Self.chutesMonthlyPercentFromDetails(result.details)].compactMap { $0 }
usedPercents = percents.isEmpty ? [singlePercent] : percents
} else if identifier == .nanoGpt {
let percents = [
Expand Down Expand Up @@ -2304,7 +2319,7 @@
for account in geminiAccounts {
hasQuota = true
let accountNumber = account.accountIndex + 1
let usedPercent = normalizedUsagePercent(100.0 - account.remainingPercentage) ?? 0.0
let usedPercent = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) ?? 0.0
// Gemini account rows should represent Gemini quota only.
// Antigravity has its own provider row and should not be duplicated here.
let usedPercents: [Double] = [usedPercent]
Expand Down Expand Up @@ -3843,7 +3858,7 @@
// 3. OpenRouter - only has current cost, no daily history
// We'll include today's cost if available
if let routerResult = providerResults[.openRouter],
case .payAsYouGo(_, let cost, _) = routerResult.usage,

Check warning on line 3861 in CopilotMonitor/CopilotMonitor/App/StatusBarController.swift

View workflow job for this annotation

GitHub Actions / Build & Test

immutable value 'cost' was never used; consider replacing with '_' or removing it
let dailyCost = routerResult.details?.dailyUsage {
let today = Calendar.current.startOfDay(for: Date())
if aggregatedDailyCosts[today] == nil {
Expand Down Expand Up @@ -4248,22 +4263,33 @@
)
),
.zaiCodingPlan: ProviderResult(
usage: .quotaBased(remaining: 1, entitlement: 100, overagePermitted: false),
usage: .quotaBased(remaining: 88, entitlement: 100, overagePermitted: false),
details: DetailedUsage(
tokenUsagePercent: 99.0,
tokenUsagePercent: 12.0,
tokenUsageReset: oneDayFromNow,
tokenUsageUsed: 990_000,
tokenUsageTotal: 1_000_000,
mcpUsagePercent: 45.0,
mcpUsagePercent: 2.0,
mcpUsageReset: oneDayFromNow,
mcpUsageUsed: 45,
mcpUsageTotal: 100,
modelUsageTokens: 500_000,
modelUsageCalls: 128,
toolNetworkSearchCount: 42,
toolWebReadCount: 15,
toolZreadCount: 8
)
weeklyUsagePercent: 1.0,
weeklyUsageReset: sevenDaysFromNow
),
accounts: [
ProviderAccountResult(
accountIndex: 0,
accountId: "zai-session",
usage: .quotaBased(remaining: 88, entitlement: 100, overagePermitted: false),
details: DetailedUsage(
tokenUsagePercent: 12.0,
mcpUsagePercent: 2.0,
weeklyUsagePercent: 1.0
)
),
ProviderAccountResult(
accountIndex: 1,
accountId: "zai-weekly",
usage: .quotaBased(remaining: 99, entitlement: 100, overagePermitted: false),
details: DetailedUsage(weeklyUsagePercent: 1.0)
)
]
),
.geminiCLI: ProviderResult(
usage: .quotaBased(remaining: 85, entitlement: 100, overagePermitted: false),
Expand Down
16 changes: 16 additions & 0 deletions CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,22 @@ extension StatusBarController {
submenu.addItem(item)
}

// === Weekly Usage (CREDIT_LIMIT unit=6, lite tier) ===

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style window rendering: provider rows can drift
[3라운드째 미합의]

The Z.AI token, MCP, and weekly branches repeat the same usage-window and limit-row construction used by sibling provider branches. Move the optional-window rendering into a small helper driven by window definitions, keeping only provider-specific labels and fields in data, so separators and reset handling do not drift across synchronized edits.

if let weeklyUsage = details.weeklyUsagePercent {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style Repeated window rendering: quota rows can drift
The token, MCP, and weekly branches repeat the same usage-window plus optional limit-row sequence. Consolidate that into a small local helper accepting the label, values, reset date, and window style so future Z.AI windows do not create another drift point.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style repeated window rendering: provider rows can drift
[2라운드째 미합의]

The new Z.AI weekly branch repeats the optional-window/separator/row sequence already hand-written for several providers. Build a small window-descriptor list and one shared add-window routine for separator handling, leaving only provider-specific labels and fields in data; that keeps future window rows from diverging.

let items = createUsageWindowRow(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: Bug weekly reset input missing: reset row disappears
This weekly branch passes the reset date but not the window duration, so createUsageWindowRow reaches the paceInfo guard and returns before adding the Resets: item. Lite users get the percentage and totals but no reset time. Pass windowHours: 24 * 7 (or an equivalent weekly input) and add a regression assertion for the rendered reset row.

label: "Weekly (7d)",
usagePercent: weeklyUsage,
resetDate: details.weeklyUsageReset,
windowHours: 24 * 7,
isMonthly: false
)
items.forEach { submenu.addItem($0) }
}
if let weeklyUsed = details.weeklyUsageUsed, let weeklyTotal = details.weeklyUsageTotal {
let item = createLimitRow(label: "Weekly", used: Double(weeklyUsed), total: Double(weeklyTotal))
submenu.addItem(item)
}

// === Last 24h stats (provider-specific, keep as-is) ===
let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal
Expand Down
Loading
Loading