diff --git a/Sources/CodeCaps/GlanceViews.swift b/Sources/CodeCaps/GlanceViews.swift index 43ce6eb..9c7b3f4 100644 --- a/Sources/CodeCaps/GlanceViews.swift +++ b/Sources/CodeCaps/GlanceViews.swift @@ -1619,13 +1619,16 @@ struct GlanceMeter: View { return glanceResetCountdown(snapshot.resetAt, now: now) } + private var isMuseAssist: Bool { snapshot.window.canonicalProviderKey == "muse-assist" } + /// The tooltip for a stale meter that has no reset to count down to. /// /// The countdown slot is empty then, so its own tooltip (which carries the /// "updated h:mm" staleness) has nothing to hang on; the bar carries it - /// instead, and fresh meters keep an empty tooltip. + /// instead, and fresh meters keep an empty tooltip. Muse Assist never + /// displays "Updated..." captions (owner 2026-10-17). private var staleBarHelp: String { - guard countdown.isEmpty else { return "" } + guard countdown.isEmpty, !isMuseAssist else { return "" } return glanceResetHelp(nil, now: now, observedAt: snapshot.observedAt, isFresh: snapshot.isFresh) ?? "" } @@ -1689,7 +1692,7 @@ struct GlanceMeter: View { // variable-width value cannot be centred in a trailing // frame. .frame(width: Metrics.glanceMeterCountdownWidth, alignment: .center) - .help(isExtraLabel ? "Extra token usage" : (glanceResetHelp(snapshot.resetAt, now: now, observedAt: snapshot.observedAt, isFresh: snapshot.isFresh) ?? "")) + .help(isExtraLabel ? "Extra token usage" : (glanceResetHelp(snapshot.resetAt, now: now, observedAt: isMuseAssist ? nil : snapshot.observedAt, isFresh: snapshot.isFresh) ?? "")) } else { Color.clear .frame(width: Metrics.glanceMeterCountdownWidth, height: 1) @@ -1710,7 +1713,7 @@ struct GlanceMeter: View { if !countdown.isEmpty { parts.append("resets in \(glanceResetFullCountdown(snapshot.resetAt, now: now))") } - if !snapshot.isFresh, let observed = snapshot.observedAt { + if !isMuseAssist, !snapshot.isFresh, let observed = snapshot.observedAt { parts.append("updated \(observed.formatted(date: .omitted, time: .shortened))") } return parts.joined(separator: ", ") @@ -2429,6 +2432,28 @@ func glanceResetCountdown(_ reset: Date?, now: Date) -> String { return glanceCountdownUnits(seconds: seconds).prefix(2).joined(separator: " ") } +/// Reset countdown in a single unit rounded up: "2h", "59m", "3d", "<1m", "due". +/// +/// When the PiP HUD narrows to where two time units cannot fit, it degrades to +/// one unit rounded up rather than dropping the countdown entirely: +/// 1.2h (1h 12m) -> 2h, 59m -> 59m, 2d 2h -> 3d. +func glanceResetCompactCountdown(_ reset: Date?, now: Date) -> String { + guard let reset else { return "" } + let seconds = Int(reset.timeIntervalSince(now).rounded()) + guard seconds > 0 else { return "due" } + guard seconds >= 60 else { return "<1m" } + let minutes = seconds / 60 + if minutes < 60 { + return "\(minutes)m" + } else if minutes < 1_440 { + let hours = (minutes + 59) / 60 + return "\(hours)h" + } else { + let days = (minutes + 1_439) / 1_440 + return "\(days)d" + } +} + /// The whole countdown, down to the minute, with zero units skipped the same /// way: "4d 2h 42m", "2h 42m", "2h", "45m". func glanceResetFullCountdown(_ reset: Date?, now: Date) -> String { diff --git a/Sources/CodeCaps/PipWidget.swift b/Sources/CodeCaps/PipWidget.swift index e0708e2..dc16db4 100644 --- a/Sources/CodeCaps/PipWidget.swift +++ b/Sources/CodeCaps/PipWidget.swift @@ -31,6 +31,7 @@ enum PipMetrics { /// at the narrow end when it was right-aligned into a tight frame. static let percentWidth: CGFloat = 30 static let countdownWidth: CGFloat = 42 + static let countdownCompactWidth: CGFloat = 28 static let barMinWidth: CGFloat = 20 static let barHeight: CGFloat = 4 @@ -64,6 +65,10 @@ enum PipMetrics { static let meterMinWidthWithCountdown: CGFloat = meterMinWidth + meterSpacing + countdownWidth + /// The same meter plus its compact reset countdown. + static let meterMinWidthWithCompactCountdown: CGFloat = + meterMinWidth + meterSpacing + countdownCompactWidth + /// Caption-less meter: bar and percent only. private static let bareMeterMinWidth: CGFloat = meterSpacing + barMinWidth + meterSpacing + percentWidth @@ -73,15 +78,23 @@ enum PipMetrics { providerLogoSize + rowSpacing + titleWidth + rowSpacing + meterMinWidthWithCountdown + rowSpacing + meterMinWidthWithCountdown - /// Provider logo, title and two meters, countdowns dropped. - static let noCountdownMinWidth: CGFloat = - providerLogoSize + rowSpacing + titleWidth + rowSpacing - + meterMinWidth + rowSpacing + meterMinWidth + /// Provider logo and two meters with 2-unit countdowns; platform name dropped first. + static let logoWithCountdownMinWidth: CGFloat = + providerLogoSize + rowSpacing + + meterMinWidthWithCountdown + rowSpacing + meterMinWidthWithCountdown + + /// Provider logo and two meters with 1-unit rounded countdowns; platform name dropped. + static let logoWithCompactCountdownMinWidth: CGFloat = + providerLogoSize + rowSpacing + + meterMinWidthWithCompactCountdown + rowSpacing + meterMinWidthWithCompactCountdown - /// Provider logo and two meters, no countdowns and no title (platform name dropped first). + /// Provider logo and two meters, countdowns and title dropped (platform name dropped first). static let logoOnlyMinWidth: CGFloat = providerLogoSize + rowSpacing + meterMinWidth + rowSpacing + meterMinWidth + /// Legacy alias so any existing reference compiles cleanly. + static let noCountdownMinWidth: CGFloat = logoOnlyMinWidth + /// Two labelled meters, with no provider identity at all. static let minimalMinWidth: CGFloat = meterMinWidth + rowSpacing + meterMinWidth @@ -100,14 +113,16 @@ enum PipMetrics { /// How much of a row survives at a given panel width, richest first. /// - /// The platform name is dropped before the second bar: - /// `full > noCountdown > logoOnly > minimal > singleMeter > barsOnly > singleBar`. + /// The platform name is dropped before the countdown disappears: + /// `full > logoWithFullCountdown > logoWithCompactCountdown > logoOnly > minimal > singleMeter > barsOnly > singleBar`. enum Detail: Int, CaseIterable, Comparable { - /// Bar, percent, reset countdown; provider logo and title. + /// Bar, percent, 2-unit reset countdown; provider logo and title. case full - /// Two meters, no countdowns. - case noCountdown - /// Two meters and the provider's logo, but platform name dropped. + /// Two meters with 2-unit countdowns; provider logo kept, platform name dropped. + case logoWithFullCountdown + /// Two meters with 1-unit rounded-up countdowns; provider logo kept, platform name dropped. + case logoWithCompactCountdown + /// Two meters, countdowns and platform name dropped; provider logo kept. case logoOnly /// Two labelled meters, provider identity dropped entirely. case minimal @@ -118,17 +133,24 @@ enum PipMetrics { /// One bar and its percentage. case singleBar + /// Legacy alias for tests. + static let noCountdown = Detail.logoOnly + static func < (lhs: Detail, rhs: Detail) -> Bool { lhs.rawValue > rhs.rawValue } var maxMeters: Int { switch self { - case .full, .noCountdown, .logoOnly, .minimal, .barsOnly: return 2 + case .full, .logoWithFullCountdown, .logoWithCompactCountdown, .logoOnly, .minimal, .barsOnly: return 2 case .singleMeter, .singleBar: return 1 } } var showsCountdown: Bool { - self == .full || self == .singleMeter + self == .full || self == .logoWithFullCountdown || self == .logoWithCompactCountdown || self == .singleMeter + } + + var usesCompactCountdown: Bool { + self == .logoWithCompactCountdown } var showsCadence: Bool { @@ -136,12 +158,12 @@ enum PipMetrics { } var showsProviderLogo: Bool { - self == .full || self == .noCountdown || self == .logoOnly || self == .singleMeter + self == .full || self == .logoWithFullCountdown || self == .logoWithCompactCountdown || self == .logoOnly || self == .singleMeter } - /// The platform name drops before the second bar. + /// The platform name drops before the countdown: only `.full` keeps the title. var showsTitle: Bool { - self == .full || self == .noCountdown + self == .full } } @@ -151,7 +173,8 @@ enum PipMetrics { let available = width - padding * 2 switch available { case fullMinWidth...: return .full - case noCountdownMinWidth...: return .noCountdown + case logoWithCountdownMinWidth...: return .logoWithFullCountdown + case logoWithCompactCountdownMinWidth...: return .logoWithCompactCountdown case logoOnlyMinWidth...: return .logoOnly case minimalMinWidth...: return .minimal case singleMeterMinWidth...: return .singleMeter @@ -178,10 +201,11 @@ enum PipMetrics { let base: CGFloat switch detail { case .full: base = fullMinWidth - case .noCountdown: base = noCountdownMinWidth - case .singleMeter: base = singleMeterMinWidth + case .logoWithFullCountdown: base = logoWithCountdownMinWidth + case .logoWithCompactCountdown: base = logoWithCompactCountdownMinWidth case .logoOnly: base = logoOnlyMinWidth case .minimal: base = minimalMinWidth + case .singleMeter: base = singleMeterMinWidth case .barsOnly: base = barsOnlyMinWidth case .singleBar: base = singleMeterMinWidth } @@ -864,10 +888,12 @@ struct PipWidgetView: View { private func detailWindowRow(_ snapshot: QuotaWindowSnapshot, detail: PipMetrics.Detail) -> some View { let metrics = QuotaBarMetrics(snapshot: snapshot, now: model.now) let remaining = snapshot.remainingPercent - let cd = glanceResetCountdown(snapshot.resetAt, now: model.now) + let isExtra = isMuseAssistExtra(snapshot) + let cd = isExtra ? "extra" : (detail.usesCompactCountdown ? glanceResetCompactCountdown(snapshot.resetAt, now: model.now) : glanceResetCountdown(snapshot.resetAt, now: model.now)) let showExpanded = detail == .full let cadenceCaption = showExpanded ? singleModelCadenceExpanded(snapshot) : glanceMeterCaption(snapshot) let cleanLabel = cleanWindowLabel(snapshot.window.label.isEmpty ? "Quota" : snapshot.window.label) + let countdownWidth = detail.usesCompactCountdown ? PipMetrics.countdownCompactWidth : PipMetrics.countdownWidth return VStack(alignment: .leading, spacing: 1) { HStack(spacing: 5) { @@ -886,9 +912,11 @@ struct PipWidgetView: View { .fixedSize() if !cd.isEmpty { Text(cd) - .font(.system(size: 9).monospacedDigit()) - .foregroundStyle(background.countdownInk) + .font(isExtra ? .system(size: 9, weight: .bold) : .system(size: 9).monospacedDigit()) + .foregroundStyle(isExtra ? background.ink : background.countdownInk) .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(width: countdownWidth, alignment: .trailing) } } QuotaUsageBar( @@ -899,7 +927,7 @@ struct PipWidgetView: View { ) .frame(height: 5) } - .help(snapshot.resetAt.map { "Resets \(glanceResetCountdown($0, now: model.now))" } ?? "") + .help(isExtra ? "Extra token usage" : (snapshot.resetAt.map { "Resets \(glanceResetCountdown($0, now: model.now))" } ?? "")) } /// The HUD's capped state, in place of its bars: the words "At Cap" and @@ -922,10 +950,19 @@ struct PipWidgetView: View { private func pipMeter(_ snapshot: QuotaWindowSnapshot, detail: PipMetrics.Detail) -> some View { let metrics = QuotaBarMetrics(snapshot: snapshot, now: model.now) - let cd = glanceResetCountdown(snapshot.resetAt, now: model.now) + let isExtra = isMuseAssistExtra(snapshot) + let cd: String + if isExtra { + cd = "extra" + } else if detail.usesCompactCountdown { + cd = glanceResetCompactCountdown(snapshot.resetAt, now: model.now) + } else { + cd = glanceResetCountdown(snapshot.resetAt, now: model.now) + } let remaining = snapshot.remainingPercent let showExpanded = isSingleModel && detail == .full let cadenceCaption = showExpanded ? singleModelCadenceExpanded(snapshot) : glanceMeterCaption(snapshot) + let countdownWidth = detail.usesCompactCountdown ? PipMetrics.countdownCompactWidth : PipMetrics.countdownWidth return HStack(spacing: PipMetrics.meterSpacing) { if detail.showsCadence { @@ -953,11 +990,14 @@ struct PipWidgetView: View { if detail.showsCountdown, !cd.isEmpty { Text(cd) - .font(.system(size: 9, weight: .medium).monospacedDigit()) - .foregroundStyle(background.countdownInk) + .font(isExtra + ? .system(size: 9, weight: .bold) + : .system(size: 9, weight: .medium).monospacedDigit()) + .foregroundStyle(isExtra ? background.ink : background.countdownInk) .lineLimit(1) + .minimumScaleFactor(0.75) .fixedSize() - .frame(width: PipMetrics.countdownWidth, alignment: .trailing) + .frame(width: countdownWidth, alignment: .trailing) } } .fixedSize(horizontal: false, vertical: true) diff --git a/Sources/CodeCaps/PlatformLogo.swift b/Sources/CodeCaps/PlatformLogo.swift index 719c8cf..c08a135 100644 --- a/Sources/CodeCaps/PlatformLogo.swift +++ b/Sources/CodeCaps/PlatformLogo.swift @@ -280,7 +280,8 @@ private static let resourceNames: [String: String] = [ "google-antigravity:gemini": "gemini-color", "google-antigravity:third-party": "antigravity", "gemini": "gemini", - "xai": "grok", + "xai": "xai", + "x-ai": "xai", "grok": "grok", "grok-cli": "grok", "grok-bot": "grok-bot", @@ -484,7 +485,7 @@ private static func variantFile(base: String, style: MarkStyle, isDark: Bool) -> private static let monochromeKeys: Set = [ "grok-bot", - "openai", "codex", "cursor", "xai", "grok", "grok-cli", + "openai", "codex", "cursor", "xai", "x-ai", "grok", "grok-cli", ] /// Presentation mode for a user-supplied custom mark. Defaults to `.color` diff --git a/Sources/CodeCaps/Resources/ProviderMarks/xai-color.png b/Sources/CodeCaps/Resources/ProviderMarks/xai-color.png new file mode 100644 index 0000000..4a80657 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/xai-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/xai-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/xai-dark.png new file mode 100644 index 0000000..d4d33fb Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/xai-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/xai-light.png b/Sources/CodeCaps/Resources/ProviderMarks/xai-light.png new file mode 100644 index 0000000..8a20fb6 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/xai-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/xai.svg b/Sources/CodeCaps/Resources/ProviderMarks/xai.svg new file mode 100644 index 0000000..06ba2eb --- /dev/null +++ b/Sources/CodeCaps/Resources/ProviderMarks/xai.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/CodeCaps/SettingsViews.swift b/Sources/CodeCaps/SettingsViews.swift index bc6c043..ac46b22 100644 --- a/Sources/CodeCaps/SettingsViews.swift +++ b/Sources/CodeCaps/SettingsViews.swift @@ -942,7 +942,7 @@ struct ReaderStatus { ReaderStatus(providerKey: "openai", label: "Codex", source: "Codex CLI credentials"), ReaderStatus(providerKey: "google-antigravity", label: "Antigravity", source: "Antigravity app or CLI"), ReaderStatus(providerKey: "cursor", label: "Cursor", source: "Cursor app session"), - ReaderStatus(providerKey: "xai", label: "Grok", source: "Grok CLI credentials"), + ReaderStatus(providerKey: "grok", label: "Grok", source: "Grok CLI credentials"), ReaderStatus(providerKey: "grok-bot", label: "Grok Bot", source: "Cursor app session"), ReaderStatus(providerKey: "minimax", label: "MiniMax", source: "MiniMax CLI credentials"), ] @@ -1065,7 +1065,8 @@ struct SettingsLogoStylePage: View { case "codex", "openai": return "Codex" case "gemini": return "Gemini" case "antigravity", "google-antigravity": return "Antigravity" - case "grok", "xai": return "Grok" + case "grok": return "Grok" + case "xai", "x-ai": return "xAI" case "grok-bot": return "Grok Bot" case "minimax": return "MiniMax" default: return rowId.capitalized diff --git a/Sources/QuotaCore/LocalQuotaReader.swift b/Sources/QuotaCore/LocalQuotaReader.swift index 13c0c09..fe1b454 100644 --- a/Sources/QuotaCore/LocalQuotaReader.swift +++ b/Sources/QuotaCore/LocalQuotaReader.swift @@ -29,7 +29,7 @@ public struct LocalQuotaResult: Equatable, Sendable { /// only the four documented CLI files below and the single Antigravity helper /// path. Network requests use an ephemeral session and refuse redirects. public struct LocalQuotaReader: Sendable { - public static let providerKeys = ["anthropic", "openai", "xai", "minimax", "google-antigravity"] + public static let providerKeys = ["anthropic", "openai", "grok", "minimax", "google-antigravity"] private let homeDirectory: URL private let now: @Sendable () -> Date @@ -377,7 +377,7 @@ public struct LocalQuotaReader: Sendable { private enum Provider: String, CaseIterable, Sendable { case claude, codex, grok, minimax, antigravity var key: String { - switch self { case .claude: return "anthropic"; case .codex: return "openai"; case .grok: return "xai"; case .minimax: return "minimax"; case .antigravity: return "google-antigravity" } + switch self { case .claude: return "anthropic"; case .codex: return "openai"; case .grok: return "grok"; case .minimax: return "minimax"; case .antigravity: return "google-antigravity" } } var label: String { switch self { case .claude: return "Claude Code"; case .codex: return "Codex"; case .grok: return "Grok"; case .minimax: return "MiniMax"; case .antigravity: return "Antigravity" } diff --git a/Sources/QuotaCore/QuotaModels.swift b/Sources/QuotaCore/QuotaModels.swift index deab8f2..c0f453a 100644 --- a/Sources/QuotaCore/QuotaModels.swift +++ b/Sources/QuotaCore/QuotaModels.swift @@ -778,7 +778,8 @@ enum QuotaProviders { Expected(key: "openai", label: "Codex", via: nil), Expected(key: "google-antigravity", label: "Antigravity", via: "antigravity"), Expected(key: "cursor", label: "Cursor", via: nil), - Expected(key: "xai", label: "Grok", via: nil), + Expected(key: "xai", label: "xAI", via: nil), + Expected(key: "grok", label: "Grok", via: nil), Expected(key: "grok-bot", label: "Grok Bot", via: "cursor"), Expected(key: "minimax", label: "MiniMax", via: nil), Expected(key: "muse-assist", label: "Muse Assist", via: nil), @@ -804,7 +805,8 @@ enum QuotaProviders { "openai": "openai", "openai-codex": "openai", "codex": "openai", "google": "google-antigravity", "google-antigravity": "google-antigravity", "antigravity": "google-antigravity", "antigravity-cli": "google-antigravity", "gemini": "google-antigravity", "cursor": "cursor", - "xai": "xai", "grok": "xai", "grok-build": "xai", + "xai": "xai", "x-ai": "xai", "grok-build": "xai", + "grok": "grok", "grok-cli": "grok", "grok-bot": "grok-bot", "grok bot": "grok-bot", "grokbot": "grok-bot", "minimax": "minimax", "minimax-code": "minimax", "muse-assist": "muse-assist", "muse assist": "muse-assist", "muse_assist": "muse-assist", "muse-assistant": "muse-assist", "muse assistant": "muse-assist", diff --git a/Sources/QuotaCore/WidgetPlatformDisplay.swift b/Sources/QuotaCore/WidgetPlatformDisplay.swift index 5291699..99667f9 100644 --- a/Sources/QuotaCore/WidgetPlatformDisplay.swift +++ b/Sources/QuotaCore/WidgetPlatformDisplay.swift @@ -10,7 +10,8 @@ public enum WidgetPlatformDisplay { "openai": "Codex", "google-antigravity": "Antigravity", "cursor": "Cursor", - "xai": "Grok", + "xai": "xAI", + "grok": "Grok", "grok-bot": "Grok Bot", "minimax": "MiniMax", "muse-assist": "Muse Assist", diff --git a/Tests/CodeCapsTests/GlanceRowTests.swift b/Tests/CodeCapsTests/GlanceRowTests.swift index 9bc4ecc..961e05b 100644 --- a/Tests/CodeCapsTests/GlanceRowTests.swift +++ b/Tests/CodeCapsTests/GlanceRowTests.swift @@ -899,6 +899,23 @@ final class GlanceMeterTests: XCTestCase { XCTAssertEqual(glanceResetCountdown(snapshot.resetAt, now: now), "4h 32m") } + func testMuseAssistNeverSaysUpdatedInTooltipOrSpeech() { + let formatter = ISO8601DateFormatter() + let staleObserved = now.addingTimeInterval(-45 * 60) + let w = QuotaWindow( + id: "weekly", provider: "muse-assist", label: "weekly window", + remainingPercent: 92, + resetAt: nil, + window: "weekly", + occurredAt: formatter.string(from: staleObserved) + ) + let snapshot = QuotaWindowSnapshot(window: w, now: now) + XCTAssertFalse(snapshot.isFresh) + // Help tooltip for muse-assist must not say "Updated..." + let help = glanceResetHelp(snapshot.resetAt, now: now, observedAt: snapshot.window.canonicalProviderKey == "muse-assist" ? nil : snapshot.observedAt, isFresh: snapshot.isFresh) + XCTAssertNil(help) + } + // MARK: Cadence func testCadenceReadsTheWindowTokenWhenPresent() { diff --git a/Tests/CodeCapsTests/PipMetricsTests.swift b/Tests/CodeCapsTests/PipMetricsTests.swift index e1d62c0..17cd362 100644 --- a/Tests/CodeCapsTests/PipMetricsTests.swift +++ b/Tests/CodeCapsTests/PipMetricsTests.swift @@ -65,14 +65,87 @@ final class PipMetricsTests: XCTestCase { XCTAssertFalse(PipMetrics.Detail.minimal.showsProviderLogo, "the logo goes only after the name, at the identity-free end") - // Nothing above logoOnly may lose the name while keeping two meters. - for level in [PipMetrics.Detail.full, .noCountdown, .logoOnly] { + // Nothing above logoOnly may lose the logo while keeping two meters. + for level in [PipMetrics.Detail.full, .logoWithFullCountdown, .logoWithCompactCountdown, .logoOnly] { if level.maxMeters > 1 || level == .logoOnly { XCTAssertTrue(level.showsProviderLogo, "\(level) must keep the logo") } } } + /// Owner 2026-10-17: On the PiP window make the platform name disappear and + /// go to just the logos when it gets smaller instead of having the remaining + /// time disappear first. Then degrade the remaining time to a single rounded-up + /// unit before dropping countdowns entirely. + func testPlatformNameDropsBeforeCountdownAndDegradesToCompactUnit() { + let fullWidth = PipMetrics.padding * 2 + PipMetrics.fullMinWidth + XCTAssertEqual(PipMetrics.detail(forWidth: fullWidth), .full) + XCTAssertTrue(PipMetrics.detail(forWidth: fullWidth).showsTitle) + XCTAssertTrue(PipMetrics.detail(forWidth: fullWidth).showsCountdown) + XCTAssertFalse(PipMetrics.detail(forWidth: fullWidth).usesCompactCountdown) + + // Below full width: platform name disappears to logos only, but countdown remains! + let fullCountdownWidth = PipMetrics.padding * 2 + PipMetrics.logoWithCountdownMinWidth + let belowFull = PipMetrics.detail(forWidth: fullWidth - 1) + XCTAssertEqual(belowFull, .logoWithFullCountdown) + XCTAssertFalse(belowFull.showsTitle, "platform name must drop first") + XCTAssertTrue(belowFull.showsProviderLogo, "logos must stay") + XCTAssertTrue(belowFull.showsCountdown, "countdown must not disappear first") + XCTAssertFalse(belowFull.usesCompactCountdown) + + // Narrower still: countdown degrades to 1-unit rounded up. + let compactWidth = PipMetrics.padding * 2 + PipMetrics.logoWithCompactCountdownMinWidth + let compactDetail = PipMetrics.detail(forWidth: fullCountdownWidth - 1) + XCTAssertEqual(compactDetail, .logoWithCompactCountdown) + XCTAssertFalse(compactDetail.showsTitle) + XCTAssertTrue(compactDetail.showsProviderLogo) + XCTAssertTrue(compactDetail.showsCountdown) + XCTAssertTrue(compactDetail.usesCompactCountdown, "must use compact single-unit countdown") + + // Narrower still: countdowns drop, logo remains. + let logoOnlyDetail = PipMetrics.detail(forWidth: compactWidth - 1) + XCTAssertEqual(logoOnlyDetail, .logoOnly) + XCTAssertFalse(logoOnlyDetail.showsTitle) + XCTAssertTrue(logoOnlyDetail.showsProviderLogo) + XCTAssertFalse(logoOnlyDetail.showsCountdown, "countdowns drop after compact countdown") + } + + /// Owner 2026-10-17: "when it gets small enough that it can't have the time + /// remaining with 2 types of time (days hours or hours minutes) then lets first + /// go down to just one type of time rounded up so 1.2 hours (1hr 12min) would + /// say 2h and 59min would say 59min and 2d 2h would say 3d". + func testCompactCountdownRoundsUpToSingleUnit() { + let now = Date() + + // 1.2 hours (1hr 12min = 72m) -> 2h + let onePointTwoHours = now.addingTimeInterval(72 * 60) + XCTAssertEqual(glanceResetCompactCountdown(onePointTwoHours, now: now), "2h") + + // 59 minutes -> 59m + let fiftyNineMins = now.addingTimeInterval(59 * 60) + XCTAssertEqual(glanceResetCompactCountdown(fiftyNineMins, now: now), "59m") + + // 2d 2h (3000 mins) -> 3d + let twoDaysTwoHours = now.addingTimeInterval((2 * 1440 + 2 * 60) * 60) + XCTAssertEqual(glanceResetCompactCountdown(twoDaysTwoHours, now: now), "3d") + + // Exactly 1 hour -> 1h + let oneHour = now.addingTimeInterval(60 * 60) + XCTAssertEqual(glanceResetCompactCountdown(oneHour, now: now), "1h") + + // Exactly 2 days -> 2d + let twoDays = now.addingTimeInterval(2 * 86_400) + XCTAssertEqual(glanceResetCompactCountdown(twoDays, now: now), "2d") + + // Sub-minute -> <1m + let subMinute = now.addingTimeInterval(45) + XCTAssertEqual(glanceResetCompactCountdown(subMinute, now: now), "<1m") + + // Expired -> due + let expired = now.addingTimeInterval(-10) + XCTAssertEqual(glanceResetCompactCountdown(expired, now: now), "due") + } + /// Owner 2026-10-10: "once it gets to where it is in the screenshot or /// actually 80-100 pixels wider than that so that it could still have /// platform logos at the left … we could make it scroll left to right like @@ -148,7 +221,7 @@ final class PipMetricsTests: XCTestCase { for title in ["Antigravity · Third-Party", "Antigravity · Gemini", "Muse Assist", "Muse Code", "MiniMax", "OpenCode"] { XCTAssertLessThanOrEqual((title as NSString).size(withAttributes: [.font: font]).width, PipMetrics.titleWidth) } - let boundary = PipMetrics.padding * 2 + PipMetrics.noCountdownMinWidth + let boundary = PipMetrics.padding * 2 + PipMetrics.fullMinWidth XCTAssertTrue(PipMetrics.detail(forWidth: boundary).showsTitle) XCTAssertFalse(PipMetrics.detail(forWidth: boundary - 1).showsTitle) XCTAssertTrue(PipMetrics.detail(forWidth: boundary - 1).showsProviderLogo) diff --git a/Tests/CodeCapsTests/PlatformLogoResourceTests.swift b/Tests/CodeCapsTests/PlatformLogoResourceTests.swift index 65117f0..e645819 100644 --- a/Tests/CodeCapsTests/PlatformLogoResourceTests.swift +++ b/Tests/CodeCapsTests/PlatformLogoResourceTests.swift @@ -58,7 +58,7 @@ final class PlatformLogoResourceTests: XCTestCase { let bundle = try XCTUnwrap(ResourceBundle.resolved, "the SwiftPM resource bundle could not be resolved at all") let marks = ["claude", "openai", "gemini", "gemini-color", "gemini-mono", - "cursor", "grok", "grok-bot", "minimax", "muse", "muse-assist", "muse-code", "muse-code-dark"] + "cursor", "xai", "grok", "grok-bot", "minimax", "muse", "muse-assist", "muse-code", "muse-code-dark"] // Every named mark must be present in some form. Deliberately not an // exact count: the owner replacing the fabricated MiniMax `{M}` with // the real PNG mark added a file, and an equality assertion is a test @@ -75,7 +75,7 @@ final class PlatformLogoResourceTests: XCTestCase { /// a different hat: `NSImage` returns an object, and the row draws nothing. func testEveryBundledMarkRasterises() throws { let bundle = try XCTUnwrap(ResourceBundle.resolved) - for name in ["claude", "openai", "gemini", "gemini-mono", "cursor", "grok", "grok-bot", "minimax", "muse", "muse-assist", "muse-code", "muse-code-dark"] { + for name in ["claude", "openai", "gemini", "gemini-mono", "cursor", "xai", "grok", "grok-bot", "minimax", "muse", "muse-assist", "muse-code", "muse-code-dark"] { for ext in ["svg", "png"] { guard let url = bundle.url(forResource: name, withExtension: ext) else { continue } let image = try XCTUnwrap(NSImage(contentsOf: url), "\(name).\(ext) did not load") diff --git a/Tests/CodeCapsTests/SettingsMigrationTests.swift b/Tests/CodeCapsTests/SettingsMigrationTests.swift index 6fbc489..4b3e726 100644 --- a/Tests/CodeCapsTests/SettingsMigrationTests.swift +++ b/Tests/CodeCapsTests/SettingsMigrationTests.swift @@ -37,6 +37,13 @@ final class SettingsMigrationTests: XCTestCase { } } + func testGrokReaderStatusUsesTheLocalGrokProviderKey() throws { + let reader = try XCTUnwrap(ReaderStatus.all.first { $0.source == "Grok CLI credentials" }) + XCTAssertEqual(reader.providerKey, "grok") + XCTAssertEqual(reader.label, "Grok") + XCTAssertFalse(ReaderStatus.all.contains { $0.providerKey == "xai" }) + } + func testFreshInstallPostsNowhere() { let model = MonitorModel(defaults: defaults) XCTAssertEqual(model.endpoint, "") diff --git a/Tests/QuotaCoreTests/LocalQuotaReaderTests.swift b/Tests/QuotaCoreTests/LocalQuotaReaderTests.swift index 8bf5a79..8af76fa 100644 --- a/Tests/QuotaCoreTests/LocalQuotaReaderTests.swift +++ b/Tests/QuotaCoreTests/LocalQuotaReaderTests.swift @@ -81,11 +81,11 @@ final class LocalQuotaReaderTests: XCTestCase { ) let result = await reader.read() - XCTAssertEqual(result.windows.first(where: { $0.providerKey == "xai" })?.remainingPercent, 75) + XCTAssertEqual(result.windows.first(where: { $0.providerKey == "grok" })?.remainingPercent, 75) XCTAssertEqual(result.windows.first(where: { $0.providerKey == "minimax" })?.remainingPercent, 25) XCTAssertEqual(result.windows.first(where: { $0.providerKey == "google-antigravity" })?.remainingPercent, 25) XCTAssertEqual(result.windows.first(where: { $0.providerKey == "google-antigravity" })?.via, "antigravity") - XCTAssertNil(result.issues["xai"]) + XCTAssertNil(result.issues["grok"]) XCTAssertNil(result.issues["minimax"]) XCTAssertNil(result.issues["google-antigravity"]) } @@ -105,7 +105,10 @@ final class LocalQuotaReaderTests: XCTestCase { XCTAssertEqual(result.issues["anthropic"], ClaudeLoginState.signedOut.issue) XCTAssertFalse(result.consentNeeded.contains("anthropic")) XCTAssertEqual(result.issues["openai"], "Codex is not signed in locally.") - XCTAssertEqual(result.issues["xai"], "Grok is not signed in locally.") + XCTAssertEqual(result.issues["grok"], "Grok is not signed in locally.") + XCTAssertNil(result.issues["xai"]) + XCTAssertTrue(LocalQuotaReader.providerKeys.contains("grok")) + XCTAssertFalse(LocalQuotaReader.providerKeys.contains("xai")) XCTAssertFalse(result.issues.values.joined(separator: " ").contains("super-secret")) XCTAssertFalse(result.issues.values.joined(separator: " ").contains("account-secret")) XCTAssertFalse(result.issues.values.joined(separator: " ").contains("private")) @@ -164,7 +167,7 @@ final class LocalQuotaReaderTests: XCTestCase { }) let result = await reader.read() XCTAssertEqual(result.windows.first { $0.providerKey == "anthropic" }?.remainingPercent, 70) - XCTAssertEqual(result.windows.first { $0.providerKey == "xai" }?.remainingPercent, 40) + XCTAssertEqual(result.windows.first { $0.providerKey == "grok" }?.remainingPercent, 40) } func testClaudeUsageRejectionDropsTheRememberedGrantAndAsksAgain() async throws { @@ -346,8 +349,20 @@ final class LocalQuotaReaderTests: XCTestCase { Self.httpResponse(#"{"config":{"creditUsagePercent":51,"currentPeriod":{"start":"2026-09-10T00:00:00Z","end":"2026-09-17T00:00:00Z"},"onDemandCap":{"val":500},"prepaidBalance":{"val":300},"productUsage":[{"usagePercent":50},{"usagePercent":1}]}}"#) }, runAntigravity: { Data("{}".utf8) }) let result = await reader.read() - let grok = try XCTUnwrap(result.windows.first { $0.providerKey == "xai" }) + let grok = try XCTUnwrap(result.windows.first { $0.providerKey == "grok" }) + XCTAssertEqual(grok.id, "local-mac:grok:subscription") + XCTAssertEqual(grok.canonicalProviderKey, "grok") XCTAssertEqual(grok.remainingPercent, 49) + + let sections = QuotaResponse(generatedAt: "", windows: result.windows) + .platformSections(now: observedAt) + let grokSection = try XCTUnwrap(sections.first { $0.providerKey == "grok" }) + let xaiSection = try XCTUnwrap(sections.first { $0.providerKey == "xai" }) + XCTAssertEqual(grokSection.providerLabel, "Grok") + XCTAssertEqual(grokSection.windows.map { $0.window.id }, [grok.id]) + XCTAssertFalse(grokSection.isMissing) + XCTAssertEqual(xaiSection.providerLabel, "xAI") + XCTAssertTrue(xaiSection.isMissing) XCTAssertEqual(grok.window, "1w") XCTAssertEqual(grok.resetAt, "2026-09-17T00:00:00Z") XCTAssertNil(grok.absoluteLimit) diff --git a/Tests/QuotaCoreTests/QuotaCoreTests.swift b/Tests/QuotaCoreTests/QuotaCoreTests.swift index f371cc3..66b0010 100644 --- a/Tests/QuotaCoreTests/QuotaCoreTests.swift +++ b/Tests/QuotaCoreTests/QuotaCoreTests.swift @@ -85,8 +85,8 @@ final class QuotaCoreTests: XCTestCase { // bundled list. The list's order and labels must match. let response = QuotaResponse(generatedAt: "", windows: []) let sections = response.platformSections(now: now) - XCTAssertEqual(Array(sections.prefix(7)).map(\.providerKey), [ - "anthropic", "openai", "google-antigravity", "cursor", "xai", "grok-bot", "minimax", + XCTAssertEqual(Array(sections.prefix(8)).map(\.providerKey), [ + "anthropic", "openai", "google-antigravity", "cursor", "xai", "grok", "grok-bot", "minimax", ]) XCTAssertEqual(sections.first?.providerLabel, "Claude") XCTAssertNil(sections.first?.iconHint) @@ -202,10 +202,10 @@ final class QuotaCoreTests: XCTestCase { let future = window(provider: "new-provider", occurred: "2023-11-14T22:05:00Z") let response = QuotaResponse(generatedAt: "2023-11-14T22:10:00Z", windows: [future]) let sections = response.platformSections(now: now) - XCTAssertEqual(Array(sections.prefix(9)).map(\.providerKey), [ - "anthropic", "openai", "google-antigravity", "cursor", "xai", "grok-bot", "minimax", "muse-assist", "muse-code", + XCTAssertEqual(Array(sections.prefix(10)).map(\.providerKey), [ + "anthropic", "openai", "google-antigravity", "cursor", "xai", "grok", "grok-bot", "minimax", "muse-assist", "muse-code", ]) - XCTAssertTrue(sections.prefix(9).allSatisfy(\.isMissing)) + XCTAssertTrue(sections.prefix(10).allSatisfy(\.isMissing)) XCTAssertEqual(sections.last?.providerKey, "new-provider") }