diff --git a/.agent-plugin/.acp.json b/.agent-plugin/.acp.json
new file mode 100644
index 00000000..4284e5c0
--- /dev/null
+++ b/.agent-plugin/.acp.json
@@ -0,0 +1,20 @@
+{
+ "github-copilot": {
+ "distribution": {
+ "binary": {
+ "darwin-aarch64": {
+ "archive": "https://github.com/github/copilot-cli/releases/download/v1.0.78/copilot-darwin-arm64.tar.gz",
+ "cmd": "./copilot",
+ "args": ["--acp"],
+ "env": {}
+ }
+ }
+ },
+ "extendedFeatures": {
+ "userConfigFile": {
+ "path": "~/.copilot/settings.json",
+ "description": "GitHub Copilot configuration and authentication credentials. Populated automatically by `copilot login`; set `store_token_plaintext: true` and add a token under `copilot_tokens` to supply credentials without an interactive session."
+ }
+ }
+ }
+}
diff --git a/.agent-plugin/icon.svg b/.agent-plugin/icon.svg
new file mode 100644
index 00000000..0b596674
--- /dev/null
+++ b/.agent-plugin/icon.svg
@@ -0,0 +1,5 @@
+
diff --git a/.agent-plugin/plugin.json b/.agent-plugin/plugin.json
new file mode 100644
index 00000000..1785e69a
--- /dev/null
+++ b/.agent-plugin/plugin.json
@@ -0,0 +1,17 @@
+{
+ "name": "GitHub Copilot",
+ "slug": "github-copilot",
+ "description": "GitHub's AI pair programmer",
+ "version": "0.1.0",
+ "author": {
+ "name": "GitHub",
+ "url": "https://github.com/github"
+ },
+ "repository": "https://github.com/github/CopilotForXcode",
+ "homepage": "https://github.com/features/copilot/",
+ "license": "proprietary",
+ "icon": "https://raw.githubusercontent.com/github/CopilotForXcode/main/.agent-plugin/icon.svg",
+ "tags": ["copilot", "github", "acp", "ai-assistant"],
+ "category": "ai-agent",
+ "platforms": ["darwin-aarch64"]
+}
diff --git a/.github/workflows/auto-create-release-pr.yml b/.github/workflows/auto-create-release-pr.yml
deleted file mode 100644
index e11d7d2a..00000000
--- a/.github/workflows/auto-create-release-pr.yml
+++ /dev/null
@@ -1,56 +0,0 @@
-name: Auto-create Release PR
-
-on:
- push:
- branches:
- - 'release/**'
-
-jobs:
- create-pr:
- runs-on: ubuntu-latest
- permissions:
- contents: write
- pull-requests: write
- steps:
- - uses: actions/checkout@v4
-
- - name: Create pull request
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- existing_pr_count="$(gh pr list \
- --state open \
- --base main \
- --head "${{ github.ref_name }}" \
- --json number \
- --jq 'length')"
- if [ "${existing_pr_count}" -gt 0 ]; then
- echo "Open pull request already exists for branch '${{ github.ref_name }}' into 'main'; skipping creation."
- else
- gh pr create \
- --title "$(git log -1 --pretty=%s)" \
- --body "Automated release PR." \
- --base main \
- --head "${{ github.ref_name }}"
- fi
-
- - name: Approve pull request
- env:
- # PAT stored in github/CopilotForXcode, with write permissions to pull requests
- GH_TOKEN: ${{ secrets.XCODE_AUTO_APPROVE }}
- run: |
- gh pr review --approve "${{ github.ref_name }}"
-
- - name: Wait for required checks
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh pr checks "${{ github.ref_name }}" --watch --required
-
- - name: Merge pull request
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh pr merge "${{ github.ref_name }}" \
- --merge \
- --delete-branch
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 861c5037..42463f66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## 0.50.0 - May 20, 2026
+### Added
+- Reasoning effort control for supported models: Low, Medium, or High from the model picker to balance response speed and quality.
+- Added internal support for upcoming usage-based billing, including billing updates for the usage panel, usage notifications, and model picker. This will be visible to the user once usage-based billing rolls out.
+
+### Changed
+- Bring Your Own Key (BYOK) is now generally available.
+
## 0.49.0 - May 15, 2026
### Added
- Native Anthropic Messages API (`/v1/messages`) endpoint support.
diff --git a/Core/Package.swift b/Core/Package.swift
index 70c40b3c..733fbe02 100644
--- a/Core/Package.swift
+++ b/Core/Package.swift
@@ -269,6 +269,7 @@ let package = Package(
.product(name: "GitHubCopilotService", package: "Tool"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "Status", package: "Tool"),
+ .product(name: "Logger", package: "Tool"),
]
),
diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift
index d9dcf0cd..e90faa3c 100644
--- a/Core/Sources/ChatService/ChatService.swift
+++ b/Core/Sources/ChatService/ChatService.swift
@@ -30,6 +30,7 @@ public protocol ChatServiceType {
references: [ConversationAttachedReference],
model: String?,
modelProviderName: String?,
+ reasoningEffort: String?,
agentMode: Bool,
customChatModeId: String?,
userLanguage: String?,
@@ -363,6 +364,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
references: [ConversationAttachedReference],
model: String? = nil,
modelProviderName: String? = nil,
+ reasoningEffort: String? = nil,
agentMode: Bool = false,
customChatModeId: String? = nil,
userLanguage: String? = nil,
@@ -469,6 +471,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
references: references,
model: model,
modelProviderName: modelProviderName,
+ reasoningEffort: reasoningEffort,
agentMode: agentMode,
customChatModeId: customChatModeId,
userLanguage: userLanguage,
@@ -500,6 +503,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
references: [ConversationAttachedReference],
model: String? = nil,
modelProviderName: String? = nil,
+ reasoningEffort: String? = nil,
agentMode: Bool = false,
customChatModeId: String? = nil,
userLanguage: String? = nil,
@@ -526,6 +530,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
references: references,
model: model,
modelProviderName: modelProviderName,
+ reasoningEffort: reasoningEffort,
agentMode: agentMode,
customChatModeId: customChatModeId,
userLanguage: userLanguage,
@@ -537,7 +542,10 @@ public final class ChatService: ChatServiceType, ObservableObject {
await memory.mutateHistory { history in
if let index = history.firstIndex(where: { $0.id == response.turnId && $0.role.isAssistant }) {
history[index].modelName = response.modelName
+ let modelProviderName = response.modelInfo?.providerName ?? response.modelProviderName
+ history[index].modelProviderName = modelProviderName
history[index].billingMultiplier = response.billingMultiplier
+ history[index].reasoningEffort = response.modelInfo?.reasoningEffort
self.saveChatMessageToStorage(history[index])
}
@@ -998,6 +1006,16 @@ public final class ChatService: ChatServiceType, ObservableObject {
}
}
+ private func strippingRequestIDs(from message: String) -> String {
+ // "Request ID:" always appears before "GitHub Request ID:", so cutting at the first
+ // occurrence removes both along with the preceding separator (". " or " | ")
+ guard let range = message.range(of: "Request ID:", options: .caseInsensitive) else {
+ return message
+ }
+ return String(message[..
@Namespace var inputAreaNamespace
- @ObservedObject private var rateLimitNotifier = RateLimitNotifierImpl.shared
+ @ObservedObject private var warningManager = WarningStateManager.shared
public var body: some View {
WithPerceptionTracking {
@@ -56,9 +56,13 @@ public struct ChatPanel: View {
}
}
- if let warning = rateLimitNotifier.currentWarning {
- RateLimitWarningBanner(message: warning.message) {
- rateLimitNotifier.dismissWarning()
+ if let warning = warningManager.currentWarning {
+ WarningBanner(
+ message: warning.message,
+ severity: warning.severity,
+ actions: warning.actions
+ ) {
+ warningManager.dismissWarning()
}
.scaledPadding(.horizontal, 24)
.scaledPadding(.vertical, 8)
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift
index 7e03aad1..e8302f4e 100644
--- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModeAndModelPickerPicker.swift
@@ -135,6 +135,11 @@ struct ModeAndModelPicker: View {
selectedModel = nil
}
} else {
+ if let fresh = freshModel, let current = currentModel,
+ fresh.supportsReasoningEffortLevel != current.supportsReasoningEffortLevel
+ || fresh.reasoningEfforts != current.reasoningEfforts {
+ AppState.shared.setSelectedModel(fresh)
+ }
selectedModel = freshModel ?? defaultModel
}
}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift
index 3e0100e7..a4542c90 100644
--- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelManagerUtils.swift
@@ -7,9 +7,11 @@ import ConversationServiceProvider
public let SELECTED_LLM_KEY = "selectedLLM"
public let SELECTED_CHATMODE_KEY = "selectedChatMode"
public let SELECTED_AGENT_SUBMODE_KEY = "selectedAgentSubMode"
+public let SELECTED_REASONING_EFFORT_KEY = "selectedReasoningEffort"
public extension Notification.Name {
static let gitHubCopilotSelectedModelDidChange = Notification.Name("com.github.CopilotForXcode.SelectedModelDidChange")
+ static let gitHubCopilotSelectedReasoningEffortDidChange = Notification.Name("com.github.CopilotForXcode.SelectedReasoningEffortDidChange")
}
public extension AppState {
@@ -35,6 +37,11 @@ public extension AppState {
let providerName = savedModel["providerName"]?.stringValue
let supportVision = savedModel["supportVision"]?.boolValue ?? false
let degradationReason = savedModel["degradationReason"]?.stringValue
+ let supportsReasoningEffortLevel = savedModel["supportsReasoningEffortLevel"]?.boolValue ?? false
+ var reasoningEfforts: [String]? = nil
+ if case .array(let arr)? = savedModel["reasoningEfforts"] {
+ reasoningEfforts = arr.compactMap { $0.stringValue }
+ }
// Try to reconstruct billing info if available
var billing: CopilotModelBilling?
@@ -54,7 +61,9 @@ public extension AppState {
billing: billing,
providerName: providerName,
supportVision: supportVision,
- degradationReason: degradationReason
+ degradationReason: degradationReason,
+ reasoningEfforts: reasoningEfforts,
+ supportsReasoningEffortLevel: supportsReasoningEffortLevel
)
}
@@ -65,6 +74,41 @@ public extension AppState {
}
}
+ func getSelectedReasoningEffort(for model: LLMModel) -> String? {
+ guard let saved = get(key: SELECTED_REASONING_EFFORT_KEY) else { return nil }
+ return saved[model.reasoningEffortStorageKey]?.stringValue
+ }
+
+ func setSelectedReasoningEffort(_ effort: String, for model: LLMModel) {
+ var efforts: [String: String] = [:]
+ if let existing = get(key: SELECTED_REASONING_EFFORT_KEY),
+ case .hash(let dict) = existing {
+ for (k, v) in dict {
+ if let s = v.stringValue { efforts[k] = s }
+ }
+ }
+ efforts[model.reasoningEffortStorageKey] = effort
+ update(key: SELECTED_REASONING_EFFORT_KEY, value: efforts)
+ DispatchQueue.main.async {
+ NotificationCenter.default.post(name: .gitHubCopilotSelectedReasoningEffortDidChange, object: nil)
+ }
+ }
+
+ /// Returns the effective reasoning effort for a given model:
+ /// - `nil` if the model does not support reasoning effort
+ /// - `nil` for the auto model — lets the server pick the effort for whichever model it routes to
+ /// - the user-persisted value if set
+ /// - otherwise the model-family default: "medium" for all models
+ func effectiveReasoningEffort(for model: LLMModel) -> String? {
+ guard model.supportsReasoningEffortLevel else { return nil }
+ guard !model.isAutoModel else { return nil }
+ let candidate = getSelectedReasoningEffort(for: model) ?? model.defaultReasoningEffort
+ if let efforts = model.reasoningEfforts, !efforts.isEmpty {
+ return efforts.contains(candidate) ? candidate : efforts.first
+ }
+ return candidate
+ }
+
func modelScope() -> PromptTemplateScope {
return isAgentModeEnabled() ? .agentPanel : .chatPanel
}
@@ -150,16 +194,7 @@ public class CopilotModelManagerObservable: ObservableObject {
scope: AppState.shared
.isAgentModeEnabled() ? .agentPanel : .chatPanel
) {
- AppState.shared.setSelectedModel(
- .init(
- modelName: fallbackModel.modelName,
- modelFamily: fallbackModel.modelFamily,
- id: fallbackModel.id,
- billing: fallbackModel.billing,
- supportVision: fallbackModel.capabilities.supports.vision,
- degradationReason: fallbackModel.degradationReason
- )
- )
+ AppState.shared.setSelectedModel(fallbackModel.toLLMModel())
}
}
.store(in: &cancellables)
@@ -173,14 +208,7 @@ public extension CopilotModelManager {
return LLMs.filter(
{ $0.scopes.contains(scope) }
).map {
- return LLMModel(
- modelName: $0.modelName,
- modelFamily: $0.isChatFallback ? $0.id : $0.modelFamily,
- id: $0.id,
- billing: $0.billing,
- supportVision: $0.capabilities.supports.vision,
- degradationReason: $0.degradationReason
- )
+ $0.toLLMModel(familyOverride: $0.isChatFallback ? $0.id : nil)
}
}
@@ -191,39 +219,17 @@ public extension CopilotModelManager {
?? LLMsInScope.first(where: { $0.isChatDefault })
// If a default model is found, return it
if let defaultModel = defaultModel {
- return LLMModel(
- modelName: defaultModel.modelName,
- modelFamily: defaultModel.modelFamily,
- id: defaultModel.id,
- billing: defaultModel.billing,
- supportVision: defaultModel.capabilities.supports.vision,
- degradationReason: defaultModel.degradationReason
- )
+ return defaultModel.toLLMModel()
}
// Fallback to gpt-4.1 if available
- let gpt4_1 = LLMsInScope.first(where: { $0.modelFamily == "gpt-4.1" })
- if let gpt4_1 = gpt4_1 {
- return LLMModel(
- modelName: gpt4_1.modelName,
- modelFamily: gpt4_1.modelFamily,
- id: gpt4_1.id,
- billing: gpt4_1.billing,
- supportVision: gpt4_1.capabilities.supports.vision,
- degradationReason: gpt4_1.degradationReason
- )
+ if let gpt4_1 = LLMsInScope.first(where: { $0.modelFamily == "gpt-4.1" }) {
+ return gpt4_1.toLLMModel()
}
// If no default model is found, fallback to the first available model
if let firstModel = LLMsInScope.first {
- return LLMModel(
- modelName: firstModel.modelName,
- modelFamily: firstModel.modelFamily,
- id: firstModel.id,
- billing: firstModel.billing,
- supportVision: firstModel.capabilities.supports.vision,
- degradationReason: firstModel.degradationReason
- )
+ return firstModel.toLLMModel()
}
return nil
@@ -247,7 +253,9 @@ public extension BYOKModelManager {
id: $0.modelId,
billing: nil,
providerName: $0.providerName.rawValue,
- supportVision: $0.modelCapabilities?.vision ?? false
+ supportVision: $0.modelCapabilities?.vision ?? false,
+ maxInputTokens: $0.modelCapabilities?.maxInputTokens,
+ maxOutputTokens: $0.modelCapabilities?.maxOutputTokens
)
}
}
@@ -258,38 +266,63 @@ public struct LLMModel: Codable, Hashable, Equatable {
public let modelName: String
public let modelFamily: String
public let id: String
+ public let vendor: String?
public let billing: CopilotModelBilling?
public let providerName: String?
public let supportVision: Bool
public let degradationReason: String?
-
+ public let maxInputTokens: Int?
+ public let maxOutputTokens: Int?
+ public let maxContextWindowTokens: Int?
+ public let modelPickerCategory: String?
+ public let modelPickerPriceCategory: String?
+ public let reasoningEfforts: [String]?
+ public let supportsReasoningEffortLevel: Bool
+
public init(
displayName: String? = nil,
modelName: String,
modelFamily: String,
id: String,
- billing: CopilotModelBilling?,
+ vendor: String? = nil,
+ billing: CopilotModelBilling? = nil,
providerName: String? = nil,
supportVision: Bool,
- degradationReason: String? = nil
+ degradationReason: String? = nil,
+ maxInputTokens: Int? = nil,
+ maxOutputTokens: Int? = nil,
+ maxContextWindowTokens: Int? = nil,
+ modelPickerCategory: String? = nil,
+ modelPickerPriceCategory: String? = nil,
+ reasoningEfforts: [String]? = nil,
+ supportsReasoningEffortLevel: Bool = false
) {
self.displayName = displayName
self.modelName = modelName
self.modelFamily = modelFamily
self.id = id
+ self.vendor = vendor
self.billing = billing
self.providerName = providerName
self.supportVision = supportVision
self.degradationReason = degradationReason
+ self.maxInputTokens = maxInputTokens
+ self.maxOutputTokens = maxOutputTokens
+ self.maxContextWindowTokens = maxContextWindowTokens
+ self.modelPickerCategory = modelPickerCategory
+ self.modelPickerPriceCategory = modelPickerPriceCategory
+ self.reasoningEfforts = reasoningEfforts
+ self.supportsReasoningEffortLevel = supportsReasoningEffortLevel
}
- // Exclude degradationReason from equality — it's transient status, not model identity
+ // Only compare model identity fields; exclude transient/display-only data
+ // (billing, degradationReason, vendor, token limits) so that a persisted
+ // model still matches a freshly-fetched one.
public static func == (lhs: LLMModel, rhs: LLMModel) -> Bool {
lhs.displayName == rhs.displayName &&
lhs.modelName == rhs.modelName &&
lhs.modelFamily == rhs.modelFamily &&
lhs.id == rhs.id &&
- lhs.billing == rhs.billing &&
lhs.providerName == rhs.providerName &&
lhs.supportVision == rhs.supportVision
}
@@ -299,9 +332,10 @@ public struct LLMModel: Codable, Hashable, Equatable {
hasher.combine(modelName)
hasher.combine(modelFamily)
hasher.combine(id)
- hasher.combine(billing)
hasher.combine(providerName)
hasher.combine(supportVision)
+ hasher.combine(maxContextWindowTokens)
+ hasher.combine(modelPickerPriceCategory)
}
}
@@ -312,8 +346,35 @@ public extension LLMModel {
var isStandardModel: Bool { !isPremiumModel || billing == nil }
/// Apply to `Copilot Models`
var isAutoModel: Bool { isStandardModel && modelName == "Auto" }
+
+ var reasoningEffortStorageKey: String {
+ "\(id)_\(providerName ?? "")"
+ }
+
+ var defaultReasoningEffort: String {
+ "medium"
+ }
}
extension CopilotModel {
var isAutoModel: Bool { modelName == "Auto" }
+
+ func toLLMModel(familyOverride: String? = nil) -> LLMModel {
+ LLMModel(
+ modelName: modelName,
+ modelFamily: familyOverride ?? modelFamily,
+ id: id,
+ vendor: vendor,
+ billing: billing,
+ supportVision: capabilities.supports.vision,
+ degradationReason: degradationReason,
+ maxInputTokens: capabilities.limits?.maxInputTokens,
+ maxOutputTokens: capabilities.limits?.maxOutputTokens,
+ maxContextWindowTokens: capabilities.limits?.maxContextWindowTokens,
+ modelPickerCategory: modelPickerCategory,
+ modelPickerPriceCategory: modelPickerPriceCategory,
+ reasoningEfforts: capabilities.supports.reasoningEfforts,
+ supportsReasoningEffortLevel: capabilities.supports.supportsReasoningEffortLevel ?? false
+ )
+ }
}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift
index e97027cc..9b6abf0e 100644
--- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelMenuItemFormatter.swift
@@ -73,25 +73,67 @@ public struct ModelMenuItemFormatter {
return attributedString
}
- /// Gets the multiplier text for a model (e.g., "2x", "Included", provider name, or "Variable")
- public static func getMultiplierText(for model: LLMModel) -> String {
+ /// Gets the trailing text for a model menu item.
+ /// - BYOK models: provider name
+ /// - Copilot models with token-based billing: " [· ] · "
+ /// - Copilot models without token-based billing: "x"
+ /// - Auto model: "Variable"
+ public static func getMultiplierText(for model: LLMModel, reasoningEffort: String? = nil) -> String {
+ if let providerName = model.providerName, !providerName.isEmpty {
+ return providerName
+ }
if model.isAutoModel {
return "Variable"
- } else if let billing = model.billing {
- let multiplier = billing.multiplier
- if multiplier == 0 {
- return "Included"
- } else {
- let numberPart = multiplier.truncatingRemainder(dividingBy: 1) == 0
- ? String(format: "%.0f", multiplier)
- : String(format: "%.2f", multiplier)
- return "\(numberPart)x"
+ }
+ if model.billing?.tokenBasedBillingEnabled == true {
+ var parts: [String] = []
+ if let tokens = model.maxContextWindowTokens {
+ parts.append(formatContextWindow(tokens))
}
- } else if let providerName = model.providerName, !providerName.isEmpty {
- return providerName
- } else {
- return ""
+ if let effort = reasoningEffort, !effort.isEmpty, effort.lowercased() != "none" {
+ parts.append(effort.capitalized)
+ }
+ if let category = model.modelPickerPriceCategory, !category.isEmpty {
+ parts.append(priceCategorySymbol(category))
+ }
+ return parts.joined(separator: " · ")
+ }
+ if let multiplier = model.billing?.multiplier {
+ return formatMultiplier(multiplier)
}
+ return ""
+ }
+
+ public static func priceCategorySymbol(_ category: String) -> String {
+ switch category.lowercased() {
+ case "low": return "$"
+ case "medium": return "$$"
+ case "high": return "$$$"
+ default: return "$$$$"
+ }
+ }
+
+ public static func formatContextWindow(_ count: Int) -> String {
+ if count >= 1_000_000 {
+ let m = Double(count) / 1_000_000.0
+ return m.truncatingRemainder(dividingBy: 1) == 0
+ ? String(format: "%.0fM", m)
+ : String(format: "%.1fM", m)
+ }
+ if count >= 1_000 {
+ let k = Double(count) / 1_000.0
+ return k.truncatingRemainder(dividingBy: 1) == 0
+ ? String(format: "%.0fK", k)
+ : String(format: "%.1fK", k)
+ }
+ return "\(count)"
+ }
+
+ private static func formatMultiplier(_ multiplier: Float) -> String {
+ if multiplier == 0 { return "Included" }
+ return multiplier.truncatingRemainder(dividingBy: 1) == 0
+ ? String(format: "%.0fx", multiplier)
+ : String(format: "%.2fx", multiplier)
}
/// Draws the standard menu-item highlight background (accent-colored rounded rect).
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ChatModelPicker.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ChatModelPicker.swift
index be81b51a..c662269d 100644
--- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ChatModelPicker.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ChatModelPicker.swift
@@ -1,3 +1,4 @@
+import Persist
import SharedUIComponents
import SwiftUI
@@ -9,6 +10,7 @@ struct ChatModelPicker: View {
let currentCache: ScopeCache
@StateObject private var fontScaleManager = FontScaleManager.shared
+ @State private var currentEffort: String?
private var fontScale: Double {
fontScaleManager.currentScale
@@ -21,8 +23,38 @@ struct ChatModelPicker: View {
byokModels: byokModels,
isBYOKFFEnabled: isBYOKFFEnabled,
currentCache: currentCache,
- fontScale: fontScale
+ fontScale: fontScale,
+ currentEffort: currentEffort
)
.fixedSize(horizontal: false, vertical: true)
+ .onAppear {
+ currentEffort = computeEffort(for: selectedModel)
+ }
+ .onChange(of: selectedModel) { model in
+ currentEffort = computeEffort(for: model)
+ }
+ .onReceive(
+ NotificationCenter.default.publisher(
+ for: .gitHubCopilotModelsDidChange
+ )
+ ) { _ in
+ currentEffort = computeEffort(for: selectedModel)
+ }
+ .onReceive(
+ NotificationCenter.default.publisher(
+ for: .gitHubCopilotSelectedReasoningEffortDidChange
+ )
+ ) { _ in
+ currentEffort = computeEffort(for: selectedModel)
+ }
+ }
+
+ private func computeEffort(for model: LLMModel?) -> String? {
+ guard let model,
+ model.supportsReasoningEffortLevel,
+ !model.isAutoModel else { return nil }
+ let effort = AppState.shared.effectiveReasoningEffort(for: model)
+ guard let e = effort, e.lowercased() != "none" else { return nil }
+ return e
}
}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift
index d2a92b5f..cc20d1a0 100644
--- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerButton.swift
@@ -1,4 +1,5 @@
import AppKit
+import Persist
import SwiftUI
// MARK: - Model Picker Button (NSViewRepresentable)
@@ -10,6 +11,7 @@ struct ModelPickerButton: NSViewRepresentable {
let isBYOKFFEnabled: Bool
let currentCache: ScopeCache
let fontScale: Double
+ let currentEffort: String?
func makeNSView(context: Context) -> NSView {
let container = ModelPickerContainerView(fontScale: fontScale)
@@ -104,10 +106,21 @@ struct ModelPickerButton: NSViewRepresentable {
let chevronView = context.coordinator.chevronView
else { return }
- let label = selectedModelLabel
- titleLabel.stringValue = label
- titleLabel.font = NSFont.systemFont(ofSize: 13 * fontScale)
- titleLabel.textColor = .labelColor
+ let font = NSFont.systemFont(ofSize: 13 * fontScale)
+ let baseName = modelDisplayName
+ let effort = currentEffort
+
+ let attrStr = NSMutableAttributedString(
+ string: baseName,
+ attributes: [.font: font, .foregroundColor: NSColor.labelColor]
+ )
+ if let effort {
+ attrStr.append(NSAttributedString(
+ string: " · \(effort.capitalized)",
+ attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor]
+ ))
+ }
+ titleLabel.attributedStringValue = attrStr
let chevronConfig = NSImage.SymbolConfiguration(
pointSize: 8 * fontScale, weight: .semibold
@@ -129,12 +142,13 @@ struct ModelPickerButton: NSViewRepresentable {
// Hover background
let isHovered = context.coordinator.isHovered
button.layer?.backgroundColor = isHovered
- ? NSColor.gray.withAlphaComponent(0.1).cgColor
+ ? NSColor.gray.withAlphaComponent(0.15).cgColor
: NSColor.clear.cgColor
button.layer?.cornerRadius = 5 * fontScale
button.layer?.cornerCurve = .continuous
// Ideal width based on text (allows shrinking when parent is tight)
+ let label = selectedModelLabel
let textWidth = labelWidth(label: label)
context.coordinator.widthConstraint?.constant = textWidth
if context.coordinator.widthConstraint == nil {
@@ -163,14 +177,19 @@ struct ModelPickerButton: NSViewRepresentable {
)
}
- private var selectedModelLabel: String {
+ private var modelDisplayName: String {
let name = selectedModel?.displayName ?? selectedModel?.modelName ?? ""
- if selectedModel?.degradationReason != nil {
- return "\u{26A0} \(name)"
- }
+ if selectedModel?.degradationReason != nil { return "\u{26A0} \(name)" }
return name
}
+ private var selectedModelLabel: String {
+ if let effort = currentEffort {
+ return "\(modelDisplayName) · \(effort.capitalized)"
+ }
+ return modelDisplayName
+ }
+
private func labelWidth(label: String) -> CGFloat {
let font = NSFont.systemFont(ofSize: 13 * fontScale)
let attrs: [NSAttributedString.Key: Any] = [.font: font]
@@ -229,7 +248,7 @@ struct ModelPickerButton: NSViewRepresentable {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.15
button?.animator().layer?.backgroundColor = NSColor.gray
- .withAlphaComponent(0.1).cgColor
+ .withAlphaComponent(0.15).cgColor
}
NSCursor.pointingHand.push()
}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerDetailPanel.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerDetailPanel.swift
index 7ea03f64..e17fd29f 100644
--- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerDetailPanel.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerDetailPanel.swift
@@ -1,29 +1,62 @@
import AppKit
+import Persist
// MARK: - Floating Detail Panel (shown on menu item hover)
+private class MouseTrackingVisualEffectView: NSVisualEffectView {
+ var onMouseEntered: (() -> Void)?
+ var onMouseExited: (() -> Void)?
+ private var mouseTrackingArea: NSTrackingArea?
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ if let existing = mouseTrackingArea {
+ removeTrackingArea(existing)
+ }
+ let area = NSTrackingArea(
+ rect: .zero,
+ options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+ owner: self,
+ userInfo: nil
+ )
+ addTrackingArea(area)
+ mouseTrackingArea = area
+ }
+
+ override func mouseEntered(with event: NSEvent) { onMouseEntered?() }
+ override func mouseExited(with event: NSEvent) { onMouseExited?() }
+}
+
class ModelPickerDetailPanel: NSPanel {
static let shared = ModelPickerDetailPanel()
- private let contentLabel = NSTextField(wrappingLabelWithString: "")
- private let nameLabel = NSTextField(labelWithString: "")
- private let separatorView = NSBox()
- private let containerView = NSView()
+ private let containerStack = NSStackView()
private var hideTimer: Timer?
private var containerConstraints: [NSLayoutConstraint] = []
private var currentFontScale: CGFloat = 1.0
+ private var currentModel: LLMModel?
+ private var onModelSelect: (() -> Void)?
+
+ // Clickable rows: (view in panel-local hierarchy, action, (label, restore color) pairs)
+ private var clickableRows: [(view: NSView, action: () -> Void, labels: [(NSTextField, NSColor)])] = []
+ private var hoveredRow: NSView?
+
+ // Event interception during NSMenu tracking
+ private var mousePollingTimer: Timer?
+ private var localEventMonitor: Any?
+ private var wasMouseDown: Bool = false
private init() {
super.init(
- contentRect: NSRect(x: 0, y: 0, width: 260, height: 100),
+ contentRect: NSRect(x: 0, y: 0, width: 200, height: 80),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: true
)
self.isFloatingPanel = true
self.level = .popUpMenu + 1
- self.isOpaque = false
+ self.isOpaque = true
self.backgroundColor = .clear
self.hidesOnDeactivate = false
self.hasShadow = true
@@ -32,189 +65,549 @@ class ModelPickerDetailPanel: NSPanel {
setupContent()
}
+ private static func roundedCornerMask(radius: CGFloat) -> NSImage {
+ let diameter = radius * 2
+ let image = NSImage(size: NSSize(width: diameter, height: diameter), flipped: false) { rect in
+ let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
+ NSColor.black.setFill()
+ path.fill()
+ return true
+ }
+ image.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius)
+ image.resizingMode = .stretch
+ return image
+ }
+
private func setupContent() {
- let visual = NSVisualEffectView()
+ let visual = MouseTrackingVisualEffectView()
+ visual.onMouseEntered = { [weak self] in self?.cancelHide() }
+ visual.onMouseExited = { [weak self] in self?.scheduleHide() }
visual.material = .popover
visual.state = .active
visual.wantsLayer = true
- visual.layer?.cornerRadius = 8
- visual.layer?.masksToBounds = true
+ visual.maskImage = Self.roundedCornerMask(radius: 8)
visual.translatesAutoresizingMaskIntoConstraints = false
- containerView.translatesAutoresizingMaskIntoConstraints = false
-
- nameLabel.textColor = .labelColor
- nameLabel.isEditable = false
- nameLabel.isBordered = false
- nameLabel.backgroundColor = .clear
- nameLabel.drawsBackground = false
- nameLabel.translatesAutoresizingMaskIntoConstraints = false
- nameLabel.lineBreakMode = .byTruncatingTail
-
- separatorView.boxType = .separator
- separatorView.translatesAutoresizingMaskIntoConstraints = false
-
- contentLabel.isEditable = false
- contentLabel.isBordered = false
- contentLabel.backgroundColor = .clear
- contentLabel.drawsBackground = false
- contentLabel.textColor = .secondaryLabelColor
- contentLabel.usesSingleLineMode = false
- contentLabel.maximumNumberOfLines = 0
- contentLabel.translatesAutoresizingMaskIntoConstraints = false
-
- containerView.addSubview(nameLabel)
- containerView.addSubview(separatorView)
- containerView.addSubview(contentLabel)
-
- visual.addSubview(containerView)
- self.contentView = visual
+ containerStack.orientation = .vertical
+ containerStack.alignment = .leading
+ containerStack.spacing = 6
+ containerStack.translatesAutoresizingMaskIntoConstraints = false
- // Static constraints that don't depend on font scale
- NSLayoutConstraint.activate([
- nameLabel.topAnchor.constraint(equalTo: containerView.topAnchor),
- nameLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
- nameLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
-
- separatorView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
- separatorView.trailingAnchor.constraint(
- equalTo: containerView.trailingAnchor
- ),
-
- contentLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
- contentLabel.trailingAnchor.constraint(
- equalTo: containerView.trailingAnchor
- ),
- contentLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
- ])
+ visual.addSubview(containerStack)
+ self.contentView = visual
applyScaledConstraints(to: visual, fontScale: 1.0)
}
- private func applyScaledConstraints(
- to visual: NSView,
- fontScale: CGFloat
- ) {
+ private func applyScaledConstraints(to visual: NSView, fontScale: CGFloat) {
NSLayoutConstraint.deactivate(containerConstraints)
- let padding: CGFloat = 10 * fontScale
- let horizontalPadding: CGFloat = 12 * fontScale
- let spacing: CGFloat = 6 * fontScale
+ let padding: CGFloat = 8 * fontScale
+ let horizontalPadding: CGFloat = 10 * fontScale
containerConstraints = [
- containerView.topAnchor.constraint(
- equalTo: visual.topAnchor, constant: padding
- ),
- containerView.leadingAnchor.constraint(
- equalTo: visual.leadingAnchor, constant: horizontalPadding
- ),
- containerView.trailingAnchor.constraint(
- equalTo: visual.trailingAnchor, constant: -horizontalPadding
- ),
- containerView.bottomAnchor.constraint(
- equalTo: visual.bottomAnchor, constant: -padding
- ),
- separatorView.topAnchor.constraint(
- equalTo: nameLabel.bottomAnchor, constant: spacing
- ),
- contentLabel.topAnchor.constraint(
- equalTo: separatorView.bottomAnchor, constant: spacing
- ),
+ containerStack.topAnchor.constraint(equalTo: visual.topAnchor, constant: padding),
+ containerStack.leadingAnchor.constraint(equalTo: visual.leadingAnchor, constant: horizontalPadding),
+ containerStack.trailingAnchor.constraint(equalTo: visual.trailingAnchor, constant: -horizontalPadding),
+ containerStack.bottomAnchor.constraint(equalTo: visual.bottomAnchor, constant: -padding),
]
NSLayoutConstraint.activate(containerConstraints)
- nameLabel.font = NSFont.systemFont(
- ofSize: 13 * fontScale, weight: .semibold
- )
- contentLabel.font = NSFont.systemFont(ofSize: 12 * fontScale)
- contentLabel.preferredMaxLayoutWidth = 236 * fontScale
+ if let visual = visual as? NSVisualEffectView {
+ visual.maskImage = Self.roundedCornerMask(radius: 8 * fontScale)
+ }
+ currentFontScale = fontScale
+ }
- visual.layer?.cornerRadius = 8 * fontScale
+ // MARK: - Interactivity (works during NSMenu event tracking)
+
+ private func startInteractivity() {
+ stopInteractivity()
+ wasMouseDown = (NSEvent.pressedMouseButtons & 1) != 0
+
+ // Poll mouse location every 50ms in .common mode so it fires during NSMenu tracking.
+ // We also use this loop to detect clicks on the panel, because
+ // `addLocalMonitorForEvents` does not reliably fire while NSMenu owns
+ // the event loop (the menu eats clicks outside its bounds before our
+ // monitor runs), so polling is the only thing that works here.
+ let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in
+ guard let self = self, self.isVisible else { return }
+ let mouse = NSEvent.mouseLocation
+ let isMouseDown = (NSEvent.pressedMouseButtons & 1) != 0
+ let isOverPanel = self.frame.contains(mouse)
+
+ if isOverPanel {
+ self.cancelHide()
+ self.updateHoveredRow(at: mouse)
+
+ // Detect mouse-down transition while over a row → trigger action.
+ if isMouseDown, !self.wasMouseDown {
+ self.handleClickInPanel(at: mouse)
+ }
+ } else {
+ self.clearHoveredRow()
+ }
- currentFontScale = fontScale
+ self.wasMouseDown = isMouseDown
+ }
+ mousePollingTimer = timer
+ RunLoop.current.add(timer, forMode: .common)
+
+ // Local monitor as a secondary path (fires when no menu is tracking).
+ localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
+ guard let self = self, self.isVisible else { return event }
+ let screenLocation = NSEvent.mouseLocation
+ if self.frame.contains(screenLocation) {
+ self.handleClickInPanel(at: screenLocation)
+ return nil
+ }
+ return event
+ }
+ }
+
+ private func stopInteractivity() {
+ mousePollingTimer?.invalidate()
+ mousePollingTimer = nil
+ if let monitor = localEventMonitor {
+ NSEvent.removeMonitor(monitor)
+ localEventMonitor = nil
+ }
+ clearHoveredRow()
+ }
+
+ private func updateHoveredRow(at mouseLocation: NSPoint) {
+ let target = rowAtScreenLocation(mouseLocation)?.view
+
+ if target !== hoveredRow {
+ restoreColors(for: hoveredRow)
+ hoveredRow?.layer?.backgroundColor = NSColor.clear.cgColor
+ if let target = target {
+ target.layer?.backgroundColor = NSColor.controlAccentColor.cgColor
+ applyHoverColors(for: target)
+ }
+ hoveredRow = target
+ }
+ }
+
+ private func clearHoveredRow() {
+ restoreColors(for: hoveredRow)
+ hoveredRow?.layer?.backgroundColor = NSColor.clear.cgColor
+ hoveredRow = nil
+ }
+
+ private func applyHoverColors(for row: NSView) {
+ guard let entry = clickableRows.first(where: { $0.view === row }) else { return }
+ for (label, _) in entry.labels {
+ label.textColor = .white
+ }
+ }
+
+ private func restoreColors(for row: NSView?) {
+ guard let row = row,
+ let entry = clickableRows.first(where: { $0.view === row }) else { return }
+ for (label, color) in entry.labels {
+ label.textColor = color
+ }
+ }
+
+ private func handleClickInPanel(at screenLocation: NSPoint) {
+ rowAtScreenLocation(screenLocation)?.action()
+ }
+
+ private func rowAtScreenLocation(_ screenLocation: NSPoint) -> (view: NSView, action: () -> Void)? {
+ let windowPoint = convertPoint(fromScreen: screenLocation)
+ guard let contentView = contentView else { return nil }
+ let contentPoint = contentView.convert(windowPoint, from: nil)
+ return clickableRows.first {
+ $0.view.convert($0.view.bounds, to: contentView).contains(contentPoint)
+ }.map { (view: $0.view, action: $0.action) }
+ }
+
+ // MARK: - Helper: Create labels
+
+ private func makeTitleLabel(_ text: String, scale: CGFloat) -> NSTextField {
+ let label = NSTextField(labelWithString: text)
+ label.font = NSFont.systemFont(ofSize: 13 * scale, weight: .bold)
+ label.textColor = .labelColor
+ label.lineBreakMode = .byTruncatingTail
+ label.setContentCompressionResistancePriority(.required, for: .horizontal)
+ return label
+ }
+
+ private func makeBodyLabel(_ text: String, scale: CGFloat, color: NSColor = .secondaryLabelColor) -> NSTextField {
+ let label = NSTextField(wrappingLabelWithString: text)
+ label.font = NSFont.systemFont(ofSize: 12 * scale)
+ label.textColor = color
+ label.isEditable = false
+ label.isBordered = false
+ label.backgroundColor = .clear
+ label.drawsBackground = false
+ return label
+ }
+
+ private func makeSeparator() -> NSBox {
+ let sep = NSBox()
+ sep.boxType = .separator
+ return sep
}
+ private func makeCategoryBadge(_ category: String, scale: CGFloat) -> NSView {
+ let lowered = category.lowercased()
+ let color: NSColor
+ switch lowered {
+ case "powerful": color = .systemBlue
+ case "lightweight": color = .systemGreen
+ default: color = .systemGray
+ }
+
+ let label = NSTextField(labelWithString: category.capitalized)
+ let hPad: CGFloat = 6 * scale
+ let vPad: CGFloat = 2 * scale
+ label.font = NSFont.systemFont(ofSize: 10 * scale, weight: .medium)
+ label.textColor = color
+ label.translatesAutoresizingMaskIntoConstraints = false
+
+ let container = NSView()
+ container.wantsLayer = true
+ container.layer?.borderColor = color.cgColor
+ container.layer?.borderWidth = 1.0
+ container.layer?.cornerRadius = (label.intrinsicContentSize.height + vPad * 2) / 2
+ container.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(label)
+
+ NSLayoutConstraint.activate([
+ label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: hPad),
+ label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -hPad),
+ label.topAnchor.constraint(equalTo: container.topAnchor, constant: vPad),
+ label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -vPad),
+ ])
+
+ return container
+ }
+
+ private func makeKeyValueRow(_ key: String, _ value: String, scale: CGFloat) -> NSStackView {
+ let keyLabel = NSTextField(labelWithString: key)
+ keyLabel.font = NSFont.systemFont(ofSize: 12 * scale)
+ keyLabel.textColor = .secondaryLabelColor
+ keyLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ keyLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
+
+ let valueLabel = NSTextField(labelWithString: value)
+ valueLabel.font = NSFont.systemFont(ofSize: 12 * scale)
+ valueLabel.textColor = .labelColor
+ valueLabel.alignment = .right
+ valueLabel.setContentHuggingPriority(.required, for: .horizontal)
+ valueLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
+
+ let row = NSStackView(views: [keyLabel, valueLabel])
+ row.orientation = .horizontal
+ row.distribution = .fill
+ row.spacing = 8 * scale
+ return row
+ }
+
+ // MARK: - Thinking Effort Helpers
+
+ private func effortDescription(for effort: String) -> String {
+ switch effort.lowercased() {
+ case "none": return "No reasoning applied"
+ case "low": return "Faster responses with less reasoning"
+ case "medium": return "Balanced reasoning and speed"
+ case "high": return "Maximum reasoning depth"
+ case "xhigh": return "Maximum reasoning depth but slower"
+ default: return ""
+ }
+ }
+
+ private func makeThinkingEffortRow(
+ effort: String,
+ isSelected: Bool,
+ isDefault: Bool,
+ scale: CGFloat,
+ onSelect: @escaping () -> Void
+ ) -> NSView {
+ let checkmark = NSTextField(labelWithString: "✓")
+ checkmark.font = NSFont.systemFont(ofSize: 12 * scale, weight: .medium)
+ checkmark.textColor = .labelColor
+ checkmark.alphaValue = isSelected ? 1.0 : 0.0
+ checkmark.setContentHuggingPriority(.required, for: .horizontal)
+ checkmark.setContentCompressionResistancePriority(.required, for: .horizontal)
+
+ var effortName = effort.capitalized
+ if isDefault { effortName += " (default)" }
+ let effortLabel = NSTextField(labelWithString: effortName)
+ effortLabel.font = NSFont.systemFont(ofSize: 12 * scale)
+ effortLabel.textColor = .labelColor
+ effortLabel.setContentHuggingPriority(.required, for: .horizontal)
+ effortLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
+
+ let description = effortDescription(for: effort)
+ let descLabel = NSTextField(labelWithString: description)
+ descLabel.font = NSFont.systemFont(ofSize: 12 * scale)
+ descLabel.textColor = .secondaryLabelColor
+ descLabel.alignment = .right
+ descLabel.lineBreakMode = .byTruncatingTail
+ descLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ descLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+
+ let innerStack = NSStackView(views: [checkmark, effortLabel, descLabel])
+ innerStack.orientation = .horizontal
+ innerStack.spacing = 4 * scale
+ innerStack.distribution = .fill
+ innerStack.translatesAutoresizingMaskIntoConstraints = false
+
+ // Outer container provides taller hover hit area without changing text spacing
+ let container = NSView()
+ container.wantsLayer = true
+ container.layer?.cornerRadius = 4 * scale
+ container.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(innerStack)
+
+ let vPad: CGFloat = 3 * scale
+ let hPad: CGFloat = 4 * scale
+ NSLayoutConstraint.activate([
+ innerStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: hPad),
+ innerStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -hPad),
+ innerStack.topAnchor.constraint(equalTo: container.topAnchor, constant: vPad),
+ innerStack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -vPad),
+ ])
+
+ clickableRows.append((container, onSelect, [
+ (checkmark, .labelColor),
+ (effortLabel, .labelColor),
+ (descLabel, .secondaryLabelColor),
+ ]))
+
+ return container
+ }
+
+ // MARK: - Token formatting
+
+ private func formatPrice(_ price: Float, tokenUnit: Int?) -> String {
+ let unit = tokenUnit ?? 1_000_000
+ let scaled = Double(price) * Double(unit) / 1_000_000.0
+ if scaled == 0 { return "$ 0" }
+ return scaled.truncatingRemainder(dividingBy: 1) == 0
+ ? String(format: "$ %.0f", scaled)
+ : String(format: "$ %.2f", scaled)
+ }
+
+ // MARK: - Show
+
func show(
for model: LLMModel,
nearRect: NSRect,
preferRight: Bool = true,
- fontScale: CGFloat = 1.0
+ fontScale: CGFloat = 1.0,
+ onModelSelect: (() -> Void)? = nil
) {
hideTimer?.invalidate()
hideTimer = nil
+ currentModel = model
+ self.onModelSelect = onModelSelect
+
if let visual = self.contentView {
applyScaledConstraints(to: visual, fontScale: fontScale)
}
+ // Clear previous content
+ containerStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
+ clickableRows.removeAll()
+ hoveredRow = nil
+ containerStack.spacing = 6 * fontScale
+
+ let scale = fontScale
+
+ // --- Title: Vendor + Display Name ---
let displayName = model.displayName ?? model.modelName
- nameLabel.stringValue = displayName
+ let vendorPrefix = model.vendor.map { "\($0) " } ?? ""
+ let titleLabel = makeTitleLabel("\(vendorPrefix)\(displayName)", scale: scale)
+ containerStack.addArrangedSubview(titleLabel)
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+ // --- Category badge ---
+ if let category = model.modelPickerCategory, !category.isEmpty {
+ let badge = makeCategoryBadge(category, scale: scale)
+ containerStack.addArrangedSubview(badge)
+ }
- var details: [String] = []
+ // --- Degradation warning ---
+ if let reason = model.degradationReason {
+ let warningLabel = makeBodyLabel("\u{26A0} \(reason)", scale: scale, color: .labelColor)
+ containerStack.addArrangedSubview(warningLabel)
+ }
- // Provider
- if let provider = model.providerName, !provider.isEmpty {
- details.append("Provider: \(provider)")
+ // --- Auto model description ---
+ if model.isAutoModel {
+ let desc = makeBodyLabel(
+ "Automatically selects the best model for your request based on capacity and performance.\n\nCost may vary based on the selected model.",
+ scale: scale
+ )
+ containerStack.addArrangedSubview(desc)
+ layoutAndShow(nearRect: nearRect, preferRight: preferRight, fontScale: fontScale)
+ return
}
- // Billing
- if let billing = model.billing {
- if billing.multiplier == 0 {
- details.append("Cost: Included")
- } else {
- let formatted = billing.multiplier
- .truncatingRemainder(dividingBy: 1) == 0
- ? String(format: "%.0f", billing.multiplier)
- : String(format: "%.2f", billing.multiplier)
- details.append("Cost: \(formatted)x premium")
- }
+ // --- Context Size section ---
+ let hasInput = model.maxInputTokens != nil
+ let hasOutput = model.maxOutputTokens != nil
+ if hasInput || hasOutput {
+ containerStack.addArrangedSubview(makeSeparator())
+
+ let inputStr = model.maxInputTokens.map { "\u{2191} \(ModelMenuItemFormatter.formatContextWindow($0))" } ?? ""
+ let outputStr = model.maxOutputTokens.map { "\u{2193} \(ModelMenuItemFormatter.formatContextWindow($0))" } ?? ""
+ let contextValue = [inputStr, outputStr].filter { !$0.isEmpty }.joined(separator: " ")
+ let row = makeKeyValueRow("Context Size:", contextValue, scale: scale)
+ containerStack.addArrangedSubview(row)
+ row.translatesAutoresizingMaskIntoConstraints = false
+ row.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true
}
- // Vision support
- if model.supportVision {
- details.append("Supports: Vision")
+ // --- Cost / million tokens section ---
+ if let tokenPrices = model.billing?.tokenPrices {
+ containerStack.addArrangedSubview(makeSeparator())
+
+ if let category = model.modelPickerPriceCategory, !category.isEmpty {
+ let categoryRow = makeKeyValueRow("Cost Category:", category.capitalized, scale: scale)
+ containerStack.addArrangedSubview(categoryRow)
+ categoryRow.translatesAutoresizingMaskIntoConstraints = false
+ categoryRow.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true
+ }
+
+ let costHeader = NSTextField(labelWithString: "Cost per 1M Tokens:")
+ costHeader.font = NSFont.systemFont(ofSize: 12 * scale)
+ costHeader.textColor = .secondaryLabelColor
+ containerStack.addArrangedSubview(costHeader)
+
+ let tokenUnit = tokenPrices.tokenUnit
+
+ if let inputPrice = tokenPrices.inputPrice {
+ let row = makeKeyValueRow("Input:", formatPrice(inputPrice, tokenUnit: tokenUnit), scale: scale)
+ containerStack.addArrangedSubview(row)
+ row.translatesAutoresizingMaskIntoConstraints = false
+ row.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true
+ }
+ if let outputPrice = tokenPrices.outputPrice {
+ let row = makeKeyValueRow("Output:", formatPrice(outputPrice, tokenUnit: tokenUnit), scale: scale)
+ containerStack.addArrangedSubview(row)
+ row.translatesAutoresizingMaskIntoConstraints = false
+ row.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true
+ }
+ if let cachePrice = tokenPrices.cachePrice {
+ let row = makeKeyValueRow("Cached:", formatPrice(cachePrice, tokenUnit: tokenUnit), scale: scale)
+ containerStack.addArrangedSubview(row)
+ row.translatesAutoresizingMaskIntoConstraints = false
+ row.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true
+ }
}
- // Degradation
- if let reason = model.degradationReason {
- details.append("\n\u{26A0} \(reason)")
+ // --- Context Window ---
+ if let maxContext = model.maxContextWindowTokens {
+ containerStack.addArrangedSubview(makeSeparator())
+
+ let row = makeKeyValueRow("Context Window:", "\(ModelMenuItemFormatter.formatContextWindow(maxContext))", scale: scale)
+ containerStack.addArrangedSubview(row)
+ row.translatesAutoresizingMaskIntoConstraints = false
+ row.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true
}
- // Auto model description
- if model.isAutoModel {
- details = [
- "Automatically selects the best model for your request based on capacity and performance.",
- "\nCost may vary based on the selected model.",
- ]
+ // --- Thinking Effort ---
+ if model.supportsReasoningEffortLevel, !model.isAutoModel {
+ let efforts = model.reasoningEfforts ?? []
+ if !efforts.isEmpty {
+ containerStack.addArrangedSubview(makeSeparator())
+
+ let headerLabel = NSTextField(labelWithString: "Thinking Effort:")
+ headerLabel.font = NSFont.systemFont(ofSize: 12 * scale)
+ headerLabel.textColor = .secondaryLabelColor
+ containerStack.addArrangedSubview(headerLabel)
+
+ let currentEffort = AppState.shared.effectiveReasoningEffort(for: model) ?? ""
+ let familyDefault = model.defaultReasoningEffort
+
+ // Zero-spacing nested stack so container vPad doesn't add to inter-row gap
+ let effortsStack = NSStackView()
+ effortsStack.orientation = .vertical
+ effortsStack.alignment = .leading
+ effortsStack.spacing = 0
+ effortsStack.translatesAutoresizingMaskIntoConstraints = false
+ containerStack.addArrangedSubview(effortsStack)
+ effortsStack.widthAnchor.constraint(equalTo: containerStack.widthAnchor).isActive = true
+
+ for effort in efforts {
+ let isSelected = effort.lowercased() == currentEffort.lowercased()
+ let isDefault = effort.lowercased() == familyDefault
+ let row = makeThinkingEffortRow(
+ effort: effort,
+ isSelected: isSelected,
+ isDefault: isDefault,
+ scale: scale,
+ onSelect: { [weak self] in
+ AppState.shared.setSelectedReasoningEffort(effort, for: model)
+ let onModelSelect = self?.onModelSelect
+ DispatchQueue.main.async { [weak self] in
+ onModelSelect?()
+ self?.orderOut(nil)
+ }
+ }
+ )
+ effortsStack.addArrangedSubview(row)
+ row.translatesAutoresizingMaskIntoConstraints = false
+ row.widthAnchor.constraint(equalTo: effortsStack.widthAnchor).isActive = true
+ }
+ }
}
- contentLabel.stringValue = details.joined(separator: "\n")
+ layoutAndShow(nearRect: nearRect, preferRight: preferRight, fontScale: fontScale)
+ startInteractivity()
+ }
- // Size to fit content
- let fittingSize = containerView.fittingSize
- let panelWidth: CGFloat = 260 * fontScale
- let panelHeight = fittingSize.height + 20 * fontScale
+ private func layoutAndShow(nearRect: NSRect, preferRight: Bool, fontScale: CGFloat) {
+ let horizontalPadding: CGFloat = 10 * fontScale
+ let verticalPadding: CGFloat = 8 * fontScale
+ let hasThinkingEffort = (currentModel?.supportsReasoningEffortLevel == true)
+ && !(currentModel?.reasoningEfforts?.isEmpty ?? true)
+ && !(currentModel?.isAutoModel ?? false)
+ let minPanelWidth: CGFloat = (hasThinkingEffort ? 320 : 220) * fontScale
+ let maxPanelWidth: CGFloat = 560 * fontScale
+
+ containerStack.layoutSubtreeIfNeeded()
+ let fittingSize = containerStack.fittingSize
+
+ let panelWidth = max(minPanelWidth, min(ceil(fittingSize.width + horizontalPadding * 2), maxPanelWidth))
+ let contentWidth = panelWidth - horizontalPadding * 2
+
+ for view in containerStack.arrangedSubviews {
+ if let textField = view as? NSTextField {
+ let wraps = textField.cell?.wraps == true
+ let isTitleFont = textField.font?.pointSize == 13 * fontScale
+
+ if isTitleFont {
+ textField.lineBreakMode = .byWordWrapping
+ textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+ textField.preferredMaxLayoutWidth = contentWidth
+ } else if wraps {
+ textField.preferredMaxLayoutWidth = contentWidth
+ }
+ }
+ }
+
+ containerStack.layoutSubtreeIfNeeded()
+ let finalFittingSize = containerStack.fittingSize
+ let panelHeight = ceil(finalFittingSize.height + verticalPadding * 2)
let gap: CGFloat = 4 * fontScale
var origin: NSPoint
if preferRight {
- origin = NSPoint(
- x: nearRect.maxX + gap, y: nearRect.midY - panelHeight / 2
- )
+ origin = NSPoint(x: nearRect.maxX + gap, y: nearRect.midY - panelHeight / 2)
} else {
- origin = NSPoint(
- x: nearRect.minX - panelWidth - gap,
- y: nearRect.midY - panelHeight / 2
- )
+ origin = NSPoint(x: nearRect.minX - panelWidth - gap, y: nearRect.midY - panelHeight / 2)
}
- // Find the screen that contains the menu item
- let menuScreen = NSScreen.screens.first(where: {
- $0.frame.contains(nearRect.origin)
- }) ?? NSScreen.main
+ let menuScreen = NSScreen.screens.first(where: { $0.frame.contains(nearRect.origin) }) ?? NSScreen.main
- // Ensure the panel stays fully visible on that screen
if let screen = menuScreen {
let screenFrame = screen.visibleFrame
if origin.x + panelWidth > screenFrame.maxX {
@@ -223,10 +616,8 @@ class ModelPickerDetailPanel: NSPanel {
if origin.x < screenFrame.minX {
origin.x = nearRect.maxX + gap
}
- // Clamp horizontally as last resort
origin.x = max(origin.x, screenFrame.minX)
origin.x = min(origin.x, screenFrame.maxX - panelWidth)
- // Clamp vertically
origin.y = max(origin.y, screenFrame.minY)
origin.y = min(origin.y, screenFrame.maxY - panelHeight)
}
@@ -238,8 +629,12 @@ class ModelPickerDetailPanel: NSPanel {
func scheduleHide() {
hideTimer?.invalidate()
- hideTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in
- self?.orderOut(nil)
+ hideTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { [weak self] _ in
+ guard let self = self else { return }
+ // Don't hide if mouse is still over the panel
+ if self.frame.contains(NSEvent.mouseLocation) { return }
+ self.stopInteractivity()
+ self.orderOut(nil)
}
}
@@ -248,9 +643,15 @@ class ModelPickerDetailPanel: NSPanel {
hideTimer = nil
}
+ override func orderOut(_ sender: Any?) {
+ stopInteractivity()
+ super.orderOut(sender)
+ }
+
override func close() {
hideTimer?.invalidate()
hideTimer = nil
+ stopInteractivity()
super.close()
}
}
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenu.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenu.swift
index a11f8b6f..9f7d87e9 100644
--- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenu.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenu.swift
@@ -348,9 +348,7 @@ struct ModelPickerMenu {
maxWidth: CGFloat
) {
let item = NSMenuItem()
- let multiplierText = currentCache
- .modelMultiplierCache[model.id.appending(model.providerName ?? "")]
- ?? ModelMenuItemFormatter.getMultiplierText(for: model)
+ let multiplierText = resolvedMultiplierText(for: model)
let menuItemView = ModelPickerMenuItem(
model: model,
@@ -367,7 +365,11 @@ struct ModelPickerMenu {
self.detailPanel.show(
for: hoveredModel,
nearRect: itemRect,
- fontScale: self.fontScale
+ fontScale: self.fontScale,
+ onModelSelect: {
+ AppState.shared.setSelectedModel(model)
+ menu.cancelTracking()
+ }
)
},
onHoverExit: {
@@ -378,6 +380,15 @@ struct ModelPickerMenu {
menu.addItem(item)
}
+ private func resolvedMultiplierText(for model: LLMModel) -> String {
+ if model.supportsReasoningEffortLevel {
+ let effort = AppState.shared.effectiveReasoningEffort(for: model)
+ return ModelMenuItemFormatter.getMultiplierText(for: model, reasoningEffort: effort)
+ }
+ return currentCache.modelMultiplierCache[model.id.appending(model.providerName ?? "")]
+ ?? ModelMenuItemFormatter.getMultiplierText(for: model)
+ }
+
private func calculateMaxWidth(
copilotModels: [LLMModel],
byokModels: [LLMModel]
@@ -386,9 +397,7 @@ struct ModelPickerMenu {
let allModels = isBYOKFFEnabled ? copilotModels + byokModels : copilotModels
for model in allModels {
- let multiplierText = currentCache
- .modelMultiplierCache[model.id.appending(model.providerName ?? "")]
- ?? ModelMenuItemFormatter.getMultiplierText(for: model)
+ let multiplierText = resolvedMultiplierText(for: model)
let width = ModelPickerMenuItem.calculateItemWidth(
model: model,
multiplierText: multiplierText,
diff --git a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenuItem.swift b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenuItem.swift
index a395256e..0ddd1927 100644
--- a/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenuItem.swift
+++ b/Core/Sources/ConversationTab/ModeAndModelPicker/ModelPicker/ModelPickerMenuItem.swift
@@ -174,22 +174,36 @@ class ModelPickerMenuItem: NSView {
let textLeading = checkmarkImageView.trailingAnchor
- NSLayoutConstraint.activate([
- nameLabel.leadingAnchor.constraint(
- equalTo: textLeading, constant: constants.checkmarkToText
- ),
- nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
-
- multiplierLabel.trailingAnchor.constraint(
- equalTo: trailingAnchor, constant: -constants.trailingPadding
- ),
- multiplierLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
-
- nameLabel.trailingAnchor.constraint(
- lessThanOrEqualTo: multiplierLabel.leadingAnchor,
- constant: -constants.nameToMultiplier
- ),
- ])
+ if multiplierText.isEmpty {
+ // No multiplier — name label extends to the trailing edge
+ NSLayoutConstraint.activate([
+ nameLabel.leadingAnchor.constraint(
+ equalTo: textLeading, constant: constants.checkmarkToText
+ ),
+ nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+ nameLabel.trailingAnchor.constraint(
+ lessThanOrEqualTo: trailingAnchor,
+ constant: -constants.trailingPadding
+ ),
+ ])
+ } else {
+ NSLayoutConstraint.activate([
+ nameLabel.leadingAnchor.constraint(
+ equalTo: textLeading, constant: constants.checkmarkToText
+ ),
+ nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+
+ multiplierLabel.trailingAnchor.constraint(
+ equalTo: trailingAnchor, constant: -constants.trailingPadding
+ ),
+ multiplierLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
+
+ nameLabel.trailingAnchor.constraint(
+ lessThanOrEqualTo: multiplierLabel.leadingAnchor,
+ constant: -constants.nameToMultiplier
+ ),
+ ])
+ }
}
// MARK: - Mouse handling
diff --git a/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift
index 108f2f64..fa9a0bb1 100644
--- a/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift
+++ b/Core/Sources/ConversationTab/Views/BotMessage/ResponseToolBar.swift
@@ -14,11 +14,13 @@ struct ResponseToolBar: View {
return nil
}
let rounded = (multiplier * 100).rounded() / 100
+ guard rounded != 0 else { return nil }
let formatter = NumberFormatter()
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 2
formatter.numberStyle = .decimal
let formattedMultiplier = formatter.string(from: NSNumber(value: rounded)) ?? "\(rounded)"
+ guard rounded != 0 else { return nil }
return "\(formattedMultiplier)x"
}
@@ -26,13 +28,21 @@ struct ResponseToolBar: View {
guard let modelName = message.modelName else {
return nil
}
-
+
var text = modelName
-
+
+ if let providerName = message.modelProviderName, !providerName.isEmpty {
+ text += " • \(providerName)"
+ }
+
+ if let effort = message.reasoningEffort, !effort.isEmpty, effort.lowercased() != "none" {
+ text += " • \(effort.capitalized)"
+ }
+
if let billingMultiplier = billingMultiplier {
text += " • \(billingMultiplier)"
}
-
+
return text
}
diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift
index 0523a44e..5686f0e9 100644
--- a/Core/Sources/ConversationTab/Views/FunctionMessage.swift
+++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift
@@ -24,21 +24,43 @@ struct FunctionMessage: View {
text.contains("You've reached your quota limit for your BYOK model")
}
+ private var isTBBMessage: Bool {
+ text.contains("AI Credits") || text.contains("additional overages")
+ }
+
private var switchToFallbackModelText: String {
+ guard !isTBBMessage else { return "" }
if let fallbackModelName = CopilotModelManager.getFallbackLLM(
scope: chat.isAgentMode ? .agentPanel : .chatPanel
)?.modelName {
return "We have automatically switched you to \(fallbackModelName) which is included with your plan."
- } else {
- return ""
}
+ return ""
+ }
+
+ private var quotaActionButtons: [(title: String, urlString: String, isProminent: Bool)] {
+ let lower = text.lowercased()
+ let hasEnableOverage = lower.contains("enable additional overages")
+ let hasIncreaseBudget = lower.contains("increase budget")
+ let hasOverage = hasEnableOverage || hasIncreaseBudget
+ var buttons: [(String, String, Bool)] = []
+ if hasEnableOverage {
+ buttons.append(("Enable Additional Overage", "https://aka.ms/github-copilot-manage-overage", true))
+ }
+ if hasIncreaseBudget {
+ buttons.append(("Increase Budget", "https://aka.ms/github-copilot-manage-overage", true))
+ }
+ if lower.contains("upgrade your plan") {
+ buttons.append(("Upgrade Plan", "https://aka.ms/github-copilot-upgrade-plan", !hasOverage))
+ }
+ return buttons
}
private var errorContent: Text {
switch (isFreePlanUser, isOrgUser, isBYOKUser) {
case (true, _, _):
return Text("Monthly message limit reached. Upgrade to Copilot Pro (30-day free trial) or wait for your limit to reset.")
-
+
case (_, true, _):
let parts = [
"You have exceeded your free request allowance.",
@@ -46,17 +68,11 @@ struct FunctionMessage: View {
"To enable additional paid premium requests, contact your organization admin."
].filter { !$0.isEmpty }
return Text(attributedString(from: parts))
-
+
case (_, _, true):
let sentences = splitBYOKQuotaMessage(text)
-
guard sentences.count == 2 else { fallthrough }
-
- let parts = [
- sentences[0],
- switchToFallbackModelText,
- sentences[1]
- ].filter { !$0.isEmpty }
+ let parts = [sentences[0], switchToFallbackModelText, sentences[1]].filter { !$0.isEmpty }
return Text(attributedString(from: parts))
default:
@@ -91,7 +107,7 @@ struct FunctionMessage: View {
var body: some View {
NotificationBanner(style: .warning) {
errorContent
-
+
if isFreePlanUser {
Button("Update to Copilot Pro") {
if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") {
@@ -109,6 +125,27 @@ struct FunctionMessage: View {
}
}
}
+
+ if !quotaActionButtons.isEmpty {
+ HStack(spacing: 8) {
+ ForEach(quotaActionButtons, id: \.title) { button in
+ Group {
+ if button.isProminent {
+ Button(button.title) {
+ if let url = URL(string: button.urlString) { openURL(url) }
+ }.buttonStyle(.borderedProminent)
+ } else {
+ Button(button.title) {
+ if let url = URL(string: button.urlString) { openURL(url) }
+ }.buttonStyle(.bordered)
+ }
+ }
+ .controlSize(.regular)
+ .scaledFont(.body)
+ .onHover { if $0 { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
+ }
+ }
+ }
}
}
}
diff --git a/Core/Sources/ConversationTab/Views/RateLimitWarningBanner.swift b/Core/Sources/ConversationTab/Views/RateLimitWarningBanner.swift
deleted file mode 100644
index 7fd01e4c..00000000
--- a/Core/Sources/ConversationTab/Views/RateLimitWarningBanner.swift
+++ /dev/null
@@ -1,43 +0,0 @@
-import SharedUIComponents
-import SwiftUI
-
-struct RateLimitWarningBanner: View {
- let message: String
- let onDismiss: () -> Void
-
- @State private var isLinkHovered = false
-
- var body: some View {
- NotificationBanner(style: .info, isDismissable: true, onDismiss: onDismiss) {
- VStack(alignment: .leading, spacing: 8) {
- Text(message)
- .foregroundColor(.primary)
- .frame(maxWidth: .infinity, alignment: .leading)
-
- Button(action: {
- if let url = URL(string: "https://aka.ms/github-copilot-rate-limit-error") {
- NSWorkspace.shared.open(url)
- }
- }) {
- Text("Learn more")
- .underline(isLinkHovered)
- .foregroundColor(.accentColor)
- }
- .buttonStyle(.plain)
- .onHover { isHovered in
- isLinkHovered = isHovered
- DispatchQueue.main.async {
- if isHovered {
- NSCursor.pointingHand.push()
- } else {
- NSCursor.pop()
- }
- }
- }
- .onDisappear {
- NSCursor.pop()
- }
- }
- }
- }
-}
diff --git a/Core/Sources/ConversationTab/Views/WarningBanner.swift b/Core/Sources/ConversationTab/Views/WarningBanner.swift
new file mode 100644
index 00000000..ae2dcdaa
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/WarningBanner.swift
@@ -0,0 +1,72 @@
+import AppKit
+import GitHubCopilotService
+import SharedUIComponents
+import SwiftUI
+
+struct WarningBanner: View {
+ let message: String
+ let severity: String // "warning" or "info"
+ let actions: [WarningAction]
+ let onDismiss: () -> Void
+
+ @State private var hoveredActionIndex: Int? = nil
+
+ private var bannerStyle: BannerStyle {
+ severity == "warning" ? .warning : .info
+ }
+
+ var body: some View {
+ NotificationBanner(style: bannerStyle, isDismissable: true, onDismiss: onDismiss) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(message)
+ .foregroundColor(.primary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if !actions.isEmpty {
+ HStack(spacing: 12) {
+ ForEach(Array(actions.enumerated()), id: \.offset) { index, action in
+ ActionLink(
+ title: action.title,
+ url: action.url,
+ isHovered: hoveredActionIndex == index
+ ) { isHovered in
+ hoveredActionIndex = isHovered ? index : nil
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct ActionLink: View {
+ let title: String
+ let url: URL
+ let isHovered: Bool
+ let onHoverChange: (Bool) -> Void
+
+ var body: some View {
+ Button(action: {
+ NSWorkspace.shared.open(url)
+ }) {
+ Text(title)
+ .underline(isHovered)
+ .foregroundColor(.accentColor)
+ }
+ .buttonStyle(.plain)
+ .onHover { isHovered in
+ onHoverChange(isHovered)
+ DispatchQueue.main.async {
+ if isHovered {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+ }
+ .onDisappear {
+ NSCursor.pop()
+ }
+ }
+}
diff --git a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
index 39d298c0..52062f58 100644
--- a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
+++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
@@ -5,6 +5,7 @@ import Status
import SwiftUI
import Cache
import Client
+import Logger
public struct SignInResponse {
public let status: SignInInitiateStatus
@@ -30,6 +31,9 @@ public class GitHubCopilotViewModel: ObservableObject {
@Published public var waitingForSignIn = false
static var copilotAuthService: GitHubCopilotService?
+
+ private var lastQuotaCheckDate: Date?
+ private static let quotaCacheInterval: TimeInterval = 60 // seconds
// Make init private to enforce singleton pattern
private init() {}
@@ -363,4 +367,23 @@ public class GitHubCopilotViewModel: ObservableObject {
object: nil
)
}
+
+ /// Refreshes quota info only if the cache has expired (older than 60s).
+ public func refreshQuotaIfNeeded() {
+ if let lastCheck = lastQuotaCheckDate,
+ Date().timeIntervalSince(lastCheck) < Self.quotaCacheInterval {
+ return
+ }
+ Task {
+ do {
+ let service = try getGitHubCopilotAuthService()
+ let accountStatus = try await service.checkStatus()
+ guard accountStatus == .ok || accountStatus == .maybeOk else { return }
+ let _ = try await service.checkQuota()
+ lastQuotaCheckDate = Date()
+ } catch {
+ Logger.client.error("Failed to refresh quota: \(error)")
+ }
+ }
+ }
}
diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
index 33590569..8d73d1d3 100644
--- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
+++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
@@ -23,7 +23,9 @@ extension ChatMessage {
var turnStatus: ChatMessage.TurnStatus?
let requestType: RequestType
var modelName: String?
+ var modelProviderName: String?
var billingMultiplier: Float?
+ var reasoningEffort: String?
// Custom decoder to provide default value for steps
init(from decoder: Decoder) throws {
@@ -51,7 +53,9 @@ extension ChatMessage {
turnStatus = try container.decodeIfPresent(ChatMessage.TurnStatus.self, forKey: .turnStatus)
requestType = try container.decodeIfPresent(RequestType.self, forKey: .requestType) ?? .conversation
modelName = try container.decodeIfPresent(String.self, forKey: .modelName)
+ modelProviderName = try container.decodeIfPresent(String.self, forKey: .modelProviderName)
billingMultiplier = try container.decodeIfPresent(Float.self, forKey: .billingMultiplier)
+ reasoningEffort = try container.decodeIfPresent(String.self, forKey: .reasoningEffort)
}
// Default memberwise init for encoding
@@ -72,7 +76,9 @@ extension ChatMessage {
turnStatus: ChatMessage.TurnStatus? = nil,
requestType: RequestType = .conversation,
modelName: String? = nil,
- billingMultiplier: Float? = nil
+ modelProviderName: String? = nil,
+ billingMultiplier: Float? = nil,
+ reasoningEffort: String? = nil
) {
self.content = content
self.contentImageReferences = contentImageReferences ?? []
@@ -90,7 +96,9 @@ extension ChatMessage {
self.turnStatus = turnStatus
self.requestType = requestType
self.modelName = modelName
+ self.modelProviderName = modelProviderName
self.billingMultiplier = billingMultiplier
+ self.reasoningEffort = reasoningEffort
}
}
@@ -112,7 +120,9 @@ extension ChatMessage {
turnStatus: self.turnStatus,
requestType: self.requestType,
modelName: self.modelName,
- billingMultiplier: self.billingMultiplier
+ modelProviderName: self.modelProviderName,
+ billingMultiplier: self.billingMultiplier,
+ reasoningEffort: self.reasoningEffort
)
// TODO: handle exception
@@ -152,7 +162,9 @@ extension ChatMessage {
turnStatus: turnItemData.turnStatus,
requestType: turnItemData.requestType,
modelName: turnItemData.modelName,
+ modelProviderName: turnItemData.modelProviderName,
billingMultiplier: turnItemData.billingMultiplier,
+ reasoningEffort: turnItemData.reasoningEffort,
createdAt: turnItem.createdAt,
updatedAt: turnItem.updatedAt
)
diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift
index 0e1fc9e4..f5530b28 100644
--- a/Core/Sources/SuggestionWidget/ChatWindowView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift
@@ -239,6 +239,7 @@ private extension View {
struct ChatBar: View {
let store: StoreOf
@Binding var isChatHistoryVisible: Bool
+ @ObservedObject private var statusObserver = StatusObserver.shared
struct TabBarState: Equatable {
var tabInfo: IdentifiedArray
@@ -254,6 +255,13 @@ struct ChatBar: View {
Spacer()
+ if statusObserver.quotaInfo != nil {
+ QuotaButton(store: store)
+
+ Divider()
+ .scaledFrame(height: 16)
+ }
+
CreateButton(store: store)
ChatHistoryButton(store: store, isChatHistoryVisible: $isChatHistoryVisible)
@@ -388,6 +396,165 @@ struct ChatBar: View {
}
}
}
+
+ struct QuotaButton: View {
+ let store: StoreOf
+ @ObservedObject private var statusObserver = StatusObserver.shared
+ @State private var isPopoverPresented = false
+ @State private var isButtonHovered = false
+ @State private var isPopoverHovered = false
+ @State private var dismissTask: DispatchWorkItem?
+
+ private var quotaInfo: GitHubCopilotQuotaInfo? {
+ statusObserver.quotaInfo
+ }
+
+ /// Static icon for unlimited business/enterprise; everyone else gets a dynamic pie chart.
+ private var usesStaticIcon: Bool {
+ guard let info = quotaInfo else { return true }
+ return info.isCBCEUnlimited
+ }
+
+ /// Free plan uses chat percentRemaining; other plans use premiumInteractions.
+ private var pieChartPercentRemaining: Float? {
+ guard let info = quotaInfo else { return nil }
+ if info.isFreeUser {
+ if let p = info.chat.percentRemaining { return p }
+ return info.chat.usedPercentage.map { 100.0 - $0 }
+ }
+ guard let snapshot = info.premiumInteractions else { return nil }
+ if let p = snapshot.percentRemaining { return p }
+ return snapshot.usedPercentage.map { 100.0 - $0 }
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ Button(action: {}) {
+ if usesStaticIcon {
+ Image(systemName: "chart.pie")
+ .scaledFont(.body)
+ } else {
+ PieChartIcon(
+ percentRemaining: pieChartPercentRemaining ?? 100
+ )
+ .scaledFrame(width: 14, height: 14)
+ }
+ }
+ .buttonStyle(HoverButtonStyle())
+ .accessibilityLabel("Copilot Usage")
+ .onHover { hovering in
+ isButtonHovered = hovering
+ handleHoverChange()
+ if hovering {
+ GitHubCopilotViewModel.shared.refreshQuotaIfNeeded()
+ }
+ }
+ .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
+ QuotaPopoverView(
+ quotaInfo: statusObserver.quotaInfo
+ )
+ .onHover { hovering in
+ isPopoverHovered = hovering
+ handleHoverChange()
+ }
+ }
+ }
+ }
+
+ private func handleHoverChange() {
+ dismissTask?.cancel()
+ if isButtonHovered || isPopoverHovered {
+ isPopoverPresented = true
+ // Activate the app so buttons in the popover are interactive
+ // even when the chat panel wasn't focused before hovering
+ if !NSApp.isActive {
+ if #available(macOS 14.0, *) {
+ NSApp.activate()
+ } else {
+ NSApp.activate(ignoringOtherApps: false)
+ }
+ }
+ } else {
+ let task = DispatchWorkItem { [self] in
+ if !isButtonHovered && !isPopoverHovered {
+ isPopoverPresented = false
+ }
+ }
+ dismissTask = task
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: task)
+ }
+ }
+ }
+
+ /// A custom pie/donut chart icon that shows usage as a filled arc.
+ struct PieChartIcon: View {
+ let percentRemaining: Float
+
+ private var usedFraction: Double {
+ Double(min(max(100 - percentRemaining, 0), 100)) / 100.0
+ }
+
+ private var color: Color {
+ if percentRemaining <= 10 { return .red }
+ if percentRemaining <= 25 { return .yellow }
+ return .primary
+ }
+
+ var body: some View {
+ ZStack {
+ if percentRemaining <= 0 {
+ DonutShape(outerRadius: 13, innerRadius: 2)
+ .fill(color, style: FillStyle(eoFill: true))
+ } else {
+ Circle()
+ .strokeBorder(color, lineWidth: 1)
+ PieSlice(fraction: usedFraction)
+ .fill(color)
+ }
+ }
+ }
+
+ private struct DonutShape: Shape {
+ var outerRadius: CGFloat
+ var innerRadius: CGFloat
+
+ func path(in rect: CGRect) -> Path {
+ let center = CGPoint(x: rect.midX, y: rect.midY)
+ let maxRadius = min(rect.width, rect.height) / 2
+ let outer = min(outerRadius, maxRadius)
+ let inner = min(innerRadius, outer)
+ var path = Path()
+ path.addEllipse(in: CGRect(
+ x: center.x - outer, y: center.y - outer,
+ width: outer * 2, height: outer * 2
+ ))
+ path.addEllipse(in: CGRect(
+ x: center.x - inner, y: center.y - inner,
+ width: inner * 2, height: inner * 2
+ ))
+ return path
+ }
+ }
+
+ private struct PieSlice: Shape {
+ var fraction: Double
+
+ func path(in rect: CGRect) -> Path {
+ let center = CGPoint(x: rect.midX, y: rect.midY)
+ let radius = min(rect.width, rect.height) / 2
+ let startAngle = Angle.degrees(-90)
+ let endAngle = Angle.degrees(-90 + 360 * fraction)
+
+ var path = Path()
+ path.move(to: center)
+ path.addArc(center: center, radius: radius,
+ startAngle: startAngle, endAngle: endAngle,
+ clockwise: false)
+ path.closeSubpath()
+ return path
+ }
+ }
+ }
}
struct ChatTabBarButton: View {
diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift
index a40b2137..bc99ed6a 100644
--- a/ExtensionService/AppDelegate+Menu.swift
+++ b/ExtensionService/AppDelegate+Menu.swift
@@ -111,23 +111,13 @@ extension AppDelegate {
quotaItem = NSMenuItem()
quotaItem.view = QuotaView(
- chat: .init(
- percentRemaining: 0,
- unlimited: false,
- overagePermitted: false
- ),
- completions: .init(
- percentRemaining: 0,
- unlimited: false,
- overagePermitted: false
- ),
- premiumInteractions: .init(
- percentRemaining: 0,
- unlimited: false,
- overagePermitted: false
- ),
- resetDate: "",
- copilotPlan: ""
+ quotaInfo: GitHubCopilotQuotaInfo(
+ chat: .init(unlimited: false, overagePermitted: false),
+ completions: .init(unlimited: false, overagePermitted: false),
+ premiumInteractions: .init(unlimited: false, overagePermitted: false),
+ resetDate: "",
+ copilotPlan: ""
+ )
)
quotaItem.isHidden = true
diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift
index 4995aa31..fcbad9d6 100644
--- a/ExtensionService/AppDelegate.swift
+++ b/ExtensionService/AppDelegate.swift
@@ -282,8 +282,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
let service = try await GitHubCopilotViewModel.shared.getGitHubCopilotAuthService()
let accountStatus = try await service.checkStatus()
if accountStatus == .ok || accountStatus == .maybeOk {
- let quota = try await service.checkQuota()
- Logger.service.info("User quota checked successfully: \(quota)")
+ await GitHubCopilotViewModel.shared.refreshQuotaIfNeeded()
}
} catch {
Logger.service.error("Failed to read auth status: \(error)")
@@ -333,27 +332,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
self.authStatusItem.isHidden = true
}
- if let quotaInfo = status.quotaInfo, !quotaInfo.resetDate.isEmpty {
+ if let quotaInfo = status.quotaInfo {
self.quotaItem.isHidden = false
- self.quotaItem.view = QuotaView(
- chat: .init(
- percentRemaining: quotaInfo.chat.percentRemaining,
- unlimited: quotaInfo.chat.unlimited,
- overagePermitted: quotaInfo.chat.overagePermitted
- ),
- completions: .init(
- percentRemaining: quotaInfo.completions.percentRemaining,
- unlimited: quotaInfo.completions.unlimited,
- overagePermitted: quotaInfo.completions.overagePermitted
- ),
- premiumInteractions: .init(
- percentRemaining: quotaInfo.premiumInteractions.percentRemaining,
- unlimited: quotaInfo.premiumInteractions.unlimited,
- overagePermitted: quotaInfo.premiumInteractions.overagePermitted
- ),
- resetDate: quotaInfo.resetDate,
- copilotPlan: quotaInfo.copilotPlan
- )
+ self.quotaItem.view = QuotaView(quotaInfo: quotaInfo)
} else {
self.quotaItem.isHidden = true
}
@@ -535,6 +516,7 @@ enum CLSMessageType {
case chatLimitReached
case completionLimitReached
case byokLimitedReached
+ case monthlyAICreditsLimitReached
case other
var summary: String {
@@ -545,6 +527,8 @@ enum CLSMessageType {
return "Monthly Completion Limit Reached"
case .byokLimitedReached:
return "BYOK Limit Reached"
+ case .monthlyAICreditsLimitReached:
+ return "Monthly AI Credits Limit Reached"
case .other:
return "CLS Error"
}
@@ -556,14 +540,6 @@ struct CLSMessage {
let detail: String
}
-func extractDateFromCLSMessage(_ message: String) -> String? {
- let pattern = #"until (\d{1,2}/\d{1,2}/\d{4}, \d{1,2}:\d{2}:\d{2} [AP]M)"#
- if let range = message.range(of: pattern, options: .regularExpression) {
- return String(message[range].dropFirst(6))
- }
- return nil
-}
-
func getCLSMessageSummary(_ message: String) -> CLSMessage {
let messageType: CLSMessageType
@@ -574,16 +550,11 @@ func getCLSMessageSummary(_ message: String) -> CLSMessage {
messageType = .completionLimitReached
} else if message.contains("BYOK") {
messageType = .byokLimitedReached
+ } else if message.contains("You've used your monthly AI Credits") {
+ messageType = .monthlyAICreditsLimitReached
} else {
messageType = .other
}
- let detail: String
- if let date = extractDateFromCLSMessage(message) {
- detail = "Visit GitHub to check your usage and upgrade to Copilot Pro or wait until \(date) for your limit to reset."
- } else {
- detail = message
- }
-
- return CLSMessage(summary: messageType.summary, detail: detail)
+ return CLSMessage(summary: messageType.summary, detail: message)
}
diff --git a/README.md b/README.md
index 5cfeeabf..74db8114 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,15 @@
[GitHub Copilot](https://github.com/features/copilot) for Xcode is the leading AI coding assistant for Swift, Objective-C and iOS/macOS development. It delivers intelligent Completions, Chat, and Code Review—plus advanced features like Agent Mode, Next Edit Suggestions, MCP Registry, and Copilot Vision to make Xcode development faster and smarter.
+> [!IMPORTANT]
+> Starting from version v0.50.0, we have added internal support for the upcoming usage-based billing experience, including experience updates to the usage panel, usage notifications, and model picker. These changes will become visible once usage-based billing is rolled out.
+>
+> To ensure compatibility with the new billing experience, we strongly recommend upgrading to the latest plugin version as soon as possible:
+>
+> * **GitHub Copilot for Xcode: v0.50.0 or later**
+>
+> Clients using older plugin versions will continue to function. However, the billing and usage experience may not be optimal and may not accurately reflect the latest usage-based billing experience.
+
## Chat
GitHub Copilot Chat provides suggestions to your specific coding tasks via chat.
diff --git a/ReleaseNotes.md b/ReleaseNotes.md
index 787aab78..3c922d4a 100644
--- a/ReleaseNotes.md
+++ b/ReleaseNotes.md
@@ -1,12 +1,13 @@
-### GitHub Copilot for Xcode 0.49.0
+### GitHub Copilot for Xcode 0.50.0
**🚀 Highlights**
-- **Native Anthropic Messages API**: Adds support for the native Anthropic Messages API (`/v1/messages`) endpoint, enabling direct integration with Anthropic-compatible providers.
-- **Thinking in Chat**: Reasoning-capable models can now show their thinking process in chat for greater transparency.
-- **Better Rate Limit Feedback**: Enhanced notifications and error messages help you understand and respond to rate limit events.
+- **Reasoning Effort**: Control how deeply reasoning-capable models think before responding. You can now select reasoning efforts directly from the model picker for supported models, letting you balance response speed against answer quality.
+
+- **Bring Your Own Key (BYOK) is now Generally Available**: Bring Your Own Key support has graduated from preview and is now available to all users. Configure your own API keys for third-party models directly in Copilot for Xcode settings.
**💪 Changes**
-- Refined the tool call item UI in agent progress for a cleaner, more readable layout.
-- Updated Copilot language server to 1.465.5.
+- Added internal support for upcoming [usage-based billing](https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/), including billing updates for the usage panel, usage notifications, and model picker. This will be visible once usage-based billing rolls out.
+
+ We **strongly recommend** upgrading to this version as soon as possible.
diff --git a/Server/package-lock.json b/Server/package-lock.json
index 72d6707e..5a375b85 100644
--- a/Server/package-lock.json
+++ b/Server/package-lock.json
@@ -8,9 +8,9 @@
"name": "@github/copilot-xcode",
"version": "0.0.1",
"dependencies": {
- "@github/copilot-language-server": "1.465.5",
- "@github/copilot-language-server-darwin-arm64": "1.465.5",
- "@github/copilot-language-server-darwin-x64": "1.465.5",
+ "@github/copilot-language-server": "1.523.3",
+ "@github/copilot-language-server-darwin-arm64": "1.523.3",
+ "@github/copilot-language-server-darwin-x64": "1.523.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"monaco-editor": "0.52.2"
@@ -38,9 +38,9 @@
}
},
"node_modules/@github/copilot-language-server": {
- "version": "1.465.5",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.465.5.tgz",
- "integrity": "sha512-dT0/MWx9wfImhQzNgVMIwWW81MkJBd0HtctYtmg2/nITyDsaSSjQLkYryzcsMMBx98JYwnyldQQc1QFx7q6mJw==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.523.3.tgz",
+ "integrity": "sha512-beEj0Mvw6hZSqYyj+7vvtkZ8s9G8IHI4D95HhCHYAk68LGhMBm2lri0WQ9if7NQPZa0fhEkNhlF6SEFTgqiPFw==",
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "^3.17.5"
@@ -49,18 +49,18 @@
"copilot-language-server": "dist/language-server.js"
},
"optionalDependencies": {
- "@github/copilot-language-server-darwin-arm64": "1.465.5",
- "@github/copilot-language-server-darwin-x64": "1.465.5",
- "@github/copilot-language-server-linux-arm64": "1.465.5",
- "@github/copilot-language-server-linux-x64": "1.465.5",
- "@github/copilot-language-server-win32-arm64": "1.465.5",
- "@github/copilot-language-server-win32-x64": "1.465.5"
+ "@github/copilot-language-server-darwin-arm64": "1.523.3",
+ "@github/copilot-language-server-darwin-x64": "1.523.3",
+ "@github/copilot-language-server-linux-arm64": "1.523.3",
+ "@github/copilot-language-server-linux-x64": "1.523.3",
+ "@github/copilot-language-server-win32-arm64": "1.523.3",
+ "@github/copilot-language-server-win32-x64": "1.523.3"
}
},
"node_modules/@github/copilot-language-server-darwin-arm64": {
- "version": "1.465.5",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.465.5.tgz",
- "integrity": "sha512-yi4QvjsLDw4NiKnnJghYuGD00veI+4+6mtp6S0E3OWodWk3jdTPmmSOrgp049f2Dok5fb+Xg28Zau5XZy3FU/A==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.523.3.tgz",
+ "integrity": "sha512-RllQkKiU4Tw3ag9sv1KNE3s97ZYjvbPjnMr0D7RCINq875/rHfdCEY+H1q42YgafZF+gGilHIW12HpA2iW6NUQ==",
"cpu": [
"arm64"
],
@@ -70,9 +70,9 @@
]
},
"node_modules/@github/copilot-language-server-darwin-x64": {
- "version": "1.465.5",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.465.5.tgz",
- "integrity": "sha512-SsYnfyTb7/8INZfxGnfVsOTVuzaWeTz02HMxzKPdk/VAdDF5bTlX5Fy2wW3vTJuGSAWDDz0yCTj9pEsP3hU4CQ==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.523.3.tgz",
+ "integrity": "sha512-c1L9Auej3jlIR2CilLsXchIEFleS158v5HtrRdyzXytPwQsrG+Wvf7e7vC5L8jTTLhAitCm9o/VqeaUAs2FGMQ==",
"cpu": [
"x64"
],
@@ -82,9 +82,9 @@
]
},
"node_modules/@github/copilot-language-server-linux-arm64": {
- "version": "1.465.5",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.465.5.tgz",
- "integrity": "sha512-qTVHFJrrlqm5dDUDanaot1KFh4aI0bRxrhYRss+/0aXPV+FhlfurxlQ9y44bWo3Bj3mBMs2Xn6c5F1BxcAFeXw==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.523.3.tgz",
+ "integrity": "sha512-JSWwX8CJ1mGBTfcLS+vB4q7DUCVDqi2AzFPYGGBHXYnRGa77X75gDdai1/kIO5OO12S2FqyjIPr7j9wY4Zas5w==",
"cpu": [
"arm64"
],
@@ -95,9 +95,9 @@
]
},
"node_modules/@github/copilot-language-server-linux-x64": {
- "version": "1.465.5",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.465.5.tgz",
- "integrity": "sha512-aAJB5uYLwOdvbXSKLkroDFLaL3WBE/zwR2GVLdwL3rvW7/oR/u5GkzfLzYDSH8+8d4qJpNQWQN+71EbomHd24w==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.523.3.tgz",
+ "integrity": "sha512-/NVKbSkdsTt11i2CYWcgBZAVTYHk0bCrb9tmwbBDRGPeZd+qyiWWv7roVOh6Ocu9ofX/BDCTUW8nZ2hOt3MKvQ==",
"cpu": [
"x64"
],
@@ -108,9 +108,9 @@
]
},
"node_modules/@github/copilot-language-server-win32-arm64": {
- "version": "1.465.5",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.465.5.tgz",
- "integrity": "sha512-jLPMguKEWg8qFB+6acEGyBnPNOza5m6Pws4OZnuFmj4aazrt6Bw40M8KhcQ9XCdp8phc4xt67SNOrCrITkOcIw==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.523.3.tgz",
+ "integrity": "sha512-o5GC4Gutkkw1Fz00h7Q2vB6/u93vjfYOjzvYJ1XaXGUTczUeFdgiwn6b7+snp4Gpy5ad20Sqk03Zd/KeOVz8KQ==",
"cpu": [
"arm64"
],
@@ -121,9 +121,9 @@
]
},
"node_modules/@github/copilot-language-server-win32-x64": {
- "version": "1.465.5",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.465.5.tgz",
- "integrity": "sha512-kVBnzO24HAaQZflC/lGG9bWJ3c4aJPLQFBHQUkXPWE+s0SjPX4T+cdiH2ZAPGn90m2PxhJ9EsknHKIJHALFOrA==",
+ "version": "1.523.3",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.523.3.tgz",
+ "integrity": "sha512-q+B6QzV/fzeqIKwqwj1yoBnHKgcnMsOrrcx0JiCPTB29iN2AMcvjdR1Er933VRF1UYyr/NZSEgU25xQQGDyV8A==",
"cpu": [
"x64"
],
@@ -924,9 +924,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.0.6",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
- "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"dev": true,
"funding": [
{
diff --git a/Server/package.json b/Server/package.json
index 03e3d6e9..bd713687 100644
--- a/Server/package.json
+++ b/Server/package.json
@@ -7,9 +7,9 @@
"build": "webpack"
},
"dependencies": {
- "@github/copilot-language-server": "1.465.5",
- "@github/copilot-language-server-darwin-arm64": "1.465.5",
- "@github/copilot-language-server-darwin-x64": "1.465.5",
+ "@github/copilot-language-server": "1.523.3",
+ "@github/copilot-language-server-darwin-arm64": "1.523.3",
+ "@github/copilot-language-server-darwin-x64": "1.523.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"monaco-editor": "0.52.2"
diff --git a/Tool/Package.swift b/Tool/Package.swift
index cf45e947..2b3c4b14 100644
--- a/Tool/Package.swift
+++ b/Tool/Package.swift
@@ -203,6 +203,7 @@ let package = Package(
"Highlightr",
"Preferences",
"SuggestionBasic",
+ "Status",
"DebounceFunction",
"ConversationServiceProvider",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
@@ -242,7 +243,7 @@ let package = Package(
]
),
- .target(name: "StatusBarItemView", dependencies: ["Cache"]),
+ .target(name: "StatusBarItemView", dependencies: ["Cache", "Status"]),
.target(
name: "Cache"
diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
index bdfe7672..5fd4bc26 100644
--- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
+++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
@@ -159,7 +159,9 @@ extension ChatMessage {
// merge modelName and billingMultiplier
self.modelName = message.modelName ?? self.modelName
+ self.modelProviderName = message.modelProviderName ?? self.modelProviderName
self.billingMultiplier = message.billingMultiplier ?? self.billingMultiplier
+ self.reasoningEffort = message.reasoningEffort ?? self.reasoningEffort
}
private func mergeEditAgentRounds(oldRounds: [AgentRound], newRounds: [AgentRound]) -> [AgentRound] {
diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift
index 0ee0fd1d..868a13b3 100644
--- a/Tool/Sources/ChatAPIService/Models.swift
+++ b/Tool/Sources/ChatAPIService/Models.swift
@@ -180,7 +180,9 @@ public struct ChatMessage: Equatable, Codable {
// The model name used for the turn.
public var modelName: String?
+ public var modelProviderName: String?
public var billingMultiplier: Float?
+ public var reasoningEffort: String?
/// The timestamp of the message.
public var createdAt: Date
@@ -208,7 +210,9 @@ public struct ChatMessage: Equatable, Codable {
turnStatus: TurnStatus? = nil,
requestType: RequestType = .conversation,
modelName: String? = nil,
+ modelProviderName: String? = nil,
billingMultiplier: Float? = nil,
+ reasoningEffort: String? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil
) {
@@ -233,7 +237,9 @@ public struct ChatMessage: Equatable, Codable {
self.turnStatus = turnStatus
self.requestType = requestType
self.modelName = modelName
+ self.modelProviderName = modelProviderName
self.billingMultiplier = billingMultiplier
+ self.reasoningEffort = reasoningEffort
let now = Date.now
self.createdAt = createdAt ?? now
@@ -275,7 +281,9 @@ public struct ChatMessage: Equatable, Codable {
turnStatus: TurnStatus? = nil,
requestType: RequestType = .conversation,
modelName: String? = nil,
- billingMultiplier: Float? = nil
+ modelProviderName: String? = nil,
+ billingMultiplier: Float? = nil,
+ reasoningEffort: String? = nil
) {
self.init(
id: id,
@@ -295,7 +303,9 @@ public struct ChatMessage: Equatable, Codable {
turnStatus: turnStatus,
requestType: requestType,
modelName: modelName,
- billingMultiplier: billingMultiplier
+ modelProviderName: modelProviderName,
+ billingMultiplier: billingMultiplier,
+ reasoningEffort: reasoningEffort
)
}
diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
index 0094483e..aaa13917 100644
--- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
+++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
@@ -346,6 +346,7 @@ public struct ConversationRequest {
public var references: [ConversationAttachedReference]?
public var model: String?
public var modelProviderName: String?
+ public var reasoningEffort: String?
public var turns: [TurnSchema]
public var agentMode: Bool = false
public var customChatModeId: String? = nil
@@ -363,6 +364,7 @@ public struct ConversationRequest {
references: [ConversationAttachedReference]? = nil,
model: String? = nil,
modelProviderName: String? = nil,
+ reasoningEffort: String? = nil,
turns: [TurnSchema] = [],
agentMode: Bool = false,
customChatModeId: String? = nil,
@@ -379,6 +381,7 @@ public struct ConversationRequest {
self.references = references
self.model = model
self.modelProviderName = modelProviderName
+ self.reasoningEffort = reasoningEffort
self.turns = turns
self.agentMode = agentMode
self.customChatModeId = customChatModeId
diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift
index cd150aa6..88b86933 100644
--- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift
+++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift
@@ -40,6 +40,7 @@ public struct CopilotModel: Codable, Equatable {
public let modelFamily: String
public let modelName: String
public let id: String
+ public let vendor: String?
public let modelPolicy: CopilotModelPolicy?
public let scopes: [PromptTemplateScope]
public let preview: Bool
@@ -48,6 +49,8 @@ public struct CopilotModel: Codable, Equatable {
public let capabilities: CopilotModelCapabilities
public let billing: CopilotModelBilling?
public let degradationReason: String?
+ public let modelPickerCategory: String?
+ public let modelPickerPriceCategory: String?
}
public struct CopilotModelPolicy: Codable, Equatable {
@@ -57,22 +60,43 @@ public struct CopilotModelPolicy: Codable, Equatable {
public struct CopilotModelCapabilities: Codable, Equatable {
public let supports: CopilotModelCapabilitiesSupports
+ public let limits: CopilotModelCapabilitiesLimits?
+}
+
+public struct CopilotModelCapabilitiesLimits: Codable, Equatable {
+ public let maxContextWindowTokens: Int?
+ public let maxOutputTokens: Int?
+ public let maxInputTokens: Int?
+ public let maxNonStreamingOutputTokens: Int?
}
public struct CopilotModelCapabilitiesSupports: Codable, Equatable {
public let vision: Bool
+ public let reasoningEfforts: [String]?
+ public let supportsReasoningEffortLevel: Bool?
}
public struct CopilotModelBilling: Codable, Equatable, Hashable {
public let isPremium: Bool
public let multiplier: Float
-
- public init(isPremium: Bool, multiplier: Float) {
+ public let tokenBasedBillingEnabled: Bool?
+ public let tokenPrices: CopilotModelBillingTokenPrices?
+
+ public init(isPremium: Bool, multiplier: Float, tokenBasedBillingEnabled: Bool? = nil, tokenPrices: CopilotModelBillingTokenPrices? = nil) {
self.isPremium = isPremium
self.multiplier = multiplier
+ self.tokenBasedBillingEnabled = tokenBasedBillingEnabled
+ self.tokenPrices = tokenPrices
}
}
+public struct CopilotModelBillingTokenPrices: Codable, Equatable, Hashable {
+ public let cachePrice: Float?
+ public let inputPrice: Float?
+ public let outputPrice: Float?
+ public let tokenUnit: Int?
+}
+
// MARK: ChatModes
public enum ChatMode: String, Codable {
case Ask = "Ask"
@@ -687,6 +711,18 @@ public enum Reference: Codable, Equatable, Hashable {
}
}
+public struct ConversationModelInfo: Codable {
+ public let id: String?
+ public let providerName: String?
+ public let reasoningEffort: String?
+
+ public init(id: String?, providerName: String?, reasoningEffort: String?) {
+ self.id = id
+ self.providerName = providerName
+ self.reasoningEffort = reasoningEffort
+ }
+}
+
public struct ConversationCreateResponse: Codable {
public let conversationId: String
public let turnId: String
@@ -694,6 +730,7 @@ public struct ConversationCreateResponse: Codable {
public let modelName: String?
public let modelProviderName: String?
public let billingMultiplier: Float?
+ public let modelInfo: ConversationModelInfo?
}
public struct ConversationCreateParams: Codable {
@@ -709,11 +746,12 @@ public struct ConversationCreateParams: Codable {
public var ignoredSkills: [String]?
public var model: String?
public var modelProviderName: String?
+ public var modelInfo: ConversationModelInfo?
public var chatMode: String?
public var customChatModeId: String?
public var needToolCallConfirmation: Bool?
public var userLanguage: String?
-
+
public struct Capabilities: Codable {
public var skills: [String]
public var allSkills: Bool?
@@ -737,6 +775,7 @@ public struct ConversationCreateParams: Codable {
ignoredSkills: [String]? = nil,
model: String? = nil,
modelProviderName: String? = nil,
+ modelInfo: ConversationModelInfo? = nil,
chatMode: String? = nil,
customChatModeId: String? = nil,
needToolCallConfirmation: Bool? = nil,
@@ -754,6 +793,7 @@ public struct ConversationCreateParams: Codable {
self.ignoredSkills = ignoredSkills
self.model = model
self.modelProviderName = modelProviderName
+ self.modelInfo = modelInfo
self.chatMode = chatMode
self.customChatModeId = customChatModeId
self.needToolCallConfirmation = needToolCallConfirmation
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift
index 1168d954..ba260e60 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/BYOKModelManager.swift
@@ -8,7 +8,9 @@ public class BYOKModelManager {
let sortedModels = BYOKModels.sorted()
guard sortedModels != availableBYOKModels else { return }
availableBYOKModels = sortedModels
- NotificationCenter.default.post(name: .gitHubCopilotModelsDidChange, object: nil)
+ DispatchQueue.main.async {
+ NotificationCenter.default.post(name: .gitHubCopilotModelsDidChange, object: nil)
+ }
}
public static func hasBYOKModels(providerName: BYOKProviderName? = nil) -> Bool {
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
index ad691ce7..b28139ca 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
@@ -234,6 +234,12 @@ class CopilotLocalProcessServer {
case "$/copilot/rateLimitWarning":
notificationPublisher.send(anyNotification)
return true
+ case "copilot/quotaChange":
+ notificationPublisher.send(anyNotification)
+ return true
+ case "copilot/quotaWarning":
+ notificationPublisher.send(anyNotification)
+ return true
case "conversation/preconditionsNotification", "statusNotification":
// Ignore
return true
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift
index 72fbbc00..b2906139 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequestTypes/Conversation.swift
@@ -107,6 +107,7 @@ struct TurnCreateParams: Codable {
var references: [Reference]?
var model: String?
var modelProviderName: String?
+ var modelInfo: ConversationModelInfo?
var workspaceFolder: String?
var workspaceFolders: [WorkspaceFolder]?
var chatMode: String?
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
index ef45792c..b1f02fcc 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
@@ -75,6 +75,7 @@ public protocol GitHubCopilotConversationServiceType {
references: [ConversationAttachedReference],
model: String?,
modelProviderName: String?,
+ reasoningEffort: String?,
turns: [TurnSchema],
agentMode: Bool,
customChatModeId: String?,
@@ -88,6 +89,7 @@ public protocol GitHubCopilotConversationServiceType {
references: [ConversationAttachedReference],
model: String?,
modelProviderName: String?,
+ reasoningEffort: String?,
workspaceFolder: String,
workspaceFolders: [WorkspaceFolder]?,
agentMode: Bool,
@@ -654,6 +656,7 @@ public final class GitHubCopilotService:
references: [ConversationAttachedReference],
model: String?,
modelProviderName: String?,
+ reasoningEffort: String?,
turns: [TurnSchema],
agentMode: Bool,
customChatModeId: String?,
@@ -687,6 +690,13 @@ public final class GitHubCopilotService:
ignoredSkills: ignoredSkills,
model: model,
modelProviderName: modelProviderName,
+ modelInfo: (model != nil || reasoningEffort != nil)
+ ? ConversationModelInfo(
+ id: model,
+ providerName: modelProviderName,
+ reasoningEffort: reasoningEffort
+ )
+ : nil,
chatMode: agentMode ? "Agent" : nil,
customChatModeId: customChatModeId,
needToolCallConfirmation: true,
@@ -711,13 +721,14 @@ public final class GitHubCopilotService:
references: [ConversationAttachedReference],
model: String?,
modelProviderName: String?,
+ reasoningEffort: String?,
workspaceFolder: String,
workspaceFolders: [WorkspaceFolder]? = nil,
agentMode: Bool,
customChatModeId: String?
) async throws -> ConversationCreateResponse {
do {
- let params = TurnCreateParams(workDoneToken: workDoneToken,
+ var params = TurnCreateParams(workDoneToken: workDoneToken,
conversationId: conversationId,
turnId: turnId,
message: message,
@@ -731,6 +742,9 @@ public final class GitHubCopilotService:
chatMode: agentMode ? "Agent" : nil,
customChatModeId: customChatModeId,
needToolCallConfirmation: true)
+ if model != nil || reasoningEffort != nil {
+ params.modelInfo = ConversationModelInfo(id: model, providerName: modelProviderName, reasoningEffort: reasoningEffort)
+ }
return try await sendRequest(
GitHubCopilotRequest.CreateTurn(params: params))
} catch {
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift
index bbc7c4c6..2bacb348 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift
@@ -16,6 +16,7 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler {
var copilotPolicyNotifier: CopilotPolicyNotifier = CopilotPolicyNotifierImpl.shared
var compressionHandler: CompressionHandler = CompressionHandlerImpl.shared
var rateLimitNotifier: RateLimitNotifier = RateLimitNotifierImpl.shared
+ var quotaNotifier: QuotaNotifier = QuotaNotifierImpl.shared
init() {
self.protocolProgressSubject = PassthroughSubject()
@@ -77,6 +78,24 @@ class ServerNotificationHandlerImpl: ServerNotificationHandler {
rateLimitNotifier.handleRateLimitWarning(params)
}
break
+ case "copilot/quotaChange":
+ if let data = try? JSONEncoder().encode(notification.params),
+ let params = try? JSONDecoder().decode(
+ QuotaChangeParams.self,
+ from: data
+ ) {
+ quotaNotifier.handleQuotaChange(params)
+ }
+ break
+ case "copilot/quotaWarning":
+ if let data = try? JSONEncoder().encode(notification.params),
+ let params = try? JSONDecoder().decode(
+ QuotaWarningParams.self,
+ from: data
+ ) {
+ quotaNotifier.handleQuotaWarning(params)
+ }
+ break
default:
break
}
diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift
index fe08a348..23fd920f 100644
--- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift
+++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift
@@ -94,11 +94,10 @@ public class FeatureFlagNotifierImpl: FeatureFlagNotifier {
}
private func updateFeatureFlags() {
- let xcodeChat = self.didChangeFeatureFlagsParams.envelope["xcode_chat"]?.boolValue != false
let chatEnabled = self.didChangeFeatureFlagsParams.envelope["chat_enabled"]?.boolValue != false
self.featureFlags.restrictedTelemetry = self.didChangeFeatureFlagsParams.token["rt"] != "0"
self.featureFlags.snippy = self.didChangeFeatureFlagsParams.token["sn"] != "0"
- self.featureFlags.chat = xcodeChat && chatEnabled
+ self.featureFlags.chat = chatEnabled
self.featureFlags.inlineChat = chatEnabled
self.featureFlags.agentMode = self.didChangeFeatureFlagsParams.token["agent_mode"] != "0"
self.featureFlags.mcp = self.didChangeFeatureFlagsParams.token["mcp"] != "0"
diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift
index 62f12e6f..0038a1e1 100644
--- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift
+++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift
@@ -57,6 +57,7 @@ public final class GitHubCopilotConversationService: ConversationServiceType {
references: request.references ?? [],
model: request.model,
modelProviderName: request.modelProviderName,
+ reasoningEffort: request.reasoningEffort,
turns: request.turns,
agentMode: request.agentMode,
customChatModeId: request.customChatModeId,
@@ -79,6 +80,7 @@ public final class GitHubCopilotConversationService: ConversationServiceType {
references: request.references ?? [],
model: request.model,
modelProviderName: request.modelProviderName,
+ reasoningEffort: request.reasoningEffort,
workspaceFolder: workspace.projectURL.absoluteString,
workspaceFolders: getWorkspaceFolders(workspace: workspace),
agentMode: request.agentMode,
diff --git a/Tool/Sources/GitHubCopilotService/Services/QuotaNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/QuotaNotifier.swift
new file mode 100644
index 00000000..a526e979
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/Services/QuotaNotifier.swift
@@ -0,0 +1,257 @@
+import AppKit
+import Combine
+import Foundation
+import Logger
+import NotificationCenterCoordinator
+import Status
+import UserNotifications
+
+public struct QuotaSnapshotNotificationParams: Hashable, Codable {
+ public var quota: Int
+ public var used: Int
+ public var percentRemaining: Double
+ public var overageUsed: Int
+ public var overageEnabled: Bool
+ public var resetDate: String
+ public var unlimited: Bool
+}
+
+public struct QuotaChangeParams: Codable {
+ public var chat: QuotaSnapshotNotificationParams?
+ public var completions: QuotaSnapshotNotificationParams?
+ public var premiumInteractions: QuotaSnapshotNotificationParams?
+ public var copilotPlan: String?
+ public var canUpgradePlan: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case chat
+ case completions
+ case premiumInteractions = "premium_interactions"
+ case copilotPlan
+ case canUpgradePlan
+ }
+}
+
+public struct QuotaWarningParams: Hashable, Codable {
+ public var title: String
+ public var message: String
+ public var severity: String // "warning" or "info"
+ public var chat: QuotaSnapshotNotificationParams?
+ public var completions: QuotaSnapshotNotificationParams?
+ public var premiumInteractions: QuotaSnapshotNotificationParams?
+ public var copilotPlan: String?
+ public var canUpgradePlan: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case title
+ case message
+ case severity
+ case chat
+ case completions
+ case premiumInteractions = "premium_interactions"
+ case copilotPlan
+ case canUpgradePlan
+ }
+}
+
+public protocol QuotaNotifier {
+ func handleQuotaChange(_ params: QuotaChangeParams)
+ func handleQuotaWarning(_ params: QuotaWarningParams)
+}
+
+public class QuotaNotifierImpl: NSObject, QuotaNotifier {
+ public static let shared = QuotaNotifierImpl()
+
+ private static let enableUsageActionIdentifier = "quotaEnableUsageAction"
+ private static let increaseBudgetActionIdentifier = "quotaIncreaseBudgetAction"
+ private static let upgradeActionIdentifier = "quotaUpgradeAction"
+ private static let categoryNone = "quotaWarning_none"
+ private static let categoryUpgrade = "quotaWarning_upgrade"
+ private static let categoryEnableUsage = "quotaWarning_enableUsage"
+ private static let categoryEnableUsageUpgrade = "quotaWarning_enableUsage_upgrade"
+ private static let categoryIncreaseBudget = "quotaWarning_increaseBudget"
+ private static let categoryIncreaseBudgetUpgrade = "quotaWarning_increaseBudget_upgrade"
+
+ private var areCategoriesRegistered = false
+
+ private override init() {
+ super.init()
+ }
+
+ private func registerCategoriesIfNeeded() {
+ guard !areCategoriesRegistered else { return }
+ areCategoriesRegistered = true
+
+ let enableUsageAction = UNNotificationAction(
+ identifier: Self.enableUsageActionIdentifier,
+ title: "Enable additional usage",
+ options: [.foreground]
+ )
+ let increaseBudgetAction = UNNotificationAction(
+ identifier: Self.increaseBudgetActionIdentifier,
+ title: "Increase budget",
+ options: [.foreground]
+ )
+ let upgradeAction = UNNotificationAction(
+ identifier: Self.upgradeActionIdentifier,
+ title: "Upgrade Plan",
+ options: [.foreground]
+ )
+
+ let handler: (UNNotificationResponse) -> Void = { response in
+ switch response.actionIdentifier {
+ case Self.enableUsageActionIdentifier, Self.increaseBudgetActionIdentifier:
+ NSWorkspace.shared.open(URL(string: QuotaFormatting.manageOverageURL)!)
+ case Self.upgradeActionIdentifier:
+ NSWorkspace.shared.open(URL(string: QuotaFormatting.upgradePlanURL)!)
+ default:
+ break
+ }
+ }
+
+ let definitions: [(String, [UNNotificationAction])] = [
+ (Self.categoryNone, []),
+ (Self.categoryUpgrade, [upgradeAction]),
+ (Self.categoryEnableUsage, [enableUsageAction]),
+ (Self.categoryEnableUsageUpgrade, [enableUsageAction, upgradeAction]),
+ (Self.categoryIncreaseBudget, [increaseBudgetAction]),
+ (Self.categoryIncreaseBudgetUpgrade, [increaseBudgetAction, upgradeAction]),
+ ]
+ for (id, actions) in definitions {
+ let category = UNNotificationCategory(
+ identifier: id,
+ actions: actions,
+ intentIdentifiers: [],
+ options: []
+ )
+ NotificationCenterCoordinator.shared.register(
+ category: category,
+ handler: handler,
+ for: id
+ )
+ }
+ }
+
+ private func notificationCategoryID(for actions: [WarningAction]) -> String {
+ let manageURL = QuotaFormatting.manageOverageURL
+ let upgradeURL = QuotaFormatting.upgradePlanURL
+ let manageAction = actions.first { $0.url.absoluteString == manageURL }
+ let hasUpgrade = actions.contains { $0.url.absoluteString == upgradeURL }
+ switch (manageAction?.title, hasUpgrade) {
+ case (nil, false): return Self.categoryNone
+ case (nil, true): return Self.categoryUpgrade
+ case ("Increase budget", false): return Self.categoryIncreaseBudget
+ case ("Increase budget", true): return Self.categoryIncreaseBudgetUpgrade
+ case (_, false): return Self.categoryEnableUsage
+ case (_, true): return Self.categoryEnableUsageUpgrade
+ }
+ }
+
+ public func handleQuotaChange(_ params: QuotaChangeParams) {
+ Task {
+ guard var quotaInfo = await Status.shared.getQuotaInfo() else { return }
+ if let chat = params.chat {
+ quotaInfo.chat = QuotaSnapshot(from: chat)
+ }
+ if let completions = params.completions {
+ quotaInfo.completions = QuotaSnapshot(from: completions)
+ }
+ if let premium = params.premiumInteractions {
+ quotaInfo.premiumInteractions = QuotaSnapshot(from: premium)
+ }
+ if let plan = params.copilotPlan {
+ quotaInfo.copilotPlan = plan
+ }
+ if let canUpgradePlan = params.canUpgradePlan {
+ quotaInfo.canUpgradePlan = canUpgradePlan
+ }
+ let resetDate = params.chat?.resetDate
+ ?? params.completions?.resetDate
+ ?? params.premiumInteractions?.resetDate
+ if let date = resetDate {
+ quotaInfo.resetDate = date
+ }
+ await Status.shared.updateQuotaInfo(quotaInfo)
+ }
+ }
+
+ public func handleQuotaWarning(_ params: QuotaWarningParams) {
+ Task { @MainActor in
+ let quotaInfo = await Status.shared.getQuotaInfo()
+ let actions = buildWarningActions(params: params, quotaInfo: quotaInfo)
+ let isCompletionsWarning = params.message.localizedCaseInsensitiveContains("completions")
+ if !isCompletionsWarning {
+ WarningStateManager.shared.setWarning(WarningContent(
+ message: params.message,
+ severity: params.severity,
+ actions: actions
+ ))
+ }
+ await NotificationCenterCoordinator.shared.setupIfNeeded()
+ self.registerCategoriesIfNeeded()
+ await sendAppleNotification(params, categoryID: notificationCategoryID(for: actions))
+ }
+ }
+
+ private func buildWarningActions(
+ params: QuotaWarningParams,
+ quotaInfo: GitHubCopilotQuotaInfo?
+ ) -> [WarningAction] {
+ let overageEnabled = params.premiumInteractions?.overageEnabled
+ ?? quotaInfo?.premiumInteractions?.overagePermitted
+ ?? false
+ let canUpgrade = params.canUpgradePlan ?? quotaInfo?.isUpgradePlanAllowed ?? true
+
+ let overageAction = WarningAction(
+ title: overageEnabled ? "Increase budget" : "Enable additional usage",
+ url: URL(string: QuotaFormatting.manageOverageURL)!
+ )
+ let upgradeAction = WarningAction(
+ title: "Upgrade plan",
+ url: URL(string: QuotaFormatting.upgradePlanURL)!
+ )
+
+ var actions: [WarningAction] = []
+ if quotaInfo?.isPaidIndividual ?? false {
+ actions.append(overageAction)
+ }
+ if canUpgrade {
+ actions.append(upgradeAction)
+ }
+ return actions
+ }
+
+ @MainActor
+ private func sendAppleNotification(_ params: QuotaWarningParams, categoryID: String) async {
+ let content = UNMutableNotificationContent()
+ content.title = params.title
+ content.body = params.message
+ content.sound = .default
+ content.categoryIdentifier = categoryID
+
+ let request = UNNotificationRequest(
+ identifier: "quotaWarning",
+ content: content,
+ trigger: nil
+ )
+
+ do {
+ try await UNUserNotificationCenter.current().add(request)
+ } catch {
+ Logger.gitHubCopilot.error("Failed to show quota warning notification: \(error)")
+ }
+ }
+}
+
+private extension QuotaSnapshot {
+ init(from params: QuotaSnapshotNotificationParams) {
+ self.init(
+ percentRemaining: Float(params.percentRemaining),
+ unlimited: params.unlimited,
+ overagePermitted: params.overageEnabled,
+ overageCount: Float(params.overageUsed),
+ entitlement: Double(params.quota),
+ quotaRemaining: Double(params.quota - params.used)
+ )
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/Services/RateLimitNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/RateLimitNotifier.swift
index 30bb3f25..d8aa5561 100644
--- a/Tool/Sources/GitHubCopilotService/Services/RateLimitNotifier.swift
+++ b/Tool/Sources/GitHubCopilotService/Services/RateLimitNotifier.swift
@@ -21,11 +21,9 @@ public protocol RateLimitNotifier {
func handleRateLimitWarning(_ params: RateLimitWarningParams)
}
-public class RateLimitNotifierImpl: NSObject, RateLimitNotifier, ObservableObject {
+public class RateLimitNotifierImpl: NSObject, RateLimitNotifier {
public static let shared = RateLimitNotifierImpl()
- @Published public var currentWarning: RateLimitWarningParams?
-
private static let categoryIdentifier = "rateLimitWarningCategory"
private static let learnMoreActionIdentifier = "rateLimitLearnMoreAction"
private static let learnMoreURL = URL(
@@ -66,9 +64,11 @@ public class RateLimitNotifierImpl: NSObject, RateLimitNotifier, ObservableObjec
}
public func handleRateLimitWarning(_ params: RateLimitWarningParams) {
- DispatchQueue.main.async { [weak self] in
- self?.currentWarning = params
- }
+ WarningStateManager.shared.setWarning(WarningContent(
+ message: params.message,
+ severity: "warning",
+ actions: [WarningAction(title: "Learn more", url: Self.learnMoreURL)]
+ ))
Task { @MainActor in
await NotificationCenterCoordinator.shared.setupIfNeeded()
@@ -77,12 +77,6 @@ public class RateLimitNotifierImpl: NSObject, RateLimitNotifier, ObservableObjec
}
}
- public func dismissWarning() {
- DispatchQueue.main.async { [weak self] in
- self?.currentWarning = nil
- }
- }
-
@MainActor
private func sendAppleNotification(_ params: RateLimitWarningParams) async {
let content = UNMutableNotificationContent()
@@ -92,7 +86,7 @@ public class RateLimitNotifierImpl: NSObject, RateLimitNotifier, ObservableObjec
content.categoryIdentifier = Self.categoryIdentifier
let request = UNNotificationRequest(
- identifier: "rateLimitWarning-\(UUID().uuidString)",
+ identifier: "rateLimitWarning",
content: content,
trigger: nil
)
diff --git a/Tool/Sources/GitHubCopilotService/Services/WarningState.swift b/Tool/Sources/GitHubCopilotService/Services/WarningState.swift
new file mode 100644
index 00000000..ef5577e3
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/Services/WarningState.swift
@@ -0,0 +1,53 @@
+import Foundation
+import Status
+
+public struct WarningAction: Equatable {
+ public var title: String
+ public var url: URL
+
+ public init(title: String, url: URL) {
+ self.title = title
+ self.url = url
+ }
+}
+
+public struct WarningContent: Equatable {
+ public var message: String
+ public var severity: String // "warning" or "info"
+ public var actions: [WarningAction] // 0-2 CTAs
+
+ public init(message: String, severity: String, actions: [WarningAction] = []) {
+ self.message = message
+ self.severity = severity
+ self.actions = actions
+ }
+}
+
+public class WarningStateManager: ObservableObject {
+ public static let shared = WarningStateManager()
+
+ @Published public var currentWarning: WarningContent?
+
+ private init() {
+ DistributedNotificationCenter.default().addObserver(
+ forName: .authStatusDidChange,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ self?.dismissWarning()
+ }
+ }
+
+ public func setWarning(_ warning: WarningContent) {
+ DispatchQueue.main.async { [weak self] in
+ guard self?.currentWarning != warning else { return }
+ self?.currentWarning = warning
+ }
+ }
+
+ public func dismissWarning() {
+ DispatchQueue.main.async { [weak self] in
+ self?.currentWarning = nil
+ }
+ }
+}
diff --git a/Tool/Sources/SharedUIComponents/QuotaPopoverView.swift b/Tool/Sources/SharedUIComponents/QuotaPopoverView.swift
new file mode 100644
index 00000000..0157da2b
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/QuotaPopoverView.swift
@@ -0,0 +1,332 @@
+import SwiftUI
+import Status
+
+// MARK: - Quota Popover View
+
+public struct QuotaPopoverView: View {
+ let quotaInfo: GitHubCopilotQuotaInfo?
+
+ public init(quotaInfo: GitHubCopilotQuotaInfo?) {
+ self.quotaInfo = quotaInfo
+ }
+
+ private var isUnlimited: Bool {
+ quotaInfo?.isCBCEUnlimited == true
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if let quotaInfo = quotaInfo {
+ // Plan name
+ HStack {
+ Text(quotaInfo.planDisplayName)
+ .font(.system(size: 13, weight: .semibold))
+
+ Spacer()
+
+ Button(action: { openURL(QuotaFormatting.settingsURL) }) {
+ Image(systemName: "slider.horizontal.3")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ }
+ .buttonStyle(HoverButtonStyle())
+ .help("Open Copilot Settings")
+ .accessibilityLabel("Open Copilot Settings")
+ }
+
+ quotaContent(quotaInfo)
+ } else {
+ Text("No usage data available.")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding(isUnlimited
+ ? EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)
+ : EdgeInsets(top: 18, leading: 16, bottom: 18, trailing: 16))
+ .frame(
+ minWidth: isUnlimited ? 260 : 320,
+ idealWidth: isUnlimited ? 300 : 400,
+ maxWidth: .infinity
+ )
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ @ViewBuilder
+ private func quotaContent(_ info: GitHubCopilotQuotaInfo) -> some View {
+ if info.isCBCEUnlimited {
+ Text("You have no monthly limit on AI credits usage set by your organization.")
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ } else {
+ let items = buildQuotaItems(info)
+
+ ForEach(items, id: \.title) { item in
+ quotaRow(item)
+ }
+
+ if !info.isFreeUser {
+ let overagePermitted = info.overagePermitted
+ let isNonTBBPaid = !info.isTokenBasedBilling && !info.isCBCE
+ let overageLabel: String = isNonTBBPaid
+ ? (overagePermitted ? "Additional paid premium requests enabled." : "Additional paid premium requests disabled.")
+ : (overagePermitted ? "Additional usage enabled" : "Additional usage not enabled")
+ let overageTooltip: String = info.isCBCE
+ ? (overagePermitted
+ ? "Usage will continue until limits are reset."
+ : "Usage will pause if the monthly usage limit is reached. Request additional usage from your administrator.")
+ : "Pay-as-you-go usage of additional AI credits once you run out of your included usage. Set a budget to cap your maximum monthly spend."
+ HStack(spacing: 2) {
+ Text(overageLabel)
+ .scaledFont(size: 13)
+ .foregroundColor(overagePermitted ? .primary : .secondary)
+
+ if !isNonTBBPaid {
+ Image(systemName: "info.circle")
+ .scaledFont(size: 10)
+ .foregroundColor(.secondary)
+ .help(overageTooltip)
+ }
+ }
+ }
+
+ if !info.isCBCE, shouldShowQuotaButtons(info) {
+ quotaButtons(info)
+ }
+ }
+ }
+
+ private func shouldShowQuotaButtons(_ info: GitHubCopilotQuotaInfo) -> Bool {
+ let snapshots: [QuotaSnapshot] = [info.premiumInteractions, info.chat, info.completions].compactMap { $0 }
+ for snapshot in snapshots {
+ if snapshot.unlimited { continue }
+ if let used = snapshot.usedPercentage, used >= 75 {
+ return true
+ }
+ }
+ return false
+ }
+
+ @ViewBuilder
+ private func quotaButtons(_ info: GitHubCopilotQuotaInfo) -> some View {
+ let canUpgrade = info.isUpgradePlanAllowed
+ let hasOverage = info.isPaidIndividual
+ let overageTitle: String = info.isTokenBasedBilling
+ ? (info.overagePermitted ? "Increase Budget" : "Enable Additional Usage")
+ : "Manage Paid Premium Requests"
+ let upgradeTitle = (!info.isTokenBasedBilling && info.isFreeUser) ? "Upgrade to Pro" : "Upgrade Plan"
+
+ HStack(spacing: 8) {
+ if hasOverage {
+ actionButton(title: overageTitle, urlString: QuotaFormatting.manageOverageURL, prominent: true)
+ }
+ if canUpgrade {
+ actionButton(title: upgradeTitle, urlString: QuotaFormatting.upgradePlanURL, prominent: hasOverage ? false : true)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func actionButton(title: String, urlString: String, prominent: Bool) -> some View {
+ if prominent {
+ Button(action: { openURL(urlString) }) {
+ Text(title)
+ .scaledFont(size: 13, weight: .medium)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.regular)
+ } else {
+ Button(action: { openURL(urlString) }) {
+ Text(title)
+ .scaledFont(size: 13, weight: .medium)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.regular)
+ }
+ }
+
+ private func openURL(_ urlString: String) {
+ if let url = URL(string: urlString) {
+ NSWorkspace.shared.open(url)
+ }
+ }
+
+ @ViewBuilder
+ private func quotaRow(_ item: QuotaItem) -> some View {
+ VStack(alignment: .leading, spacing: item.tightResetSpacing ? 2 : 8) {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ Text(item.title)
+ .font(.system(size: 13))
+
+ if item.showInfoIcon {
+ Image(systemName: "info.circle")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ .help(item.tooltip)
+ }
+
+ Spacer()
+
+ if let parts = item.creditCountParts {
+ (Text(parts.used).foregroundColor(.primary)
+ + Text(parts.suffix).foregroundColor(.secondary))
+ .font(.system(size: 11))
+ } else {
+ Text(item.percentageText)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ }
+
+ if item.isUnlimited {
+ Text("You have no limit on AI credits usage")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ } else {
+ GeometryReader { geometry in
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(item.barColor.opacity(0.3))
+ .frame(height: 3)
+
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(item.barColor)
+ .frame(width: geometry.size.width * CGFloat(min(item.usedFraction, 1.0)), height: 3)
+ }
+ }
+ .frame(height: 3)
+ }
+ }
+
+ if let resetText = item.resetText {
+ Text(resetText)
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+
+ // MARK: - Data helpers
+
+ private struct QuotaItem {
+ let title: String
+ let percentageText: String
+ let creditCountParts: (used: String, suffix: String)?
+ let usedFraction: Float
+ let isUnlimited: Bool
+ let barColor: Color
+ let tooltip: String
+ let resetText: String?
+ let showInfoIcon: Bool
+ let tightResetSpacing: Bool
+ }
+
+ private func buildQuotaItems(_ info: GitHubCopilotQuotaInfo) -> [QuotaItem] {
+ var items: [QuotaItem] = []
+ let showInfoIcon = info.isTokenBasedBilling && !info.isFreeUser && !info.isCBCE
+ let tightResetSpacing = !info.isFreeUser
+ let creditsTooltip = "AI credits included with your plan, reset monthly. Enable additional usage to continue with pay-as-you-go credits once you run out of your included usage."
+
+ if info.isCBCE {
+ if let premium = info.premiumInteractions {
+ items.append(makeQuotaItem(
+ title: "Monthly Limit",
+ snapshot: premium,
+ tooltip: "",
+ resetAt: info.resetDateUtc ?? info.resetDate,
+ showInfoIcon: false,
+ tightResetSpacing: tightResetSpacing
+ ))
+ }
+ } else if info.isFreeUser {
+ let completionsTitle = info.isTokenBasedBilling ? "Inline Suggestions" : "Code Completions"
+ let chatTitle = info.isTokenBasedBilling ? "Included Credits" : "Chat Messages"
+ items.append(makeQuotaItem(title: completionsTitle, snapshot: info.completions, tooltip: "", resetAt: nil, showInfoIcon: showInfoIcon, tightResetSpacing: tightResetSpacing))
+ let chatResetAt = info.isTokenBasedBilling ? (info.resetDateUtc ?? info.resetDate) : nil
+ let chatTooltip = info.isTokenBasedBilling ? creditsTooltip : ""
+ items.append(makeQuotaItem(title: chatTitle, snapshot: info.chat, tooltip: chatTooltip, resetAt: chatResetAt, showInfoIcon: showInfoIcon, tightResetSpacing: tightResetSpacing))
+ } else if info.isTokenBasedBilling {
+ if let premium = info.premiumInteractions {
+ items.append(makeQuotaItem(
+ title: "Included Credits",
+ snapshot: premium,
+ tooltip: creditsTooltip,
+ resetAt: info.resetDateUtc ?? info.resetDate,
+ showInfoIcon: showInfoIcon,
+ tightResetSpacing: tightResetSpacing
+ ))
+ }
+ } else {
+ if let premium = info.premiumInteractions {
+ items.append(makeQuotaItem(title: "Premium Requests", snapshot: premium, tooltip: "", resetAt: nil, showInfoIcon: showInfoIcon, tightResetSpacing: tightResetSpacing))
+ }
+ if !info.completions.unlimited {
+ items.append(makeQuotaItem(title: "Code Completions", snapshot: info.completions, tooltip: "", resetAt: nil, showInfoIcon: showInfoIcon, tightResetSpacing: tightResetSpacing))
+ }
+ if !info.chat.unlimited {
+ items.append(makeQuotaItem(title: "Chat Messages", snapshot: info.chat, tooltip: "", resetAt: nil, showInfoIcon: showInfoIcon, tightResetSpacing: tightResetSpacing))
+ }
+ }
+
+ return items
+ }
+
+ private func makeQuotaItem(title: String, snapshot: QuotaSnapshot, tooltip: String, resetAt: String?, showInfoIcon: Bool, tightResetSpacing: Bool) -> QuotaItem {
+ let usedPercentage = snapshot.usedPercentage
+ let showAsCreditCount = showInfoIcon
+ let percentageText: String
+ var creditCountParts: (used: String, suffix: String)? = nil
+ if snapshot.unlimited {
+ percentageText = "Included"
+ } else if showAsCreditCount,
+ let entitlement = snapshot.entitlement,
+ let remaining = snapshot.quotaRemaining {
+ let used = max(0, entitlement - remaining)
+ let usedStr = Int(used).formatted()
+ let totalStr = Int(entitlement).formatted()
+ creditCountParts = (used: usedStr, suffix: " / \(totalStr) AI credits")
+ percentageText = "\(usedStr) / \(totalStr) AI credits"
+ } else if let used = usedPercentage {
+ percentageText = QuotaFormatting.formatUsedPercentage(used)
+ } else if let remaining = snapshot.quotaRemaining {
+ percentageText = "\(Int(remaining)) remaining"
+ } else {
+ percentageText = "0%"
+ }
+
+ let usedFraction = (usedPercentage ?? 0) / 100.0
+ let barColor = progressBarColor(for: snapshot.usageLevel)
+ let noUsageYet = snapshot.usedPercentage == 0 || (snapshot.usedPercentage == nil && snapshot.percentRemaining == 100)
+
+ let resetText: String?
+ if let resetAt = resetAt, !snapshot.unlimited {
+ resetText = noUsageYet ? "No usage yet" : QuotaFormatting.formatResetText(resetAt)
+ } else {
+ resetText = nil
+ }
+
+ return QuotaItem(
+ title: title,
+ percentageText: percentageText,
+ creditCountParts: creditCountParts,
+ usedFraction: usedFraction,
+ isUnlimited: snapshot.unlimited,
+ barColor: barColor,
+ tooltip: tooltip,
+ resetText: resetText,
+ showInfoIcon: showInfoIcon,
+ tightResetSpacing: tightResetSpacing
+ )
+ }
+
+ private func progressBarColor(for level: QuotaSnapshot.UsageLevel) -> Color {
+ switch level {
+ case .critical: return .red
+ case .warning: return .yellow
+ case .healthy: return .blue
+ }
+ }
+}
diff --git a/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift b/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift
index 50ffc4f3..9df34403 100644
--- a/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift
+++ b/Tool/Sources/Status/Types/GitHubCopilotQuotaInfo.swift
@@ -1,17 +1,168 @@
import Foundation
public struct QuotaSnapshot: Codable, Equatable, Hashable {
- public var percentRemaining: Float
+ public var percentRemaining: Float?
public var unlimited: Bool
public var overagePermitted: Bool
+ public var overageCount: Float?
+ public var entitlement: Double?
+ public var quotaRemaining: Double?
+ public var timeStamp: String?
+
+ public init(
+ percentRemaining: Float? = nil,
+ unlimited: Bool,
+ overagePermitted: Bool,
+ overageCount: Float? = nil,
+ entitlement: Double? = nil,
+ quotaRemaining: Double? = nil,
+ timeStamp: String? = nil
+ ) {
+ self.percentRemaining = percentRemaining
+ self.unlimited = unlimited
+ self.overagePermitted = overagePermitted
+ self.overageCount = overageCount
+ self.entitlement = entitlement
+ self.quotaRemaining = quotaRemaining
+ self.timeStamp = timeStamp
+ }
+
+ /// Percentage of the quota that has been consumed (0–100), or nil when it can't be derived.
+ public var usedPercentage: Float? {
+ if let percentRemaining = percentRemaining {
+ return 100.0 - percentRemaining
+ }
+ if let entitlement = entitlement, entitlement > 0, let remaining = quotaRemaining {
+ return Float(max(0, min(100, ((entitlement - remaining) / entitlement) * 100)))
+ }
+ return nil
+ }
+
+ /// Coarse health bucket used to pick progress-bar / status colors.
+ public enum UsageLevel {
+ case healthy // >25% remaining
+ case warning // 10–25% remaining
+ case critical // ≤10% remaining
+ }
+
+ public var usageLevel: UsageLevel {
+ let percentRemaining = self.percentRemaining ?? (100.0 - (usedPercentage ?? 0))
+ if percentRemaining <= 10 { return .critical }
+ if percentRemaining <= 25 { return .warning }
+ return .healthy
+ }
}
public struct GitHubCopilotQuotaInfo: Codable, Equatable, Hashable {
public var chat: QuotaSnapshot
public var completions: QuotaSnapshot
- public var premiumInteractions: QuotaSnapshot
+ public var premiumInteractions: QuotaSnapshot?
public var resetDate: String
+ public var resetDateUtc: String? // CB/CE User only
public var copilotPlan: String
-
+ public var tokenBasedBillingEnabled: Bool?
+ public var canUpgradePlan: Bool?
+
public var isFreeUser: Bool { copilotPlan == "free" }
+ public var isUpgradePlanAllowed: Bool { canUpgradePlan ?? true }
+ public var isTokenBasedBilling: Bool { tokenBasedBillingEnabled == true }
+ public var isCBCE: Bool { copilotPlan == "business" || copilotPlan == "enterprise" }
+ public var isCBCEUnlimited: Bool { isCBCE && (premiumInteractions?.unlimited ?? false) }
+ public var isPaidIndividual: Bool {
+ copilotPlan == "individual" || copilotPlan == "individual_pro" || copilotPlan == "individual_max"
+ }
+ public var overagePermitted: Bool { premiumInteractions?.overagePermitted ?? false }
+
+ /// Human-readable plan name (e.g. "Copilot Pro Plan").
+ public var planDisplayName: String {
+ switch copilotPlan {
+ case "free": return "Copilot Free Plan"
+ case "individual": return "Copilot Pro Plan"
+ case "individual_pro": return "Copilot Pro+ Plan"
+ case "individual_max": return "Copilot Max Plan"
+ case "business": return "Copilot Business Plan"
+ case "enterprise": return "Copilot Enterprise Plan"
+ default: return "Copilot Plan"
+ }
+ }
+
+ public init(
+ chat: QuotaSnapshot,
+ completions: QuotaSnapshot,
+ premiumInteractions: QuotaSnapshot? = nil,
+ resetDate: String,
+ resetDateUtc: String? = nil,
+ copilotPlan: String,
+ tokenBasedBillingEnabled: Bool? = nil,
+ canUpgradePlan: Bool? = nil
+ ) {
+ self.chat = chat
+ self.completions = completions
+ self.premiumInteractions = premiumInteractions
+ self.resetDate = resetDate
+ self.resetDateUtc = resetDateUtc
+ self.copilotPlan = copilotPlan
+ self.tokenBasedBillingEnabled = tokenBasedBillingEnabled
+ self.canUpgradePlan = canUpgradePlan
+ }
+}
+
+// MARK: - Shared formatting
+
+public enum QuotaFormatting {
+ public static let upgradePlanURL = "https://aka.ms/github-copilot-upgrade-plan"
+ public static let manageOverageURL = "https://aka.ms/github-copilot-manage-overage"
+ public static let settingsURL = "https://aka.ms/github-copilot-settings"
+
+ /// Formats a percentage as "12% used" / "12.3% used".
+ public static func formatUsedPercentage(_ used: Float) -> String {
+ let numberPart = used.truncatingRemainder(dividingBy: 1) == 0
+ ? String(format: "%.0f", used)
+ : String(format: "%.1f", used)
+ return "\(numberPart)% used"
+ }
+
+ /// Formats a reset date string into "Resets in N days on MMM d, yyyy." text.
+ /// Accepts ISO8601 (with or without fractional seconds), `yyyy-MM-dd`, or `yyyy.MM.dd`.
+ public static func formatResetText(_ dateString: String) -> String {
+ guard let date = parseResetDate(dateString) else {
+ return "Resets on \(dateString)."
+ }
+ let days = max(0, Calendar.current.dateComponents([.day], from: Date(), to: date).day ?? 0)
+ let formattedDate = mediumDateFormatter.string(from: date)
+ return "Resets in \(days) \(days == 1 ? "day" : "days") on \(formattedDate)."
+ }
+
+ private static func parseResetDate(_ dateString: String) -> Date? {
+ if let date = isoFractionalFormatter.date(from: dateString) { return date }
+ if let date = isoFormatter.date(from: dateString) { return date }
+ for formatter in shortDateFormatters {
+ if let date = formatter.date(from: dateString) { return date }
+ }
+ return nil
+ }
+
+ private static let isoFractionalFormatter: ISO8601DateFormatter = {
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return f
+ }()
+
+ private static let isoFormatter: ISO8601DateFormatter = {
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime]
+ return f
+ }()
+
+ private static let shortDateFormatters: [DateFormatter] = ["yyyy-MM-dd", "yyyy.MM.dd"].map { format in
+ let f = DateFormatter()
+ f.dateFormat = format
+ return f
+ }
+
+ private static let mediumDateFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "MMM d, yyyy"
+ return f
+ }()
}
diff --git a/Tool/Sources/StatusBarItemView/QuotaView.swift b/Tool/Sources/StatusBarItemView/QuotaView.swift
index 4f073716..de5386a6 100644
--- a/Tool/Sources/StatusBarItemView/QuotaView.swift
+++ b/Tool/Sources/StatusBarItemView/QuotaView.swift
@@ -1,61 +1,38 @@
import SwiftUI
import Foundation
-
-// MARK: - QuotaSnapshot Model
-public struct QuotaSnapshot {
- public var percentRemaining: Float
- public var unlimited: Bool
- public var overagePermitted: Bool
-
- public init(percentRemaining: Float, unlimited: Bool, overagePermitted: Bool) {
- self.percentRemaining = percentRemaining
- self.unlimited = unlimited
- self.overagePermitted = overagePermitted
- }
-}
+import Status
// MARK: - QuotaView Main Class
public class QuotaView: NSView {
-
+
// MARK: - Properties
- private let chat: QuotaSnapshot
- private let completions: QuotaSnapshot
- private let premiumInteractions: QuotaSnapshot
- private let resetDate: String
- private let copilotPlan: String
-
- private var isFreeUser: Bool {
- return copilotPlan == "free"
- }
-
- private var isOrgUser: Bool {
- return copilotPlan == "business" || copilotPlan == "enterprise"
- }
-
+ private let quotaInfo: GitHubCopilotQuotaInfo
+
+ private var isFreeUser: Bool { quotaInfo.isFreeUser }
+ private var isCBCE: Bool { quotaInfo.isCBCE }
+ private var isCBCEUnlimited: Bool { quotaInfo.isCBCEUnlimited }
+ private var tokenBasedBillingEnabled: Bool { quotaInfo.isTokenBasedBilling }
+ private var isPaidIndividualUser: Bool { quotaInfo.isPaidIndividual }
+ private var canUpgradePlan: Bool { quotaInfo.isUpgradePlanAllowed }
+
private var isFreeQuotaUsedUp: Bool {
- return chat.percentRemaining == 0 && completions.percentRemaining == 0
+ let chatRemaining = quotaInfo.chat.percentRemaining ?? (100.0 - (quotaInfo.chat.usedPercentage ?? 0))
+ let completionsRemaining = quotaInfo.completions.percentRemaining ?? (100.0 - (quotaInfo.completions.usedPercentage ?? 0))
+ return chatRemaining == 0 && completionsRemaining == 0
}
-
+
private var isFreeQuotaRemaining: Bool {
- return chat.percentRemaining > 25 && completions.percentRemaining > 25
+ let chatRemaining = quotaInfo.chat.percentRemaining ?? (100.0 - (quotaInfo.chat.usedPercentage ?? 0))
+ let completionsRemaining = quotaInfo.completions.percentRemaining ?? (100.0 - (quotaInfo.completions.usedPercentage ?? 0))
+ return chatRemaining > 25 && completionsRemaining > 25
}
-
+
// MARK: - Initialization
- public init(
- chat: QuotaSnapshot,
- completions: QuotaSnapshot,
- premiumInteractions: QuotaSnapshot,
- resetDate: String,
- copilotPlan: String
- ) {
- self.chat = chat
- self.completions = completions
- self.premiumInteractions = premiumInteractions
- self.resetDate = resetDate
- self.copilotPlan = copilotPlan
-
+ public init(quotaInfo: GitHubCopilotQuotaInfo) {
+ self.quotaInfo = quotaInfo
+
super.init(frame: NSRect(x: 0, y: 0, width: Layout.viewWidth, height: 0))
-
+
configureView()
}
@@ -80,24 +57,41 @@ public class QuotaView: NSView {
// MARK: - Component Creation
private func createViewComponents() -> ViewComponents {
+ let (upsellView, upsellHeight) = createUpsellView()
return ViewComponents(
titleContainer: createTitleContainer(),
- progressViews: createProgressViews(),
+ progressViews: isCBCEUnlimited ? [] : createProgressViews(),
statusMessageLabel: createStatusMessageLabel(),
+ unlimitedMessageLabel: isCBCEUnlimited ? createUnlimitedMessageLabel() : nil,
+ refreshTextLabel: (isCBCE && !isCBCEUnlimited) ? createRefreshTextLabel() : nil,
resetTextLabel: createResetTextLabel(),
- upsellLabel: createUpsellLabel()
+ upsellView: upsellView,
+ upsellHeight: upsellHeight
)
}
-
+
private func addSubviewsToHierarchy(_ components: ViewComponents) {
addSubview(components.titleContainer)
- components.progressViews.forEach { addSubview($0) }
- if !isFreeUser {
- addSubview(components.statusMessageLabel)
+ if isCBCEUnlimited {
+ if let label = components.unlimitedMessageLabel {
+ addSubview(label)
+ }
+ return
}
- addSubview(components.resetTextLabel)
- if !(isOrgUser || (isFreeUser && isFreeQuotaRemaining)) {
- addSubview(components.upsellLabel)
+ components.progressViews.forEach { addSubview($0) }
+ if isCBCE, let refreshLabel = components.refreshTextLabel {
+ if quotaInfo.premiumInteractions != nil {
+ addSubview(components.statusMessageLabel)
+ }
+ addSubview(refreshLabel)
+ } else {
+ if !isFreeUser, quotaInfo.premiumInteractions != nil || isPaidIndividualUser {
+ addSubview(components.statusMessageLabel)
+ }
+ addSubview(components.resetTextLabel)
+ if !(isCBCE || (isFreeUser && isFreeQuotaRemaining)) {
+ addSubview(components.upsellView)
+ }
}
}
}
@@ -165,28 +159,33 @@ extension QuotaView {
// MARK: - Progress Bars Section
extension QuotaView {
private func createProgressViews() -> [NSView] {
- let completionsView = createProgressBarSection(
- title: "Code Completions",
- snapshot: completions
- )
-
- let chatView = createProgressBarSection(
- title: "Chat Messages",
- snapshot: chat
- )
-
+ var items: [(String, QuotaSnapshot)] = []
+
if isFreeUser {
- return [completionsView, chatView]
+ let completionsTitle = tokenBasedBillingEnabled ? "Inline Suggestions" : "Code Completions"
+ let chatTitle = tokenBasedBillingEnabled ? "Included Credits" : "Chat Messages"
+ items.append((completionsTitle, quotaInfo.completions))
+ items.append((chatTitle, quotaInfo.chat))
+ } else if tokenBasedBillingEnabled {
+ if let premiumInteractions = quotaInfo.premiumInteractions {
+ items.append(("Included Credits", premiumInteractions))
+ }
+ } else {
+ // Original billing
+ if let premiumInteractions = quotaInfo.premiumInteractions {
+ items.append(("Premium Requests", premiumInteractions))
+ }
+ if !quotaInfo.completions.unlimited {
+ items.append(("Code Completions", quotaInfo.completions))
+ }
+ if !quotaInfo.chat.unlimited {
+ items.append(("Chat Messages", quotaInfo.chat))
+ }
}
-
- let premiumView = createProgressBarSection(
- title: "Premium Requests",
- snapshot: premiumInteractions
- )
-
- return [completionsView, chatView, premiumView]
+
+ return items.map { createProgressBarSection(title: $0.0, snapshot: $0.1) }
}
-
+
private func createProgressBarSection(title: String, snapshot: QuotaSnapshot) -> NSView {
let container = NSView()
container.translatesAutoresizingMaskIntoConstraints = false
@@ -215,24 +214,29 @@ extension QuotaView {
}
private func createPercentageLabel(snapshot: QuotaSnapshot) -> NSTextField {
- let usedPercentage = (100.0 - snapshot.percentRemaining)
- let numberPart = usedPercentage.truncatingRemainder(dividingBy: 1) == 0
- ? String(format: "%.0f", usedPercentage)
- : String(format: "%.1f", usedPercentage)
- let text = snapshot.unlimited ? "Included" : "\(numberPart)%"
-
+ let text: String
+ if snapshot.unlimited {
+ text = "Included"
+ } else if let usedPercentage = snapshot.usedPercentage {
+ text = QuotaFormatting.formatUsedPercentage(usedPercentage)
+ } else if let quotaRemaining = snapshot.quotaRemaining {
+ text = "\(Int(quotaRemaining)) remaining"
+ } else {
+ text = "0%"
+ }
+
let label = NSTextField(labelWithString: text)
label.font = NSFont.systemFont(ofSize: Style.percentageFontSize, weight: .regular)
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .secondaryLabelColor
label.alignment = .right
-
+
return label
}
-
+
private func addProgressBar(to container: NSView, snapshot: QuotaSnapshot, titleLabel: NSTextField, percentageLabel: NSTextField) {
- let usedPercentage = 100.0 - snapshot.percentRemaining
- let color = getProgressBarColor(for: usedPercentage)
+ let usedPercentage = snapshot.usedPercentage ?? 0
+ let color = progressBarColor(for: snapshot.usageLevel)
let progressBackground = createProgressBackground(color: color)
let progressFill = createProgressFill(color: color, usedPercentage: usedPercentage)
@@ -315,48 +319,69 @@ extension QuotaView {
])
}
- private func getProgressBarColor(for usedPercentage: Float) -> NSColor {
- switch usedPercentage {
- case 90...:
- return .systemRed
- case 75..<90:
- return .systemYellow
- default:
- return .systemBlue
+ private func progressBarColor(for level: QuotaSnapshot.UsageLevel) -> NSColor {
+ switch level {
+ case .critical: return .systemRed
+ case .warning: return .systemYellow
+ case .healthy: return .systemBlue
}
}
}
// MARK: - Footer Section
extension QuotaView {
+ private func createUnlimitedMessageLabel() -> NSTextField {
+ let label = NSTextField(labelWithString: "You have no monthly limit on AI credits usage set by your organization.")
+ label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular)
+ label.translatesAutoresizingMaskIntoConstraints = false
+ label.textColor = .secondaryLabelColor
+ label.alignment = .left
+ label.lineBreakMode = .byWordWrapping
+ label.maximumNumberOfLines = 0
+ label.preferredMaxLayoutWidth = Layout.viewWidth - Layout.horizontalMargin * 2
+ return label
+ }
+
+ private func createRefreshTextLabel() -> NSTextField {
+ let dateString = quotaInfo.resetDateUtc ?? quotaInfo.resetDate
+ let label = NSTextField(labelWithString: QuotaFormatting.formatResetText(dateString))
+ label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular)
+ label.translatesAutoresizingMaskIntoConstraints = false
+ label.textColor = .secondaryLabelColor
+ label.alignment = .left
+ label.lineBreakMode = .byWordWrapping
+ label.maximumNumberOfLines = 0
+ label.preferredMaxLayoutWidth = Layout.viewWidth - Layout.horizontalMargin * 2
+ return label
+ }
+
private func createStatusMessageLabel() -> NSTextField {
- let message = premiumInteractions.overagePermitted ?
- "Additional paid premium requests enabled." :
- "Additional paid premium requests disabled."
-
+ let overagePermitted = quotaInfo.overagePermitted
+ let message: String
+ if tokenBasedBillingEnabled {
+ message = overagePermitted ? "Additional usage enabled." : "Additional usage not enabled."
+ } else {
+ message = overagePermitted ?
+ "Additional paid premium requests enabled." :
+ "Additional paid premium requests disabled."
+ }
+
let label = NSTextField(labelWithString: isFreeUser ? "" : message)
label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular)
label.translatesAutoresizingMaskIntoConstraints = false
- label.textColor = .secondaryLabelColor
+ label.textColor = (tokenBasedBillingEnabled && overagePermitted) ? .labelColor : .secondaryLabelColor
label.alignment = .left
return label
}
-
+
private func createResetTextLabel() -> NSTextField {
-
- // Format reset date
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy.MM.dd"
-
- var resetText = "Allowance resets \(resetDate)."
-
- if let date = formatter.date(from: resetDate) {
- let outputFormatter = DateFormatter()
- outputFormatter.dateFormat = "MMMM d, yyyy"
- let formattedDate = outputFormatter.string(from: date)
- resetText = "Allowance resets \(formattedDate)."
+ let resetText: String
+ if tokenBasedBillingEnabled {
+ resetText = QuotaFormatting.formatResetText(quotaInfo.resetDateUtc ?? quotaInfo.resetDate)
+ } else {
+ resetText = legacyAllowanceResetText(quotaInfo.resetDate)
}
-
+
let label = NSTextField(labelWithString: resetText)
label.font = NSFont.systemFont(ofSize: Style.footerFontSize, weight: .regular)
label.translatesAutoresizingMaskIntoConstraints = false
@@ -364,49 +389,133 @@ extension QuotaView {
label.alignment = .left
return label
}
+
+ private func legacyAllowanceResetText(_ dateString: String) -> String {
+ for format in ["yyyy-MM-dd", "yyyy.MM.dd"] {
+ let formatter = DateFormatter()
+ formatter.dateFormat = format
+ if let date = formatter.date(from: dateString) {
+ let outputFormatter = DateFormatter()
+ outputFormatter.dateFormat = "MMMM d, yyyy"
+ return "Allowance resets \(outputFormatter.string(from: date))."
+ }
+ }
+ return "Allowance resets \(dateString)."
+ }
- private func createUpsellLabel() -> NSButton {
- if isFreeUser {
- let button = NSButton()
- let upgradeTitle = "Upgrade to Copilot Pro"
-
- button.translatesAutoresizingMaskIntoConstraints = false
- button.bezelStyle = .push
- if isFreeQuotaUsedUp {
- if #available(macOS 26.0, *) {
- button.attributedTitle = NSAttributedString(
- string: upgradeTitle,
- attributes: [.foregroundColor: NSColor.controlTextColor]
- )
- button.bezelColor = .controlBackgroundColor
+ private func createUpsellView() -> (NSView, CGFloat) {
+ if tokenBasedBillingEnabled || isFreeUser {
+ var buttons: [NSButton] = []
+ if tokenBasedBillingEnabled, isPaidIndividualUser {
+ let overagePermitted = quotaInfo.premiumInteractions?.overagePermitted ?? false
+ let primaryTitle = overagePermitted ? "Increase Budget" : "Enable Additional Usage"
+ buttons.append(makeProminentButton(title: primaryTitle, action: #selector(openCopilotManageOverage)))
+ }
+ if canUpgradePlan {
+ if isFreeUser, !tokenBasedBillingEnabled {
+ buttons.append(createUpgradeToProButton())
} else {
- button.attributedTitle = NSAttributedString(
- string: upgradeTitle,
- attributes: [.foregroundColor: NSColor.white]
- )
- button.bezelColor = .controlAccentColor
+ let upgrade = buttons.isEmpty
+ ? makeProminentButton(title: "Upgrade Plan", action: #selector(openCopilotUpgradePlan))
+ : makeBorderedButton(title: "Upgrade Plan", action: #selector(openCopilotUpgradePlan))
+ buttons.append(upgrade)
}
- } else {
- button.title = upgradeTitle
}
- button.controlSize = .large
- button.target = self
- button.action = #selector(openCopilotUpgradePlan)
+ switch buttons.count {
+ case 1:
+ let height = (isFreeUser && !tokenBasedBillingEnabled)
+ ? Layout.upgradeButtonHeight
+ : Layout.compactUpgradeButtonHeight
+ return (buttons[0], height)
+ case 2: return (makeButtonStack(buttons: buttons), Layout.dualButtonHeight)
+ default:
+ if isFreeUser { return (NSView(), 0) }
+ break // TBB org/CBCE: fall through to default link
+ }
+ }
+
+ let button = HoverButton()
+ let title = tokenBasedBillingEnabled ? "Manage your Budget" : "Manage paid premium requests"
+ button.setLinkStyle(title: title, fontSize: Style.footerFontSize)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.alphaValue = Style.labelAlphaValue
+ button.alignment = .left
+ button.target = self
+ button.action = #selector(openCopilotManageOverage)
+ return (button, Layout.linkLabelHeight)
+ }
+
+ private func createUpgradeToProButton() -> NSButton {
+ let button = NSButton()
+ let upgradeTitle = "Upgrade to Copilot Pro"
- return button
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.bezelStyle = .push
+ if isFreeQuotaUsedUp {
+ if #available(macOS 26.0, *) {
+ button.attributedTitle = NSAttributedString(
+ string: upgradeTitle,
+ attributes: [.foregroundColor: NSColor.controlTextColor]
+ )
+ button.bezelColor = .controlBackgroundColor
+ } else {
+ button.attributedTitle = NSAttributedString(
+ string: upgradeTitle,
+ attributes: [.foregroundColor: NSColor.white]
+ )
+ button.bezelColor = .controlAccentColor
+ }
} else {
- let button = HoverButton()
- let title = "Manage paid premium requests"
-
- button.setLinkStyle(title: title, fontSize: Style.footerFontSize)
- button.translatesAutoresizingMaskIntoConstraints = false
- button.alphaValue = Style.labelAlphaValue
- button.alignment = .left
- button.target = self
- button.action = #selector(openCopilotManageOverage)
-
- return button
+ button.title = upgradeTitle
}
+ button.controlSize = .large
+ button.target = self
+ button.action = #selector(openCopilotUpgradePlan)
+ return button
+ }
+
+ private func makeProminentButton(title: String, action: Selector) -> NSButton {
+ let button = NSButton()
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.bezelStyle = .push
+ button.controlSize = .regular
+ button.isBordered = false
+ button.wantsLayer = true
+ button.layer?.backgroundColor = NSColor.controlAccentColor.cgColor
+ button.layer?.cornerRadius = 6
+ button.attributedTitle = NSAttributedString(
+ string: title,
+ attributes: [.foregroundColor: NSColor.white]
+ )
+ button.target = self
+ button.action = action
+ return button
+ }
+
+ private func makeBorderedButton(title: String, action: Selector) -> NSButton {
+ let button = NSButton()
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.bezelStyle = .push
+ button.controlSize = .regular
+ button.title = title
+ button.target = self
+ button.action = action
+ return button
+ }
+
+ private func makeButtonStack(buttons: [NSButton]) -> NSStackView {
+ let stack = NSStackView(views: buttons)
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.distribution = .fillEqually
+ stack.spacing = 6
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ for button in buttons {
+ button.leadingAnchor.constraint(equalTo: stack.leadingAnchor).isActive = true
+ button.trailingAnchor.constraint(equalTo: stack.trailingAnchor).isActive = true
+ button.heightAnchor.constraint(equalToConstant: 24).isActive = true
+ }
+ return stack
}
}
@@ -419,16 +528,26 @@ extension QuotaView {
private func buildConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] {
var constraints: [NSLayoutConstraint] = []
-
+
// Title constraints
constraints.append(contentsOf: buildTitleConstraints(components.titleContainer))
-
+
+ if let unlimitedLabel = components.unlimitedMessageLabel {
+ constraints.append(contentsOf: [
+ unlimitedLabel.topAnchor.constraint(equalTo: components.titleContainer.bottomAnchor, constant: Layout.verticalSpacing),
+ unlimitedLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
+ unlimitedLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
+ unlimitedLabel.bottomAnchor.constraint(equalTo: bottomAnchor)
+ ])
+ return constraints
+ }
+
// Progress view constraints
constraints.append(contentsOf: buildProgressViewConstraints(components))
-
+
// Footer constraints
constraints.append(contentsOf: buildFooterConstraints(components))
-
+
return constraints
}
@@ -442,109 +561,94 @@ extension QuotaView {
}
private func buildProgressViewConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] {
- let completionsView = components.progressViews[0]
- let chatView = components.progressViews[1]
-
var constraints: [NSLayoutConstraint] = []
-
- if !isFreeUser {
- let premiumView = components.progressViews[2]
- constraints.append(contentsOf: buildPremiumProgressConstraints(premiumView, titleContainer: components.titleContainer))
- constraints.append(contentsOf: buildCompletionsProgressConstraints(completionsView, topView: premiumView, isPremiumUnlimited: premiumInteractions.unlimited))
- } else {
- constraints.append(contentsOf: buildCompletionsProgressConstraints(completionsView, topView: components.titleContainer, isPremiumUnlimited: false))
+ var previousView: NSView = components.titleContainer
+
+ for progressView in components.progressViews {
+ constraints.append(contentsOf: [
+ progressView.topAnchor.constraint(equalTo: previousView.bottomAnchor, constant: Layout.verticalSpacing),
+ progressView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
+ progressView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
+ progressView.heightAnchor.constraint(equalToConstant: Layout.progressBarHeight)
+ ])
+ previousView = progressView
}
-
- constraints.append(contentsOf: buildChatProgressConstraints(chatView, topView: completionsView))
-
+
return constraints
}
-
- private func buildPremiumProgressConstraints(_ premiumView: NSView, titleContainer: NSView) -> [NSLayoutConstraint] {
- return [
- premiumView.topAnchor.constraint(equalTo: titleContainer.bottomAnchor, constant: Layout.verticalSpacing),
- premiumView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
- premiumView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
- premiumView.heightAnchor.constraint(
- equalToConstant: premiumInteractions.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight
- )
- ]
- }
-
- private func buildCompletionsProgressConstraints(_ completionsView: NSView, topView: NSView, isPremiumUnlimited: Bool) -> [NSLayoutConstraint] {
- let topSpacing = isPremiumUnlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing
-
- return [
- completionsView.topAnchor.constraint(equalTo: topView.bottomAnchor, constant: topSpacing),
- completionsView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
- completionsView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
- completionsView.heightAnchor.constraint(
- equalToConstant: completions.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight
- )
- ]
- }
-
- private func buildChatProgressConstraints(_ chatView: NSView, topView: NSView) -> [NSLayoutConstraint] {
- let topSpacing = completions.unlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing
-
- return [
- chatView.topAnchor.constraint(equalTo: topView.bottomAnchor, constant: topSpacing),
- chatView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
- chatView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
- chatView.heightAnchor.constraint(
- equalToConstant: chat.unlimited ? Layout.unlimitedProgressBarHeight : Layout.progressBarHeight
- )
- ]
- }
-
+
private func buildFooterConstraints(_ components: ViewComponents) -> [NSLayoutConstraint] {
- let chatView = components.progressViews[1]
- let topSpacing = chat.unlimited ? Layout.unlimitedVerticalSpacing : Layout.verticalSpacing
-
+ let lastProgressView = components.progressViews.last ?? components.titleContainer
+ let showResetText = true
+
var constraints = [NSLayoutConstraint]()
-
- if !isFreeUser {
- // Add status message label constraints
+
+ // CB/CE non-unlimited: show refresh text label + status message (if premium info exists)
+ if let refreshLabel = components.refreshTextLabel {
constraints.append(contentsOf: [
- components.statusMessageLabel.topAnchor.constraint(equalTo: chatView.bottomAnchor, constant: topSpacing),
- components.statusMessageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
- components.statusMessageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
- components.statusMessageLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight)
+ refreshLabel.topAnchor.constraint(equalTo: lastProgressView.bottomAnchor, constant: Layout.smallVerticalSpacing),
+ refreshLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
+ refreshLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
+ refreshLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight)
])
-
- // Add reset text label constraints with status message label as the top anchor
+ var anchor: NSView = refreshLabel
+ if quotaInfo.premiumInteractions != nil {
+ let statusHeight = tokenBasedBillingEnabled ? Layout.statusMessageHeight : Layout.footerTextHeight
+ constraints.append(contentsOf: [
+ components.statusMessageLabel.topAnchor.constraint(equalTo: anchor.bottomAnchor, constant: Layout.verticalSpacing),
+ components.statusMessageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
+ components.statusMessageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
+ components.statusMessageLabel.heightAnchor.constraint(equalToConstant: statusHeight)
+ ])
+ anchor = components.statusMessageLabel
+ }
+ constraints.append(anchor.bottomAnchor.constraint(equalTo: bottomAnchor))
+ return constraints
+ }
+
+ // Anchor for the element after progress views
+ var lastAnchorView: NSView = lastProgressView
+
+ if showResetText {
+ let resetTopSpacing = isFreeUser ? Layout.verticalSpacing : Layout.smallVerticalSpacing
constraints.append(contentsOf: [
- components.resetTextLabel.topAnchor.constraint(equalTo: components.statusMessageLabel.bottomAnchor),
+ components.resetTextLabel.topAnchor.constraint(equalTo: lastProgressView.bottomAnchor, constant: resetTopSpacing),
components.resetTextLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
components.resetTextLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
components.resetTextLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight)
])
- } else {
- // For free users, only show reset text label
+ lastAnchorView = components.resetTextLabel
+ }
+
+ if !isFreeUser, quotaInfo.premiumInteractions != nil || isPaidIndividualUser {
+ let statusHeight = tokenBasedBillingEnabled ? Layout.statusMessageHeight : Layout.footerTextHeight
constraints.append(contentsOf: [
- components.resetTextLabel.topAnchor.constraint(equalTo: chatView.bottomAnchor, constant: topSpacing),
- components.resetTextLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
- components.resetTextLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
- components.resetTextLabel.heightAnchor.constraint(equalToConstant: Layout.footerTextHeight)
+ components.statusMessageLabel.topAnchor.constraint(equalTo: lastAnchorView.bottomAnchor, constant: Layout.verticalSpacing),
+ components.statusMessageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
+ components.statusMessageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
+ components.statusMessageLabel.heightAnchor.constraint(equalToConstant: statusHeight)
])
+ lastAnchorView = components.statusMessageLabel
}
-
- if isOrgUser || (isFreeUser && isFreeQuotaRemaining) {
- // Do not show link label for business or enterprise users
- constraints.append(components.resetTextLabel.bottomAnchor.constraint(equalTo: bottomAnchor))
+
+ if isCBCE || (isFreeUser && isFreeQuotaRemaining) {
+ constraints.append(lastAnchorView.bottomAnchor.constraint(equalTo: bottomAnchor))
return constraints
}
-
+
// Add link label constraints
+ let isTallButton = components.upsellHeight == Layout.upgradeButtonHeight || components.upsellHeight == Layout.compactUpgradeButtonHeight
+ let upsellTopSpacing: CGFloat = isTallButton ? Layout.smallVerticalSpacing : 0
+ let upsellBottomSpacing: CGFloat = isTallButton ? -Layout.smallVerticalSpacing : 0
constraints.append(contentsOf: [
- components.upsellLabel.topAnchor.constraint(equalTo: components.resetTextLabel.bottomAnchor),
- components.upsellLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
- components.upsellLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
- components.upsellLabel.heightAnchor.constraint(equalToConstant: isFreeUser ? Layout.upgradeButtonHeight : Layout.linkLabelHeight),
-
- components.upsellLabel.bottomAnchor.constraint(equalTo: bottomAnchor)
+ components.upsellView.topAnchor.constraint(equalTo: lastAnchorView.bottomAnchor, constant: upsellTopSpacing),
+ components.upsellView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.horizontalMargin),
+ components.upsellView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.horizontalMargin),
+ components.upsellView.heightAnchor.constraint(equalToConstant: components.upsellHeight),
+
+ components.upsellView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: upsellBottomSpacing)
])
-
+
return constraints
}
}
@@ -552,26 +656,20 @@ extension QuotaView {
// MARK: - Actions
extension QuotaView {
@objc private func openCopilotSettings() {
- Task {
- if let url = URL(string: "https://aka.ms/github-copilot-settings") {
- NSWorkspace.shared.open(url)
- }
- }
+ openURL(QuotaFormatting.settingsURL)
}
-
+
@objc private func openCopilotManageOverage() {
- Task {
- if let url = URL(string: "https://aka.ms/github-copilot-manage-overage") {
- NSWorkspace.shared.open(url)
- }
- }
+ openURL(QuotaFormatting.manageOverageURL)
}
-
+
@objc private func openCopilotUpgradePlan() {
- Task {
- if let url = URL(string: "https://aka.ms/github-copilot-upgrade-plan") {
- NSWorkspace.shared.open(url)
- }
+ openURL(QuotaFormatting.upgradePlanURL)
+ }
+
+ private func openURL(_ urlString: String) {
+ if let url = URL(string: urlString) {
+ NSWorkspace.shared.open(url)
}
}
}
@@ -581,24 +679,30 @@ private struct ViewComponents {
let titleContainer: NSView
let progressViews: [NSView]
let statusMessageLabel: NSTextField
+ let unlimitedMessageLabel: NSTextField?
+ let refreshTextLabel: NSTextField?
let resetTextLabel: NSTextField
- let upsellLabel: NSButton
+ let upsellView: NSView
+ let upsellHeight: CGFloat
}
// MARK: - Layout Constants
private struct Layout {
static let viewWidth: CGFloat = 256
static let horizontalMargin: CGFloat = 14
- static let verticalSpacing: CGFloat = 8
+ static let verticalSpacing: CGFloat = 6
static let unlimitedVerticalSpacing: CGFloat = 6
- static let smallVerticalSpacing: CGFloat = 4
+ static let smallVerticalSpacing: CGFloat = 2
static let titleHeight: CGFloat = 20
static let progressBarHeight: CGFloat = 22
static let unlimitedProgressBarHeight: CGFloat = 16
static let footerTextHeight: CGFloat = 16
+ static let statusMessageHeight: CGFloat = 20
static let linkLabelHeight: CGFloat = 16
static let upgradeButtonHeight: CGFloat = 40
+ static let compactUpgradeButtonHeight: CGFloat = 28
+ static let dualButtonHeight: CGFloat = 54
static let settingsButtonSize: CGFloat = 20
static let settingsButtonHoverSize: CGFloat = 14
diff --git a/marketplace.json b/marketplace.json
new file mode 100644
index 00000000..7dd23f7e
--- /dev/null
+++ b/marketplace.json
@@ -0,0 +1,17 @@
+{
+ "name": "GitHub Copilot",
+ "description": "GitHub's AI pair programmer",
+ "publisher": {
+ "name": "GitHub",
+ "url": "https://github.com/github",
+ "verified": true
+ },
+ "repository": "https://github.com/github/CopilotForXcode",
+ "license": "proprietary",
+ "plugins": [
+ {
+ "path": ".agent-plugin",
+ "type": "acp-agent"
+ }
+ ]
+}