diff --git a/README.md b/README.md index 552e441e..a8064928 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ CodeCaps has two surfaces. Nothing renders in both. Glance, light Glance, dark -**Console** is a resizable window, sidebar split into Quotas and Dashboard: Quotas is an all-platforms overview plus a per-platform drill-down with search; Dashboard holds the pages below. The window title reads **CodeCaps Dashboard** while a Dashboard page is showing. +**Console** is a resizable window, sidebar split into Quotas and Settings: Quotas is an all-platforms overview plus a per-platform drill-down with search; Settings holds the pages listed below. The window title is always **CodeCaps Dashboard**. Console, light Console, dark @@ -48,7 +48,7 @@ Antigravity sells two independent model pools, shown as two rows, **Gemini** (th ## Reset Alarms -A reset alarm is a notification, with the sound picked in Dashboard → Alerts & Alarms, when a quota window starts a new period: +A reset alarm is a notification, with the sound picked on the Alerts & Alarms page, when a quota window starts a new period: - A provider's **largest** window — the weekly or monthly one — alarms every time it resets, even if usage never came near the cap, so you know a new week or month began. - A **smaller** window — the 5-hour one — alarms on reset only if, during the period that just ended, it hit its cap or came within 20% of it. @@ -70,11 +70,11 @@ Each reset alarms once. The state behind it is saved on every refresh, so a res None of the above ever asks for a typed credential — every reader reuses a session or file the CLI already created. -CodeCaps reads Claude Code's saved login through macOS's own `security` tool, so a fresh install needs no setup and never raises a Keychain panel of its own. The **Allow Access To Claude Code** button on the Claude row (Console → Dashboard → Sources & Fleet) appears only if macOS explicitly refuses that read; press it and choose **Always Allow** in the panel macOS puts up. On a heavily loaded Mac the Claude row may briefly read "temporarily unavailable" and recover on the next refresh. CodeCaps only ever reads Claude Code's saved login: it never writes to, re-permissions, or removes it. +CodeCaps reads Claude Code's saved login through macOS's own `security` tool, so a fresh install needs no setup and never raises a Keychain panel of its own. The **Allow Access To Claude Code** button on the Claude row (Dashboard window → Sources & Fleet) appears only if macOS explicitly refuses that read; press it and choose **Always Allow** in the panel macOS puts up. On a heavily loaded Mac the Claude row may briefly read "temporarily unavailable" and recover on the next refresh. CodeCaps only ever reads Claude Code's saved login: it never writes to, re-permissions, or removes it. ## Fleet Push And Pull -Both off by default, configured on Console → Dashboard → Sources & Fleet. +Both off by default, configured under Sources & Fleet in the Dashboard window. **Push formats** — two, chosen when pushing. The producer id is `codecaps` (with `agent-bar` retained as a recognized legacy alias for seamless backwards compatibility). @@ -169,7 +169,7 @@ Grab the signed and notarized `CodeCaps.dmg` from the [latest release](https://g **Updates** -CodeCaps updates itself. Every change merged to `main` becomes a signed, notarized release, and an installed copy checks hourly, downloads it in the background, and installs it the next time CodeCaps is not in front. Dashboard ▸ About has **Check For Updates…** for the impatient. How it works, and how to roll a release back: [`docs/AUTO-UPDATE.md`](docs/AUTO-UPDATE.md). +CodeCaps updates itself. Every change merged to `main` becomes a signed, notarized release, and an installed copy checks hourly, downloads it in the background, and installs it the next time CodeCaps is not in front. The About page has **Check For Updates…** for the impatient. How it works, and how to roll a release back: [`docs/AUTO-UPDATE.md`](docs/AUTO-UPDATE.md). **Build From Source** @@ -193,9 +193,9 @@ Other modes: `--install` (same, no relaunch); `--dev` (separate `.dev` identifie **Icon.** The master in `assets/` is a full-bleed square, and stays that way. macOS before 26 does not mask an app icon, so `script/make_icon.swift` derives the macOS shape at build time — the master drawn inside an 824x824 rounded rectangle on a 1024 canvas, with the standard drop shadow — and the `.icns` is built from that. The master files are only ever read. -## Dashboard +## Settings -Console's sidebar has eight Dashboard pages. +Console's sidebar has eight Settings pages. - **Menu Bar & PiP** — where the icon shows (menu bar, Dock, or both), plus its Displayed Quota picker (above) and the Picture-in-Picture HUD. - **Platforms** — platform list order, shared by Glance, Console, and the menu bar. diff --git a/Sources/CodeCaps/AppDelegate.swift b/Sources/CodeCaps/AppDelegate.swift index e5b36311..2c0e478b 100644 --- a/Sources/CodeCaps/AppDelegate.swift +++ b/Sources/CodeCaps/AppDelegate.swift @@ -101,6 +101,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe // Read before anything can open a window: the launch Sparkle performs // after installing an update stays in the background. let relaunchedForUpdate = UpdateRelaunchMarker().consume() + // Warm the provider-mark cache off the main thread: custom marks read + // from disk and were always instant, while bundled marks sometimes + // drew placeholders first. The retry still covers a bundle that + // arrives after this runs. + DispatchQueue.global(qos: .utility).async { + PlatformLogoImage.preloadBundledMarks() + } UNUserNotificationCenter.current().delegate = self AppUpdater.shared.start() configureMenu() @@ -156,8 +163,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe // above others but left the app an accessory with no Dock icon, so // the owner could not switch to CodeCaps at all. Dock + activate is // what they actually wanted..store(in: &subscriptions) - consoleState.$page.removeDuplicates().sink { [weak self] page in - self?.consoleWindow?.title = page.isSettings ? "CodeCaps Dashboard" : "CodeCaps" + consoleState.$page.removeDuplicates().sink { [weak self] _ in + self?.consoleWindow?.title = "CodeCaps Dashboard" }.store(in: &subscriptions) model.$glanceView.removeDuplicates().sink { [weak self] _ in DispatchQueue.main.async { @@ -301,23 +308,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe private func checkPlanMismatches() { guard !navigatingPlanWarning, planWarningCheckPending || popover.isShown || consoleWindow?.isVisible == true || floatingGlance?.isVisible == true else { return } + // Accepted overrides are filtered inside warnings(), so an + // explicit choice never warns again no matter the caller. let warnings = planWarningCycle.warnings(windows: model.detectedPlanWindows, customInfo: model.platformCustomInfo, now: Date()) if model.lastChecked != nil { planWarningCheckPending = false } - guard !warnings.isEmpty else { return } + // A model change can re-enter while the modal is up: warnings() + // returns [] for a cycle already presenting, and finishing here + // would clear isPresenting under the visible alert and let a + // second modal stack. Only finish a cycle this call did not open. + guard !warnings.isEmpty else { + if !planWarningCycle.isPresenting { planWarningCycle.finishPresentation() } + return + } + // At most once an hour, no matter how often the app opens. + if let last = PlanMismatchSuppression.lastPresentedAt, + Date().timeIntervalSince(last) < 3600 { + planWarningCycle.finishPresentation() + return + } + // "Don't show this again" silences repeats, never first sightings: a + // mismatch with a new provider or a changed detected plan still shows. + if PlanMismatchSuppression.isSuppressed, PlanMismatchSuppression.isRepeat(warnings) { + planWarningCycle.finishPresentation() + return + } let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = "Plan Information Mismatch" + // The transparent colour mark, as on the About page, with CodeCaps + // beside it — not the app icon. NSAlert draws messageText bold to + // the right of the icon, which is exactly the requested lockup. + if let brandMark = BrandMarkImage.asset { alert.icon = brandMark } + alert.messageText = "CodeCaps" let values = warnings.map { "\($0.providerLabel): entered “\($0.enteredPlan)”; provider reports “\($0.detectedPlan)”." }.joined(separator: "\n") - alert.informativeText = values + "\n\n" + alert.informativeText = "Plan Information Mismatch\n\n" + values + "\n\n" + "Your entered values have been kept." + sentenceGap + "Review the plan fields in Settings to resolve the difference." alert.addButton(withTitle: "Open Settings") alert.addButton(withTitle: "Continue") + alert.showsSuppressionButton = true + alert.suppressionButton?.title = "Don't show this again" NSApp.activate(ignoringOtherApps: true) let response = alert.runModal() + if alert.suppressionButton?.state == .on { PlanMismatchSuppression.isSuppressed = true } + // The hourly throttle reads this stamp; it only engages if the + // presentation actually writes it. + PlanMismatchSuppression.lastPresentedAt = Date() + PlanMismatchSuppression.record(warnings) navigatingPlanWarning = true planWarningCycle.finishPresentation() if response == .alertFirstButtonReturn { showSettings() } @@ -595,7 +634,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe window.setFrameAutosaveName("CodeCapsConsoleWindow") consoleWindow = window } - consoleWindow?.title = consoleState.page.isSettings ? "CodeCaps Dashboard" : "CodeCaps" + // The window is the Dashboard: it holds every platform alongside the + // settings pages, so the title never depends on the selected page. + consoleWindow?.title = "CodeCaps Dashboard" // Derive the level unconditionally on every call. The old branch // only ever raised and left a stale popUpMenuWindow + 1 in place when // the popover had been dismissed by a path that does not also resign diff --git a/Sources/CodeCaps/ConsoleViews.swift b/Sources/CodeCaps/ConsoleViews.swift index 7bf0b278..f21388e4 100644 --- a/Sources/CodeCaps/ConsoleViews.swift +++ b/Sources/CodeCaps/ConsoleViews.swift @@ -153,7 +153,7 @@ final class ConsoleState: ObservableObject { init(defaults: UserDefaults = .standard) { self.defaults = defaults - // A Dashboard page is a destination, never a place to resume. + // A Settings page is a destination, never a place to resume. let stored = defaults.string(forKey: "consoleLastPage").flatMap(ConsolePage.fromStorageKey) page = (stored?.isSettings == false ? stored : nil) ?? .settingsSourcesFleet if case .platform(let key) = page { pendingStoredKey = key } @@ -547,7 +547,7 @@ struct ConsoleSidebar: View { } } } header: { - Eyebrow("DASHBOARD") + Eyebrow("SETTINGS") } } .listStyle(.sidebar) @@ -606,7 +606,7 @@ struct ConsoleSidebar: View { .accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton) } - /// Quotas rows carry a trailing value; Dashboard rows do not. Two different + /// Quotas rows carry a trailing value; Settings rows do not. Two different /// row views is what stops `.listStyle(.sidebar)` aligning them identically. private func quotaRow(_ row: DisplaySection) -> some View { HStack(spacing: 6) { @@ -669,7 +669,7 @@ struct ConsoleSidebar: View { /// The sidebar footer exists only for a problem worth interrupting for: /// BotFleet quota sharing is unavailable and nothing else says so. The - /// owner had this pinned here as a second copy of a Dashboard switch that is + /// owner had this pinned here as a second copy of a Settings switch that is /// on for almost everyone (2026-10-05), so a status line that never changes /// was removed with it; the footer now appears only when there is an error /// to show. @@ -936,7 +936,7 @@ struct PlatformDetailPage: View { } /// Editing a platform's presentation happens on that platform's own page, - /// which is why Dashboard ▸ Platforms needs no row selection and no flush. + /// which is why Settings ▸ Platforms needs no row selection and no flush. private var displaySection: some View { VStack(alignment: .leading, spacing: 12) { Eyebrow("DISPLAY") @@ -946,7 +946,9 @@ struct PlatformDetailPage: View { Divider() Toggle("Display Plan, Cost and Renewal", isOn: $showCostAndRenewal) .onChange(of: showCostAndRenewal) { _, _ in save() } - field("Plan Name", "e.g. Max 20x, Pro", $planName, disabled: !showCostAndRenewal) + field("Plan Name", "e.g. Max 20x, Pro", $planName, + caption: planNameCaption, disabled: !showCostAndRenewal) + planOverrideRow field("Cost", "e.g. $20/mo", $costUsd, disabled: !showCostAndRenewal) field("Renewal Date", "e.g. Oct 12 or Monthly", renewalField, caption: renewalDate.isEmpty && !suggestedRenewal.isEmpty @@ -960,6 +962,72 @@ struct PlatformDetailPage: View { } } + /// The provider-reported plan beside the owner's typed one, so a mismatch + /// can be resolved where the name is set instead of only in the popup. + private var detectedPlan: String? { + let key = row?.providerKey ?? customInfoKey + let entered = planName.trimmingCharacters(in: .whitespacesAndNewlines) + let candidates = model.detectedPlanWindows + .filter { $0.canonicalProviderKey == key } + .compactMap { $0.planName?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + // The page must name the same plan the popup warns about: prefer + // the window that actually disagrees with the entered value, as + // PlanWarningCycle.warnings does. + return candidates.first(where: { + PlanComparison.disagrees(detected: $0, entered: entered, providerKey: key) + }) ?? candidates.first + } + + private var planMismatch: Bool { + guard let detected = detectedPlan else { return false } + let entered = planName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !entered.isEmpty else { return false } + let key = row?.providerKey ?? customInfoKey + // An accepted pair is resolved, not a live mismatch: the page + // stops offering a resolution for a choice the owner already made. + guard !PlanMismatchSuppression.isAccepted(providerKey: key, entered: entered, detected: detected) + else { return false } + return PlanComparison.disagrees(detected: detected, entered: entered, + providerKey: key) + } + + private var planNameCaption: String? { + guard let detected = detectedPlan else { return nil } + if planMismatch { + return "Detected: \(detected)." + sentenceGap + "Does not match — fix the name or the popup keeps appearing." + } + return "Detected: \(detected)." + } + + /// One-click resolution where the name is typed. "Use detected" fills + /// the field; "Keep mine" records the exact pair so an explicit choice + /// never warns again. Either edit re-arms the warning for new values. + @ViewBuilder + private var planOverrideRow: some View { + if let detected = detectedPlan, showCostAndRenewal, + (planName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || planMismatch) { + HStack(spacing: 10) { + Text("").frame(width: 150, alignment: .leading).accessibilityHidden(true) + Button("Use Detected") { + planName = detected + save() + } + .help("Replace the plan name with what the provider reports.") + .accessibilityLabel("Use detected plan name") + if planMismatch { + Button("Keep Mine") { + PlanMismatchSuppression.accept(providerKey: row?.providerKey ?? customInfoKey, + entered: planName, detected: detected) + } + .help("Keep the entered name and stop warning about this exact mismatch.") + .accessibilityLabel("Keep my plan name, stop warning") + } + Spacer(minLength: 0) + } + } + } + /// Empty stored text means "keep tracking the billing cycle". The field /// still shows that date, and typing anything else pins an override. private var suggestedRenewal: String { diff --git a/Sources/CodeCaps/PlanMismatchWarning.swift b/Sources/CodeCaps/PlanMismatchWarning.swift index 42506c93..e78ccbc8 100644 --- a/Sources/CodeCaps/PlanMismatchWarning.swift +++ b/Sources/CodeCaps/PlanMismatchWarning.swift @@ -36,6 +36,10 @@ struct PlanWarningCycle { // Do not interrupt an edit with a warning about a partial value. // The next explicit open compares the completed entered value. if let enteredAtOpen, enteredAtOpen[key] != customInfo[key]?.planName { continue } + // An accepted (provider, entered, detected) triple is resolved, + // never a warning: the check lives here so every consumer of + // the list gets it, not just one call site's filter. + if PlanMismatchSuppression.isAccepted(providerKey: key, entered: entered, detected: detected) { continue } shown.insert(key) warnings.append(.init(providerKey: key, providerLabel: window.providerLabel ?? window.provider, detectedPlan: detected, enteredPlan: entered)) @@ -46,3 +50,66 @@ struct PlanWarningCycle { mutating func finishPresentation() { isPresenting = false } } + +/// "Don't show this again" for the mismatch popup. Suppression is sticky, +/// but a mismatch the owner has never seen still interrupts: only a repeat +/// of an already-recorded (provider, detected plan) pair stays silent. +enum PlanMismatchSuppression { + private static let suppressedKey = "planMismatchWarningsSuppressed" + private static let knownKey = "planMismatchKnownDetections" + + static var isSuppressed: Bool { + get { UserDefaults.standard.bool(forKey: suppressedKey) } + set { UserDefaults.standard.set(newValue, forKey: suppressedKey) } + } + + /// Detected plans the owner has already been shown, by provider key. + static var knownDetections: [String: String] { + get { (UserDefaults.standard.dictionary(forKey: knownKey) as? [String: String]) ?? [:] } + set { UserDefaults.standard.set(newValue, forKey: knownKey) } + } + + /// When the popup last appeared. It fires at most once an hour no matter + /// how often the app opens, while a never-seen mismatch still breaks + /// through suppression immediately. + static var lastPresentedAt: Date? { + get { + let epoch = UserDefaults.standard.double(forKey: "planMismatchLastPresentedAt") + return epoch > 0 ? Date(timeIntervalSince1970: epoch) : nil + } + set { UserDefaults.standard.set(newValue?.timeIntervalSince1970 ?? 0, forKey: "planMismatchLastPresentedAt") } + } + + /// Owner-accepted (provider, entered, detected) triples. Choosing "keep + /// mine" records the exact pair, so the popup stays silent for it — while + /// any new entered value or any changed detected plan warns again. + static var acceptedOverrides: [String: [String]] { + get { (UserDefaults.standard.dictionary(forKey: "planMismatchAcceptedOverrides") as? [String: [String]]) ?? [:] } + set { UserDefaults.standard.set(newValue, forKey: "planMismatchAcceptedOverrides") } + } + + static func accept(providerKey: String, entered: String, detected: String) { + var accepted = acceptedOverrides + accepted[providerKey] = [entered.trimmingCharacters(in: .whitespacesAndNewlines), + detected.trimmingCharacters(in: .whitespacesAndNewlines)] + acceptedOverrides = accepted + } + + static func isAccepted(providerKey: String, entered: String, detected: String) -> Bool { + guard let pair = acceptedOverrides[providerKey], pair.count == 2 else { return false } + return pair[0] == entered.trimmingCharacters(in: .whitespacesAndNewlines) + && pair[1] == detected.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// True when every current warning repeats an already-recorded pair. + static func isRepeat(_ warnings: [PlanMismatchWarning]) -> Bool { + let known = knownDetections + return !warnings.isEmpty && warnings.allSatisfy { known[$0.providerKey] == $0.detectedPlan } + } + + static func record(_ warnings: [PlanMismatchWarning]) { + var known = knownDetections + for warning in warnings { known[warning.providerKey] = warning.detectedPlan } + knownDetections = known + } +} diff --git a/Sources/CodeCaps/PlatformLogo.swift b/Sources/CodeCaps/PlatformLogo.swift index c08a135b..8ac7b3d7 100644 --- a/Sources/CodeCaps/PlatformLogo.swift +++ b/Sources/CodeCaps/PlatformLogo.swift @@ -290,6 +290,8 @@ private static let resourceNames: [String: String] = [ "muse-assist": "muse-assist", "muse-code": "muse-code", "cursor": "cursor", + "github-copilot": "copilot", + "copilot": "copilot", ] /// The file a style and appearance ask for, or `nil` when the mark is absent. @@ -303,7 +305,15 @@ private static func variantFile(base: String, style: MarkStyle, isDark: Bool) -> /// Return the bundled asset for `providerKey`, or `nil` if no artwork ships. /// The standard cache preserves brand colors; the template cache marks the /// image as a template so it adapts to Light/Dark and menu bar selection. + /// Guards cachedBundle and lastFailure: preloadBundledMarks() warms the + /// caches on a background queue while the main thread reads, and an + /// unguarded String?/Bundle? write can tear. + private static let stateLock = NSLock() private static var cachedBundle: Bundle? = nil + /// Bumped by every invalidation. A resolve that started before an + /// invalidation must not pin its result afterwards, or the clear is + /// silently undone. + private static var bundleGeneration: UInt64 = 0 /// A latch that only remembers success is a latch that can be poisoned /// before the bundle is reachable: the first call can land while the /// resource bundle is not yet resolvable, and every later call would then @@ -315,19 +325,45 @@ private static func variantFile(base: String, style: MarkStyle, isDark: Bool) -> /// anything in the PiP, which looked like a broken artwork cache but was a /// bundle that resolved and could not deliver. private static func currentBundle() -> Bundle? { - if let cached = cachedBundle { return cached } + stateLock.lock() + if let cached = cachedBundle { stateLock.unlock(); return cached } + let generation = bundleGeneration + stateLock.unlock() + // Resolution is kept outside the lock: it runs arbitrary bundle + // code and only the pin needs guarding. if let b = ResourceBundle.resolved ?? ResourceBundle.resolveBundle() { - cachedBundle = b + stateLock.lock() + if generation == bundleGeneration { cachedBundle = b } + stateLock.unlock() return b } return nil } + /// Decode every bundled mark once, on a background queue, so the first + /// draw of every surface finds a warm cache instead of paying per-row + /// filesystem probes while the window is already on screen. Custom + /// marks load from disk and were never slow; this closes that gap for + /// the bundled set. + public static func preloadBundledMarks() { + for key in resourceNames.keys { + for style in [MarkStyle.standard, .template] { + for isDark in [false, true] { + _ = bundledImage(providerKey: key, style: style, isDark: isDark) + } + } + } + _ = BrandMarkImage.asset + } + /// Forget every cached bundle and mark. Called when something changes that /// could have invalidated them — a custom mark import, or a resource bundle /// that appears after the first lookup. public static func invalidateCaches() { + stateLock.lock() + bundleGeneration &+= 1 cachedBundle = nil + stateLock.unlock() standardCache.removeAllObjects() templateCache.removeAllObjects() menuBarCache.removeAllObjects() @@ -340,8 +376,10 @@ private static func variantFile(base: String, style: MarkStyle, isDark: Bool) -> private static var lastFailure: String? = nil private static func noteFailure(_ reason: String) { - guard lastFailure != reason else { return } + stateLock.lock() + guard lastFailure != reason else { stateLock.unlock(); return } lastFailure = reason + stateLock.unlock() NSLog("CodeCaps: provider mark unavailable — %@", reason) } @@ -351,10 +389,7 @@ private static func variantFile(base: String, style: MarkStyle, isDark: Bool) -> // previously cached mark for the same provider key, and appearance must // never serve a Light silhouette onto a Dark surface (or the reverse). let hintName = iconHint?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let stem = (hintName?.isEmpty == false) ? "\(key)|\(hintName!)" : (key as String) - let cacheKey = "\(stem)|v2|\(style.rawValue)|\(isDark ? "dark" : "light")" as NSString let cache = (style == .standard) ? standardCache : templateCache - if let cached = cache.object(forKey: cacheKey) { return cached } // Server-first: try the manifest-supplied hint name first; fall back to // the built-in map when the hint does not resolve to a file, so a bad // hint can never blank a mark the map would have found. @@ -363,6 +398,12 @@ private static func variantFile(base: String, style: MarkStyle, isDark: Bool) -> return resourceNames[name] ?? name } let mappedBase = resourceNames[key as String] ?? resourceNames[platformKey(of: key as String)] + // A hint that resolves to the same artwork as the built-in map shares + // the map's cache entry, so launch preloading (which cannot know the + // manifest's hints) still warms the first draw. + let stem = (hintBase != nil && hintBase != mappedBase) ? "\(key)|\(hintName!)" : (key as String) + let cacheKey = "\(stem)|v2|\(style.rawValue)|\(isDark ? "dark" : "light")" as NSString + if let cached = cache.object(forKey: cacheKey) { return cached } var filenames: [String] = [] for base in [hintBase, mappedBase].compactMap({ $0 }) { for filename in bundledFilenames(base: base, style: style, isDark: isDark) where !filenames.contains(filename) { @@ -462,7 +503,9 @@ private static func variantFile(base: String, style: MarkStyle, isDark: Bool) -> /// Claude and the colour Gemini star keep their brand colour. public static func isMonochromeMark(_ providerKey: String) -> Bool { let key = providerKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return monochromeKeys.contains(key) + // Pool-scoped keys ("github-copilot:premium") classify by platform, + // matching the artwork lookup below. + return monochromeKeys.contains(key) || monochromeKeys.contains(platformKey(of: key)) } /// Marks that must always render in brand colour (orange Claude starburst, @@ -484,8 +527,8 @@ private static func variantFile(base: String, style: MarkStyle, isDark: Bool) -> private static let solidToneKeys: Set = ["google-antigravity:third-party"] private static let monochromeKeys: Set = [ - "grok-bot", - "openai", "codex", "cursor", "xai", "x-ai", "grok", "grok-cli", + "grok-bot", "openai", "codex", "cursor", "xai", "x-ai", "grok", "grok-cli", + "github-copilot", "copilot", ] /// Presentation mode for a user-supplied custom mark. Defaults to `.color` @@ -648,7 +691,10 @@ private static func variantFile(base: String, style: MarkStyle, isDark: Bool) -> // pinned bundle gets re-resolved too: it may have been resolved before // the bundle existed, and that stale pin is what leaves every bundled // mark drawing as a fallback for the rest of the process. + stateLock.lock() + bundleGeneration &+= 1 cachedBundle = nil + stateLock.unlock() standardCache.removeObject(forKey: key as NSString) templateCache.removeObject(forKey: key as NSString) for style in MarkStyle.allCases { @@ -698,11 +744,14 @@ private static func variantFile(base: String, style: MarkStyle, isDark: Bool) -> } /// Delay before re-probing a mark that failed to load, by attempt number: - /// five seconds for the first dozen attempts (covers install and update - /// windows), then once a minute so a mark that appears later -- a custom - /// import, a late-mounted bundle -- still heals without a relaunch. + /// the first three tries come fast (a bundle that lands seconds after the + /// first draw should not leave placeholder glyphs lingering), then five + /// seconds for the install/update window, then once a minute so a mark + /// that appears later — a custom import, a late-mounted bundle — still + /// heals without a relaunch. public static func missingMarkRetryDelay(forAttempt attempt: Int) -> UInt64 { - attempt < 12 ? 5_000_000_000 : 60_000_000_000 + if attempt < 3 { return 1_000_000_000 } + return attempt < 12 ? 5_000_000_000 : 60_000_000_000 } } diff --git a/Sources/CodeCaps/Resources/ProviderMarks/copilot-color.png b/Sources/CodeCaps/Resources/ProviderMarks/copilot-color.png new file mode 100644 index 00000000..13c4a015 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/copilot-color.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/copilot-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/copilot-dark.png new file mode 100644 index 00000000..8281ba03 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/copilot-dark.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/copilot-light.png b/Sources/CodeCaps/Resources/ProviderMarks/copilot-light.png new file mode 100644 index 00000000..7ace5d81 Binary files /dev/null and b/Sources/CodeCaps/Resources/ProviderMarks/copilot-light.png differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/copilot.svg b/Sources/CodeCaps/Resources/ProviderMarks/copilot.svg new file mode 100644 index 00000000..0413407a --- /dev/null +++ b/Sources/CodeCaps/Resources/ProviderMarks/copilot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Sources/CodeCaps/SettingsViews.swift b/Sources/CodeCaps/SettingsViews.swift index 58d0c7b4..56cd71f2 100644 --- a/Sources/CodeCaps/SettingsViews.swift +++ b/Sources/CodeCaps/SettingsViews.swift @@ -1470,6 +1470,20 @@ struct SettingsNotificationsPage: View { .fixedSize(horizontal: false, vertical: true) } + Section { + Toggle("Plan Mismatch Warnings", isOn: Binding( + get: { !PlanMismatchSuppression.isSuppressed }, + set: { PlanMismatchSuppression.isSuppressed = !$0 })) + .help("Warn when an entered plan name does not match what the provider reports. Turning this back on resumes the popup for mismatches that were silenced with Don't show this again, at most once an hour.") + .accessibilityLabel("Plan mismatch warnings") + } footer: { + Text("An explicit Keep Mine choice on a platform's page stays silent regardless of this switch." + sentenceGap + + "A new plan name on either side always warns again.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Section { Toggle("Reset Alarms For All Providers", isOn: $model.alarmsAll) .help("The same switch as the All bell at the top of the Docked Bar.") diff --git a/Sources/QuotaCore/CopilotQuotaReader.swift b/Sources/QuotaCore/CopilotQuotaReader.swift index 85b6e495..b0a47641 100644 --- a/Sources/QuotaCore/CopilotQuotaReader.swift +++ b/Sources/QuotaCore/CopilotQuotaReader.swift @@ -64,6 +64,7 @@ public struct CopilotQuotaReader: Sendable { quotaUnit: unlimited ? nil : "requests", planName: plan, remainingUnknown: percent == nil, isExhausted: exhausted, resetAt: Self.timestamp(quota["quota_reset_date"]) ?? reset, + window: "monthly", status: percent.map { QuotaWindowStatus.derived(remainingPercent: $0) } ?? .unknown, skip: exhausted, skipReason: exhausted ? "quota exhausted" : nil, occurredAt: observed, source: "copilot" @@ -75,7 +76,8 @@ public struct CopilotQuotaReader: Sendable { id: "local-mac:github-copilot:plan", provider: "GitHub Copilot", providerKey: "github-copilot", providerLabel: "GitHub Copilot", via: "gh", sourceApp: "local-mac", label: "Copilot plan", planName: plan, - remainingUnknown: true, resetAt: reset, status: .unknown, + remainingUnknown: true, resetAt: reset, + window: "monthly", status: .unknown, occurredAt: observed, source: "copilot" )] } diff --git a/Sources/QuotaCore/PlanMetadata.swift b/Sources/QuotaCore/PlanMetadata.swift index a75079de..b7b503ab 100644 --- a/Sources/QuotaCore/PlanMetadata.swift +++ b/Sources/QuotaCore/PlanMetadata.swift @@ -70,6 +70,9 @@ public enum PlanComparison { [detected, entered].allSatisfy({ $0 == "max" || $0.range(of: #"^max[1-9][0-9]*x$"#, options: .regularExpression) != nil }) { return false } + // The Grok Bot feed reports the generic "Grok Bot Plan" for every plan, + // so it can never contradict an entered value (owner, 2026-10-22). + if providerKey == "grok-bot", detected == "grokbotplan" { return false } return true } @@ -95,6 +98,14 @@ public enum PlanComparison { text = text.replacingOccurrences(of: "×", with: "x") let key = text.unicodeScalars.filter { (CharacterSet.alphanumerics.contains($0) || $0 == "+") }.map(String.init).joined() guard !key.isEmpty, !["unknown", "unavailable", "notreported"].contains(key) else { return nil } + // Owner-directed substring aliases (2026-10-22): anything reading + // "pro lite"/"prolite" is the Codex Pro Lite tier, and anything with + // "ultra" in it is Cursor's unified_ultra allowance. Applied after + // provider-name stripping so "ChatGPT Pro Lite (5x)" and "Ultra" stop + // warning against the provider-reported slugs. + if let rules = substringAliases[providerKey] { + for rule in rules where key.contains(rule.fragment) { return rule.canonical } + } // Raw Copilot API plan tokens (for example `individual_pro`) name // the same tiers users type; alias them to the user-facing forms so // a standard entry is not a false mismatch (Sentry on #277). @@ -110,4 +121,9 @@ public enum PlanComparison { "copilotpro": "pro", "copilotproplus": "pro+", ] + + private static let substringAliases: [String: [(fragment: String, canonical: String)]] = [ + "openai": [("prolite", "prolite")], + "cursor": [("ultra", "unifiedultra")], + ] } diff --git a/Tests/CodeCapsTests/PlatformLogoTests.swift b/Tests/CodeCapsTests/PlatformLogoTests.swift index 351da8c2..b536ba9a 100644 --- a/Tests/CodeCapsTests/PlatformLogoTests.swift +++ b/Tests/CodeCapsTests/PlatformLogoTests.swift @@ -250,7 +250,9 @@ final class PlatformLogoTests: XCTestCase { /// A failed first draw re-probes quickly through the install/update window, /// then settles to once a minute so a late-arriving mark still heals. func testMissingMarkRetryDelayQuickThenSlow() { - XCTAssertEqual(PlatformLogoImage.missingMarkRetryDelay(forAttempt: 0), 5_000_000_000) + XCTAssertEqual(PlatformLogoImage.missingMarkRetryDelay(forAttempt: 0), 1_000_000_000) + XCTAssertEqual(PlatformLogoImage.missingMarkRetryDelay(forAttempt: 2), 1_000_000_000) + XCTAssertEqual(PlatformLogoImage.missingMarkRetryDelay(forAttempt: 3), 5_000_000_000) XCTAssertEqual(PlatformLogoImage.missingMarkRetryDelay(forAttempt: 11), 5_000_000_000) XCTAssertEqual(PlatformLogoImage.missingMarkRetryDelay(forAttempt: 12), 60_000_000_000) XCTAssertEqual(PlatformLogoImage.missingMarkRetryDelay(forAttempt: 500), 60_000_000_000) diff --git a/Tests/CodeCapsTests/ProviderMarkResolutionTests.swift b/Tests/CodeCapsTests/ProviderMarkResolutionTests.swift index 04a7edd0..20c0979d 100644 --- a/Tests/CodeCapsTests/ProviderMarkResolutionTests.swift +++ b/Tests/CodeCapsTests/ProviderMarkResolutionTests.swift @@ -59,7 +59,7 @@ final class ProviderMarkResolutionTests: XCTestCase { let keys = ["anthropic", "claude", "openai", "codex", "xai", "grok", "grok-cli", "grok-bot", "cursor", "gemini", "antigravity", "google-antigravity", "minimax", "muse", "muse-assist", - "muse-code"] + "muse-code", "github-copilot", "copilot"] for key in keys { for style in [MarkStyle.standard, .template] { XCTAssertNotNil(PlatformLogoImage.load(providerKey: key, style: style), diff --git a/Tests/CodeCapsTests/ProviderMarkTripletTests.swift b/Tests/CodeCapsTests/ProviderMarkTripletTests.swift index 1932ce89..488e759c 100644 --- a/Tests/CodeCapsTests/ProviderMarkTripletTests.swift +++ b/Tests/CodeCapsTests/ProviderMarkTripletTests.swift @@ -23,6 +23,7 @@ final class ProviderMarkTripletTests: XCTestCase { "google-antigravity:gemini", "google-antigravity:third-party", "gemini", "xai", "grok", "grok-cli", "grok-bot", "minimax", "muse", "muse-assist", "muse-code", "cursor", + "github-copilot", "copilot", ] override func setUp() { @@ -30,6 +31,18 @@ final class ProviderMarkTripletTests: XCTestCase { PlatformLogoImage.invalidateCaches() } + /// The companion's asset catalog must carry every provider the model can + /// name, Copilot included: a missing imageset draws the dashed fallback. + func testCompanionCatalogCarriesCopilotMark() { + let catalog = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("ios/CodeCapsCompanion/Assets.xcassets/provider-copilot.imageset") + XCTAssertTrue(FileManager.default.fileExists(atPath: catalog.appendingPathComponent("provider-copilot.svg").path), + "provider-copilot.imageset is missing its SVG") + XCTAssertTrue(FileManager.default.fileExists(atPath: catalog.appendingPathComponent("Contents.json").path), + "provider-copilot.imageset is missing Contents.json") + } + func testEveryProviderResolvesInEveryStyleAndAppearance() { var unresolved: [String] = [] for key in Self.providerKeys { diff --git a/Tests/QuotaCoreTests/PlanComparisonTests.swift b/Tests/QuotaCoreTests/PlanComparisonTests.swift index c84dc76f..172e8eb0 100644 --- a/Tests/QuotaCoreTests/PlanComparisonTests.swift +++ b/Tests/QuotaCoreTests/PlanComparisonTests.swift @@ -29,6 +29,25 @@ final class PlanComparisonTests: XCTestCase { XCTAssertFalse(PlanComparison.disagrees(detected: "chatgpt-plus", entered: "Plus", providerKey: "openai")) } + func testOwnerDirectedSubstringAliases() { + // Anything with "pro lite"/"prolite" is Codex Pro Lite (owner 2026-10-22). + XCTAssertFalse(PlanComparison.disagrees(detected: "prolite", entered: "ChatGPT Pro Lite (5x)", providerKey: "openai")) + XCTAssertFalse(PlanComparison.disagrees(detected: "Pro Lite", entered: "prolite", providerKey: "openai")) + XCTAssertTrue(PlanComparison.disagrees(detected: "prolite", entered: "Plus", providerKey: "openai")) + // Anything with "ultra" is Cursor's unified_ultra allowance. + XCTAssertFalse(PlanComparison.disagrees(detected: "unified_ultra", entered: "Ultra", providerKey: "cursor")) + XCTAssertFalse(PlanComparison.disagrees(detected: "Ultra", entered: "unified_ultra", providerKey: "cursor")) + XCTAssertTrue(PlanComparison.disagrees(detected: "unified_ultra", entered: "Pro", providerKey: "cursor")) + } + + func testGenericGrokBotPlanNeverMismatches() { + // The Grok Bot feed reports "Grok Bot Plan" for every plan, so it can + // never contradict an entered value (owner 2026-10-22). + XCTAssertFalse(PlanComparison.disagrees(detected: "Grok Bot Plan", entered: "Ultra", providerKey: "grok-bot")) + XCTAssertFalse(PlanComparison.disagrees(detected: "Grok Bot Plan", entered: "inherit from Cursor & xAI", providerKey: "grok-bot")) + XCTAssertFalse(PlanComparison.disagrees(detected: "Grok Bot Plan", entered: "Grok Bot Plan", providerKey: "grok-bot")) + } + func testRealTierDifferencesStayDifferent() { XCTAssertTrue(PlanComparison.disagrees(detected: "Max 20x", entered: "Max 5x", providerKey: "anthropic")) XCTAssertTrue(PlanComparison.disagrees(detected: "Pro+", entered: "Pro", providerKey: "github-copilot")) diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 00000000..687d3829 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,66 @@ +# Logo and icon inventory — which file is for what + +One brand mark, many deliveries. The vector sources live here; everything +else is generated from them or checked in for a target that cannot run a +generator. Do not add a new logo file without a row in this table. + +## The copy rule — read before duplicating anything + +One source per artwork, referenced everywhere, duplicated nowhere — +**except where tooling forces a copy**: + +- Xcode `.imageset` folders must contain their own file. The iOS provider + SVGs mirror `Sources/CodeCaps/Resources/ProviderMarks/*.svg`, and + `codecaps-mark.imageset/codecaps-mark.png` mirrors the menu-bar icon. + Same bytes on purpose; the catalog cannot point outside itself. +- The iOS `AppIcon.appiconset` manifest references distinct filenames that + share pixels at different point sizes. Same bytes on purpose; the + manifest needs the names. +- A `-color` variant that equals its base file (e.g. `gemini-color-color.png` + next to `gemini-color.png`) is the loader's Standard-variant lookup, not + a spare copy. Leave it. + +Copies are caught by hash in review; the tests below are the enforcement. +Yes, this file lists files — that is unusual on purpose. The layout is +still converging (SWU-22), and until it does, an explicit map beats +archaeology. + +## Brand sources (`assets/brand/`) + +| File | For | +| ---- | --- | +| `codecaps-logo-transparent.svg` | Primary mark: teal ring, red ring, dark needle + dots. App (About, menu-bar popover, alerts), Simple-With-Us site, anything color-on-transparent. | +| `codecaps-logo-dark.svg` / `codecaps-logo-light.svg` | Monochrome variants for single-color surfaces. | +| `brand-colors.json` | Palette tokens (`--codecaps-*`), mirrored in the website CSS. Not artwork. | + +## Mac app (`assets/`, `Sources/CodeCaps/Resources/`) + +| File | For | Regenerate from | +| ---- | --- | --------------- | +| `assets/icon-1024.png` | Icon MASTER. Release script derives the `.icns` from it (`make_icon.swift`: 824 plate + shadow). | Source SVG at 1024, off-white ground `#F5F3EF`, mark at SVG fill. | +| `assets/AppIcon.icns` | Checked-in built icon; the release script prefers it over rebuilding. Delete it to force a regen from the master. | `script/build_and_run.sh` on a Mac. | +| `Resources/CodeCapsBrandMark.png` | In-app color mark (About, Glance header, plan popup). | Transparent SVG at 512. | +| `Resources/CodeCapsMenuBarIcon.png` | Black template for the menu-bar icon and PiP header. | Black fill of the same vector at 850. | +| `Resources/ProviderMarks/*` | Per-provider triplets (`-color`, `-light`, `-dark`) plus source SVGs where the vector is owned. | `scripts/regenerate-provider-marks.py` on a Mac (needs cairosvg). | + +## iOS companion (`ios/CodeCapsCompanion/`) + +| File | For | Regenerate from | +| ---- | --- | --------------- | +| `Assets.xcassets/AppIcon.appiconset/` | iPhone/iPad + Mac-companion icons. Dark ground, full-color mark. Every file in the set is manifest-referenced; strays get deleted. | 1024 master, PIL downscale per manifest size. | +| `Assets.xcassets/provider-*.imageset/` | Provider marks as SVG (template-rendered, so only alpha matters). | Copy the matching `ProviderMarks/*.svg`. | + +## Everything else + +| File | For | +| ---- | --- | +| `assets/marketing/` | Owner screenshots filed for website use. Not logos; never edit. | + +## Enforcement (not this file) + +`Tests/CodeCapsTests/ProviderMarkTripletTests.swift` requires every mapped +provider to resolve in every style and appearance as a real bitmap. +`ProviderMarkResolutionTests` requires the same without any custom file +present. `ProviderMarkDetailTests` caps silhouette softness for Mac and +companion marks. If an asset change breaks any of those, the asset is +wrong — not the test. diff --git a/assets/icon-1024-textured.png b/assets/icon-1024-textured.png deleted file mode 100644 index f7ca4c68..00000000 Binary files a/assets/icon-1024-textured.png and /dev/null differ diff --git a/assets/icon-512-alternate-teal.png b/assets/icon-512-alternate-teal.png deleted file mode 100644 index 7697bd2a..00000000 Binary files a/assets/icon-512-alternate-teal.png and /dev/null differ diff --git a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift index ad900cc4..240ba952 100644 --- a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift +++ b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift @@ -674,6 +674,7 @@ public struct CompanionQuotaItem: Identifiable, Codable, Equatable { if key.contains("grok-bot") { return "provider-grok-bot" } if key.contains("grok") || key.contains("xai") { return "provider-grok" } if key.contains("minimax") { return "provider-minimax" } + if key.contains("copilot") || lowId.contains("copilot") { return "provider-copilot" } if key.contains("muse-assist") || key.contains("muse_assist") { return "provider-muse-assist" } if key.contains("muse-code") || key.contains("muse_code") { return "provider-muse-code" } if key.contains("muse") { return "provider-muse-assist" } diff --git a/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift b/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift index 042d5904..63c1c81c 100644 --- a/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift +++ b/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift @@ -1126,7 +1126,7 @@ public struct CompanionProviderLogo: View { /// Antigravity's supplied arch and Gemini's star keep their color here. private var isMonochrome: Bool { guard let name = resolvedLogoName else { return false } - return ["provider-openai", "provider-cursor", "provider-grok", "provider-grok-bot"] + return ["provider-openai", "provider-cursor", "provider-grok", "provider-grok-bot", "provider-copilot"] .contains(name) } diff --git a/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-60.png b/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-60.png deleted file mode 100644 index 6b001dce..00000000 Binary files a/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-60.png and /dev/null differ diff --git a/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-76@3x.png b/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-76@3x.png deleted file mode 100644 index 06a40c67..00000000 Binary files a/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-76@3x.png and /dev/null differ diff --git a/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-83.png b/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-83.png deleted file mode 100644 index b3201490..00000000 Binary files a/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-83.png and /dev/null differ diff --git a/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-83@2x.png b/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-83@2x.png deleted file mode 100644 index 81168659..00000000 Binary files a/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-83@2x.png and /dev/null differ diff --git a/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-83@3x.png b/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-83@3x.png deleted file mode 100644 index b3201490..00000000 Binary files a/ios/CodeCapsCompanion/Assets.xcassets/AppIcon.appiconset/icon-83@3x.png and /dev/null differ diff --git a/ios/CodeCapsCompanion/Assets.xcassets/provider-copilot.imageset/Contents.json b/ios/CodeCapsCompanion/Assets.xcassets/provider-copilot.imageset/Contents.json new file mode 100644 index 00000000..ebadeaf9 --- /dev/null +++ b/ios/CodeCapsCompanion/Assets.xcassets/provider-copilot.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "provider-copilot.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ios/CodeCapsCompanion/Assets.xcassets/provider-copilot.imageset/provider-copilot.svg b/ios/CodeCapsCompanion/Assets.xcassets/provider-copilot.imageset/provider-copilot.svg new file mode 100644 index 00000000..0413407a --- /dev/null +++ b/ios/CodeCapsCompanion/Assets.xcassets/provider-copilot.imageset/provider-copilot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ios/CodeCapsCompanion/Widgets/WidgetViews.swift b/ios/CodeCapsCompanion/Widgets/WidgetViews.swift index 5aa8547e..42e1abdc 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetViews.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetViews.swift @@ -139,6 +139,7 @@ struct ProviderMarkView: View { if low.contains("openai") || low.contains("codex") { return "provider-openai" } if low.contains("cursor") { return "provider-cursor" } if low.contains("minimax") { return "provider-minimax" } + if low.contains("copilot") { return "provider-copilot" } if low.contains("muse-assist") || low.contains("muse_assist") { return "provider-muse-assist" } if low.contains("muse-code") || low.contains("muse_code") { return "provider-muse-code" } if low.contains("muse") { return "provider-muse-assist" } diff --git a/scripts/regenerate-provider-marks.py b/scripts/regenerate-provider-marks.py index 23d8f0ef..96f06673 100644 --- a/scripts/regenerate-provider-marks.py +++ b/scripts/regenerate-provider-marks.py @@ -195,6 +195,10 @@ def owner(*parts: str) -> str: Target("Sources/CodeCaps/Resources/ProviderMarks/cursor-color.png", repo_svg("cursor.svg"), "svg", "own"), Target("Sources/CodeCaps/Resources/ProviderMarks/cursor-dark.png", repo_svg("cursor.svg"), "svg", "own"), Target("Sources/CodeCaps/Resources/ProviderMarks/cursor-light.png", repo_svg("cursor.svg"), "svg", "own"), + # Copilot -- own vector (official outline, owner-supplied). + Target("Sources/CodeCaps/Resources/ProviderMarks/copilot-color.png", repo_svg("copilot.svg"), "svg", "own"), + Target("Sources/CodeCaps/Resources/ProviderMarks/copilot-dark.png", repo_svg("copilot.svg"), "svg", "own"), + Target("Sources/CodeCaps/Resources/ProviderMarks/copilot-light.png", repo_svg("copilot.svg"), "svg", "own"), # OpenAI -- own vector. # Grok -- own vector. Target("Sources/CodeCaps/Resources/ProviderMarks/grok-color.png", repo_svg("grok.svg"), "svg", "own"),