fix(ai-usage): 隐藏无额度来源的菜单栏占位

This commit is contained in:
ysicing committed 2026-09-24 10:05:26 +08:00
1 parent 23d015a000
commit bfce9c6cc5
2 files changed
+14 -42

No files matched your search

@@ -45,8 +45,7 @@ struct MenuBarReading {
var batteryHistory: [Double] = []
/// 本机日志今日 Token 总量;扫描未开启或还没有数据时为 nil
var aiTokens: Int?
/// 每个已启用来源各占一个紧凑读数;没有额度时显示占位,避免来源从菜单栏消失。
var aiQuotaProviders: [AIProviderID] = []
/// 只有可显示额度的来源才占用菜单栏读数;本机 Token 统计独立于订阅额度。
var aiQuotas: [MenuBarQuota] = []
var batteryCharging = false
/// 附在电池项旁的蓝牙设备:电量低的那一个,或没有电池的 Mac 上电量最低的那一个
@@ -74,12 +73,10 @@ struct MenuBarReading {
battery = store.battery?.level
batteryHistory = battery.map { Array(repeating: $0, count: 30) } ?? []
aiTokens = model.aiUsage.todayTokens
aiQuotaProviders = model.settings.aiUsageEnabled
? AIProviderID.allCases.filter { model.settings.aiUsageSources.contains($0) } : []
aiQuotas = model.aiUsage.visibleQuotaProviders(for: nil).compactMap { provider in
let state = model.aiUsage.visibleQuotaState(for: provider)
guard let snapshot = state.snapshot else { return nil }
// 已过重置时间的窗口不再代表当前额度(常见于重启后离线恢复的缓存),宁可显示占位。
// 已过重置时间的窗口不再代表当前额度(常见于重启后离线恢复的缓存),不显示过期额度。
let now = Date()
let windows = snapshot.windows.filter { $0.resetsAt.map { $0 > now } ?? true }
// 周额度比短时会话额度更适合作为常驻读数;模型专属周额度仅在通用周额度缺席时使用。
@@ -151,13 +148,10 @@ struct MenuBarReading {
case .battery:
battery.map { tr("电池 \(Format.percent($0))") + (batteryCharging ? tr(",充电中") : "") }
?? bluetoothDevice.map { tr("蓝牙设备电量 \($0.percent)%") }
// 没有读数也要给出一行,否则菜单栏只剩一个不会解释自己的 “AI —”
// 没有订阅额度时仍解释本机 Token 读数。
case .aiUsage:
if !aiQuotas.isEmpty {
aiQuotaProviders.map { provider in
guard let quota = aiQuotas.first(where: { $0.provider == provider }) else {
return "\(provider == .codex ? "Codex" : "Claude") · \(tr("暂无额度数据"))"
}
aiQuotas.map { quota in
let previous = quota.isStale ? "\(tr("上次额度")) · " : ""
let checked = quota.isStale
? " · \(tr("上次检查:\(quota.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))"
@@ -301,31 +295,12 @@ enum MenuBarRenderer {
private static func aiUsageSegment(reading: MenuBarReading, style: MenuBarStyle) -> Segment {
guard !reading.aiQuotas.isEmpty else {
if style == .icon, reading.aiQuotaProviders.count == 1,
let provider = reading.aiQuotaProviders.first {
return combine([aiProviderLogo(provider),
inlineValue(reading.aiTokens.map { tokenText($0) } ?? "—",
sample: "999.9M", alert: false)])
}
return textSegment(item: .aiUsage, value: reading.aiTokens.map { tokenText($0) } ?? "—",
sample: "999.9M", style: style)
}
let providers = reading.aiQuotaProviders.count > 1
? reading.aiQuotaProviders : [reading.aiQuotas[0].provider]
let status = combine(providers.map { provider in
let status = combine(reading.aiQuotas.map { quota in
let provider = quota.provider
let name = provider == .codex ? "Codex" : "Claude"
guard let quota = reading.aiQuotas.first(where: { $0.provider == provider }) else {
switch style {
case .ring, .history, .line, .pie, .meter:
return aiQuotaInlineText(label: name, value: "—")
case .inline:
return inlineText(label: name, value: "—", sample: "100%", alert: false)
case .icon:
return combine([aiProviderLogo(provider), inlineValue("—", sample: "100%", alert: false)])
case .stacked, .dot:
return aiQuotaText(label: name, value: "—")
}
}
let label = "\(name) · \(quota.shortWindowName)"
let value = "\(quota.remainingPercent)%"
let stacked = aiQuotaText(label: name, value: value)
@@ -100,11 +100,10 @@ private actor GatedUsageProvider: AIUsageProvider {
#expect(codexQuota?.window.kind == .weekly)
#expect(codexQuota?.remainingPercent == 27)
#expect(codexQuota?.resetText != "—")
#expect(reading.aiQuotaProviders == [.codex, .claude])
#expect(reading.aiQuotas.map(\.provider) == [.codex, .claude])
let both = MenuBarRenderer.image(reading: reading, items: [.aiUsage],
style: { _ in .stacked }, networkStyle: .dots, colorizeHighLoad: false, fahrenheit: false)
var codexOnly = reading
codexOnly.aiQuotaProviders = [.codex]
codexOnly.aiQuotas = reading.aiQuotas.filter { $0.provider == .codex }
let single = MenuBarRenderer.image(reading: codexOnly, items: [.aiUsage],
style: { _ in .stacked }, networkStyle: .dots, colorizeHighLoad: false, fahrenheit: false)
@@ -112,7 +111,6 @@ private actor GatedUsageProvider: AIUsageProvider {
let codexIcon = MenuBarRenderer.image(reading: codexOnly, items: [.aiUsage],
style: { _ in .icon }, networkStyle: .dots, colorizeHighLoad: false, fahrenheit: false)
var claudeOnly = reading
claudeOnly.aiQuotaProviders = [.claude]
claudeOnly.aiQuotas = reading.aiQuotas.filter { $0.provider == .claude }
let claudeIcon = MenuBarRenderer.image(reading: claudeOnly, items: [.aiUsage],
style: { _ in .icon }, networkStyle: .dots, colorizeHighLoad: false, fahrenheit: false)
@@ -134,7 +132,7 @@ private actor GatedUsageProvider: AIUsageProvider {
#expect(!tooltip.contains("5%"))
}
@Test func menuBarNamesAnEnabledSourceWithoutQuotaBesideTheAvailableOne() async {
@Test func menuBarOmitsSourceWithoutQuotaBesideTheAvailableOne() async {
let settings = AppSettings(defaults: defaultsForAIUsage())
settings.aiUsageEnabled = true
let codex = AIQuotaSnapshot(provider: .codex, windows: [
@@ -147,10 +145,8 @@ private actor GatedUsageProvider: AIUsageProvider {
await model.aiUsage.refresh()
let reading = MenuBarReading(model: model)
#expect(reading.aiQuotas.count == 1)
let claudeLine = reading.tooltip(items: [.aiUsage], fahrenheit: false)
.split(separator: "\n").first { $0.hasPrefix("Claude · ") }
#expect(claudeLine?.contains("%") == false)
#expect(reading.aiQuotas.map(\.provider) == [.codex])
#expect(!reading.tooltip(items: [.aiUsage], fahrenheit: false).contains("Claude"))
let image = MenuBarRenderer.image(reading: reading, items: [.aiUsage],
style: { _ in .stacked }, networkStyle: .dots, colorizeHighLoad: false, fahrenheit: false)
#expect(image.size.width > 0)
@@ -195,8 +191,7 @@ private actor GatedUsageProvider: AIUsageProvider {
#expect(reading.aiQuotas.first?.window.kind == .session)
#expect(reading.aiQuotas.first?.remainingPercent == 70)
let tooltip = reading.tooltip(items: [.aiUsage], fahrenheit: false)
let claudeLine = tooltip.split(separator: "\n").first { $0.hasPrefix("Claude · ") }
#expect(claudeLine?.contains("%") == false, "tooltip: \(tooltip)")
#expect(!tooltip.contains("Claude"), "tooltip: \(tooltip)")
#expect(!tooltip.contains("5%"), "tooltip: \(tooltip)")
}
@@ -208,7 +203,9 @@ private actor GatedUsageProvider: AIUsageProvider {
aiQuotaProviders: [StubQuotaProvider(results: [.failure(.notConfigured)])])
await model.aiUsage.refresh()
let tooltip = MenuBarReading(model: model).tooltip(items: [.aiUsage], fahrenheit: false)
let reading = MenuBarReading(model: model)
#expect(reading.aiQuotas.isEmpty)
let tooltip = reading.tooltip(items: [.aiUsage], fahrenheit: false)
#expect(tooltip.contains("42 Tokens"))
#expect(!tooltip.contains("剩余"))
}