diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 71d9e18..14ce6d1 100644
--- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -261,6 +261,15 @@
"version" : "509.0.2"
}
},
+ {
+ "identity" : "swiftui-flow-layout",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/globulus/swiftui-flow-layout",
+ "state" : {
+ "revision" : "de7da3440c3b87ba94adfa98c698828d7746a76d",
+ "version" : "1.0.5"
+ }
+ },
{
"identity" : "swiftui-navigation",
"kind" : "remoteSourceControl",
diff --git a/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json
new file mode 100644
index 0000000..8ad86a7
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "CopilotIssue.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/CopilotIssue.svg b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/CopilotIssue.svg
new file mode 100644
index 0000000..af4e890
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/CopilotIssue.imageset/CopilotIssue.svg
@@ -0,0 +1,18 @@
+
diff --git a/Copilot for Xcode/Assets.xcassets/DescriptionForegroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/DescriptionForegroundColor.colorset/Contents.json
new file mode 100644
index 0000000..bdcbb88
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/DescriptionForegroundColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x9D",
+ "green" : "0x9D",
+ "red" : "0x9D"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/Contents.json
new file mode 100644
index 0000000..1c65bf6
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "file_type_swift.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/file_type_swift.svg b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/file_type_swift.svg
new file mode 100644
index 0000000..c232d1f
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/SwiftIcon.imageset/file_type_swift.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Copilot for Xcode/Assets.xcassets/TextLinkForegroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/TextLinkForegroundColor.colorset/Contents.json
new file mode 100644
index 0000000..d892da1
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/TextLinkForegroundColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0x94",
+ "red" : "0x37"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Copilot for Xcode/Credits.rtf b/Copilot for Xcode/Credits.rtf
index 5655b19..c2bc880 100644
--- a/Copilot for Xcode/Credits.rtf
+++ b/Copilot for Xcode/Credits.rtf
@@ -3216,4 +3216,30 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\
SOFTWARE.\
\
\
+Dependency: github.com/globulus/swiftui-flow-layout\
+Version: 1.0.5\
+License Content:\
+MIT License\
+\
+Copyright (c) 2021 Gordan Glavaš\
+\
+Permission is hereby granted, free of charge, to any person obtaining a copy\
+of this software and associated documentation files (the "Software"), to deal\
+in the Software without restriction, including without limitation the rights\
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\
+copies of the Software, and to permit persons to whom the Software is\
+furnished to do so, subject to the following conditions:\
+\
+The above copyright notice and this permission notice shall be included in all\
+copies or substantial portions of the Software.\
+\
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\
+SOFTWARE.\
+\
+\
}
\ No newline at end of file
diff --git a/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist
index b45f6d1..12d852d 100644
--- a/Copilot-for-Xcode-Info.plist
+++ b/Copilot-for-Xcode-Info.plist
@@ -1,32 +1,34 @@
-
- APP_ID_PREFIX
- $(AppIdentifierPrefix)
- APPLICATION_SUPPORT_FOLDER
- $(APPLICATION_SUPPORT_FOLDER)
- BUNDLE_IDENTIFIER_BASE
- $(BUNDLE_IDENTIFIER_BASE)
- EXTENSION_BUNDLE_NAME
- $(EXTENSION_BUNDLE_NAME)
- HOST_APP_NAME
- $(HOST_APP_NAME)
- LANGUAGE_SERVER_PATH
- $(LANGUAGE_SERVER_PATH)
- NODE_PATH
- $(NODE_PATH)
- SUEnableAutomaticChecks
- YES
- SUScheduledCheckInterval
- 3600
- SUEnableJavaScript
- NO
- SUFeedURL
- $(SPARKLE_FEED_URL)
- SUPublicEDKey
- $(SPARKLE_PUBLIC_KEY)
- TEAM_ID_PREFIX
- $(TeamIdentifierPrefix)
-
-
+
+ APP_ID_PREFIX
+ $(AppIdentifierPrefix)
+ APPLICATION_SUPPORT_FOLDER
+ $(APPLICATION_SUPPORT_FOLDER)
+ BUNDLE_IDENTIFIER_BASE
+ $(BUNDLE_IDENTIFIER_BASE)
+ EXTENSION_BUNDLE_NAME
+ $(EXTENSION_BUNDLE_NAME)
+ HOST_APP_NAME
+ $(HOST_APP_NAME)
+ LANGUAGE_SERVER_PATH
+ $(LANGUAGE_SERVER_PATH)
+ NODE_PATH
+ $(NODE_PATH)
+ SUEnableAutomaticChecks
+ YES
+ SUScheduledCheckInterval
+ 3600
+ SUEnableJavaScript
+ NO
+ SUFeedURL
+ $(SPARKLE_FEED_URL)
+ SUPublicEDKey
+ $(SPARKLE_PUBLIC_KEY)
+ TEAM_ID_PREFIX
+ $(TeamIdentifierPrefix)
+ STANDARD_TELEMETRY_CHANNEL_KEY
+ $(STANDARD_TELEMETRY_CHANNEL_KEY)
+
+
\ No newline at end of file
diff --git a/Core/Package.swift b/Core/Package.swift
index 385746c..6cf84fb 100644
--- a/Core/Package.swift
+++ b/Core/Package.swift
@@ -33,6 +33,7 @@ let package = Package(
"Client",
"LaunchAgentManager",
"UpdateChecker",
+ "GitHubCopilotViewModel",
]
),
],
@@ -52,6 +53,8 @@ let package = Package(
.package(url: "https://github.com/devm33/KeyboardShortcuts", branch: "main"),
.package(url: "https://github.com/devm33/CGEventOverride", branch: "devm33/fix-stale-AXIsProcessTrusted"),
.package(url: "https://github.com/devm33/Highlightr", branch: "master"),
+ .package(url: "https://github.com/globulus/swiftui-flow-layout",
+ from: "1.0.5")
],
targets: [
// MARK: - Main
@@ -76,6 +79,7 @@ let package = Package(
"ConversationTab",
"KeyBindingManager",
"XcodeThemeController",
+ .product(name: "TelemetryService", package: "Tool"),
.product(name: "XPCShared", package: "Tool"),
.product(name: "SuggestionProvider", package: "Tool"),
.product(name: "ConversationServiceProvider", package: "Tool"),
@@ -84,10 +88,12 @@ let package = Package(
.product(name: "AppMonitoring", package: "Tool"),
.product(name: "SuggestionBasic", package: "Tool"),
.product(name: "Status", package: "Tool"),
+ .product(name: "StatusBarItemView", package: "Tool"),
.product(name: "ChatTab", package: "Tool"),
.product(name: "Logger", package: "Tool"),
.product(name: "ChatAPIService", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
+ .product(name: "AXHelper", package: "Tool"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "Dependencies", package: "swift-dependencies"),
@@ -114,6 +120,7 @@ let package = Package(
dependencies: [
"Client",
"LaunchAgentManager",
+ "GitHubCopilotViewModel",
.product(name: "SuggestionProvider", package: "Tool"),
.product(name: "Toast", package: "Tool"),
.product(name: "SharedUIComponents", package: "Tool"),
@@ -167,6 +174,7 @@ let package = Package(
.product(name: "Parsing", package: "swift-parsing"),
.product(name: "ChatAPIService", package: "Tool"),
.product(name: "Preferences", package: "Tool"),
+ .product(name: "AXHelper", package: "Tool"),
.product(name: "ConversationServiceProvider", package: "Tool"),
.product(name: "GitHubCopilotService", package: "Tool"),
]),
@@ -180,8 +188,10 @@ let package = Package(
.product(name: "Logger", package: "Tool"),
.product(name: "ChatTab", package: "Tool"),
.product(name: "Terminal", package: "Tool"),
+ .product(name: "Cache", package: "Tool"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
+ .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout")
]
),
@@ -192,6 +202,7 @@ let package = Package(
dependencies: [
"PromptToCodeService",
"ConversationTab",
+ "GitHubCopilotViewModel",
.product(name: "GitHubCopilotService", package: "Tool"),
.product(name: "Toast", package: "Tool"),
.product(name: "UserDefaultsObserver", package: "Tool"),
@@ -209,7 +220,7 @@ let package = Package(
// MARK: - Helpers
- .target(name: "FileChangeChecker"),
+ .target(name: "FileChangeChecker"),
.target(
name: "LaunchAgentManager",
dependencies: [
@@ -224,6 +235,14 @@ let package = Package(
.product(name: "Logger", package: "Tool"),
]
),
+ .target(
+ name: "GitHubCopilotViewModel",
+ dependencies: [
+ .product(name: "GitHubCopilotService", package: "Tool"),
+ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
+ .product(name: "Status", package: "Tool"),
+ ]
+ ),
// MARK: Key Binding
diff --git a/Core/Sources/ChatService/ChatInjector.swift b/Core/Sources/ChatService/ChatInjector.swift
new file mode 100644
index 0000000..81a6024
--- /dev/null
+++ b/Core/Sources/ChatService/ChatInjector.swift
@@ -0,0 +1,97 @@
+import SuggestionBasic
+import AppKit
+import XcodeInspector
+import AXHelper
+import ApplicationServices
+import AppActivator
+
+
+public struct ChatInjector {
+ public init() {}
+
+ public func insertCodeBlock(codeBlock: String) {
+ do {
+ guard let editorContent = XcodeInspector.shared.focusedEditor?.getContent(),
+ let focusElement = XcodeInspector.shared.focusedElement,
+ focusElement.description == "Source Editor"
+ else { return }
+
+ var cursorPosition = editorContent.cursorPosition
+ guard cursorPosition.line >= 0, cursorPosition.character >= 0 else { return }
+
+ var lines = editorContent.content.splitByNewLine(
+ omittingEmptySubsequences: false
+ ).map { String($0) }
+ // Ensure the line number is within the bounds of the file
+ guard cursorPosition.line <= lines.count else { return }
+
+ var modifications: [Modification] = []
+
+ // remove selection
+ // make sure there is selection exist and valid
+ if let selection = editorContent.selections.first,
+ selection.isValid,
+ selection.start.line < lines.endIndex {
+ let selectionEndLine = min(selection.end.line, lines.count - 1)
+ let deletedSelection = CursorRange(
+ start: selection.start,
+ end: .init(line: selectionEndLine, character: selection.end.character)
+ )
+ modifications.append(.deletedSelection(deletedSelection))
+ lines = lines.applying([.deletedSelection(deletedSelection)])
+
+ // update cursorPosition to the start of selection
+ cursorPosition = selection.start
+ }
+
+ let targetLine = lines[cursorPosition.line]
+
+ // Determine the indention level of the target line
+ let leadingWhitespace = cursorPosition.character > 0 ? targetLine.prefix { $0.isWhitespace } : ""
+ let indentation = String(leadingWhitespace)
+
+ // Insert codeblock at the specified position
+ let index = targetLine.index(targetLine.startIndex, offsetBy: min(cursorPosition.character, targetLine.count))
+ let before = targetLine[.. String in
+ return index == 0 ? String(element) : indentation + String(element)
+ }
+
+ var toBeInsertedLines = [String]()
+ toBeInsertedLines.append(String(before) + codeBlockLines.first!)
+ toBeInsertedLines.append(contentsOf: codeBlockLines.dropFirst().dropLast())
+ toBeInsertedLines.append(codeBlockLines.last! + String(after))
+
+ lines.replaceSubrange((cursorPosition.line)...(cursorPosition.line), with: toBeInsertedLines)
+
+ // Join the lines
+ let newContent = String(lines.joined(separator: "\n"))
+
+ // Inject updated content
+ let newCursorPosition = CursorPosition(
+ line: cursorPosition.line + codeBlockLines.count - 1,
+ character: codeBlockLines.last?.count ?? 0
+ )
+ modifications.append(.inserted(cursorPosition.line, toBeInsertedLines))
+ try AXHelper().injectUpdatedCodeWithAccessibilityAPI(
+ .init(
+ content: newContent,
+ newSelection: .cursor(newCursorPosition),
+ modifications: modifications
+ ),
+ focusElement: focusElement,
+ onSuccess: {
+ NSWorkspace.activatePreviousActiveXcode()
+ }
+
+ )
+
+ } catch {
+ print("Failed to insert code block: \(error)")
+ }
+ }
+}
diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift
index 06f8c4d..99ea272 100644
--- a/Core/Sources/ChatService/ChatService.swift
+++ b/Core/Sources/ChatService/ChatService.swift
@@ -5,10 +5,12 @@ import GitHubCopilotService
import Preferences
import ConversationServiceProvider
import BuiltinExtension
+import JSONRPC
+import Status
public protocol ChatServiceType {
var memory: ContextAwareAutoManagedChatMemory { get set }
- func send(_ id: String, content: String) async throws
+ func send(_ id: String, content: String, skillSet: [ConversationSkill], references: [FileReference]) async throws
func stopReceivingMessage() async
func upvote(_ id: String, _ rating: ConversationRating) async
func downvote(_ id: String, _ rating: ConversationRating) async
@@ -20,13 +22,16 @@ public final class ChatService: ChatServiceType, ObservableObject {
public var memory: ContextAwareAutoManagedChatMemory
@Published public internal(set) var chatHistory: [ChatMessage] = []
@Published public internal(set) var isReceivingMessage = false
+ public var chatTemplates: [ChatTemplate]? = nil
+ public static var shared: ChatService = ChatService.service()
private let conversationProvider: ConversationServiceProvider?
private let conversationProgressHandler: ConversationProgressHandler
+ private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared
private var cancellables = Set()
private var activeRequestId: String?
private var conversationId: String?
-
+ private var skillSet: [ConversationSkill] = []
init(provider: any ConversationServiceProvider,
memory: ContextAwareAutoManagedChatMemory = ContextAwareAutoManagedChatMemory(),
conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared) {
@@ -36,6 +41,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
memory.chatService = self
subscribeToNotifications()
+ subscribeToConversationContextRequest()
}
private func subscribeToNotifications() {
@@ -59,6 +65,16 @@ public final class ChatService: ChatServiceType, ObservableObject {
}.store(in: &cancellables)
}
+ private func subscribeToConversationContextRequest() {
+ self.conversationContextHandler.onConversationContext.sink(receiveValue: { [weak self] (request, completion) in
+ guard let skills = self?.skillSet, !skills.isEmpty, request.params!.conversationId == self?.conversationId else { return }
+ skills.forEach { skill in
+ if (skill.applies(params: request.params!)) {
+ skill.resolveSkill(request: request, completion: completion)
+ }
+ }
+ }).store(in: &cancellables)
+ }
public static func service() -> ChatService {
let provider = BuiltinExtensionConversationServiceProvider(
extension: GitHubCopilotExtension.self
@@ -66,20 +82,29 @@ public final class ChatService: ChatServiceType, ObservableObject {
return ChatService(provider: provider)
}
- public func send(_ id: String, content: String) async throws {
+ public func send(_ id: String, content: String, skillSet: Array, references: Array) async throws {
guard activeRequestId == nil else { return }
let workDoneToken = UUID().uuidString
activeRequestId = workDoneToken
- await memory.appendMessage(ChatMessage(id: id, role: .user, content: content, summary: nil, references: []))
-
+ await memory.appendMessage(ChatMessage(id: id, role: .user, content: content, references: []))
+ let skillCapabilities: [String] = [ CurrentEditorSkill.ID, ProblemsInActiveDocumentSkill.ID ]
+ let supportedSkills: [String] = skillSet.map { $0.id }
+ let ignoredSkills: [String] = skillCapabilities.filter {
+ !supportedSkills.contains($0)
+ }
let request = ConversationRequest(workDoneToken: workDoneToken,
- content: content, workspaceFolder: "", skills: [])
+ content: content,
+ workspaceFolder: "",
+ skills: skillCapabilities,
+ ignoredSkills: ignoredSkills,
+ references: references)
+ self.skillSet = skillSet
try await send(request)
}
public func sendAndWait(_ id: String, content: String) async throws -> String {
- try await send(id, content: content)
+ try await send(id, content: content, skillSet: [], references: [])
if let reply = await memory.history.last(where: { $0.role == .assistant })?.content {
return reply
}
@@ -117,7 +142,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
if let message = (await memory.history).first(where: { $0.id == id })
{
do {
- try await send(id, content: message.content)
+ try await send(id, content: message.content, skillSet: [], references: [])
} catch {
print("Failed to resend message")
}
@@ -185,7 +210,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
if let sendingMessageImmediately = info.sendingMessageImmediately,
!sendingMessageImmediately.isEmpty
{
- try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately))
+ try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately), skillSet: [], references: [])
}
}
@@ -201,6 +226,21 @@ public final class ChatService: ChatServiceType, ObservableObject {
// TODO: pass copy code info to Copilot server
}
+ public func loadChatTemplates() async -> [ChatTemplate]? {
+ guard self.chatTemplates == nil else { return self.chatTemplates }
+
+ do {
+ if let templates = (try await conversationProvider?.templates()) {
+ self.chatTemplates = templates
+ return templates
+ }
+ } catch {
+ // handle error if desired
+ }
+
+ return nil
+ }
+
public func handleSingleRoundDialogCommand(
systemPrompt: String?,
overwriteSystemPrompt: Bool,
@@ -210,7 +250,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
return try await sendAndWait(UUID().uuidString, content: templateProcessor.process(prompt))
}
- private func handleProgressBegin(token: String, progress: ConversationProgress) {
+ private func handleProgressBegin(token: String, progress: ConversationProgressBegin) {
guard let workDoneToken = activeRequestId, workDoneToken == token else { return }
conversationId = progress.conversationId
@@ -221,17 +261,82 @@ public final class ChatService: ChatServiceType, ObservableObject {
}
}
- private func handleProgressReport(token: String, progress: ConversationProgress) {
- guard let workDoneToken = activeRequestId, workDoneToken == token, let reply = progress.reply else { return }
+ private func handleProgressReport(token: String, progress: ConversationProgressReport) {
+ guard let workDownToken = activeRequestId, workDownToken == token else {
+ return
+ }
+
+ let id = progress.turnId
+ var content = ""
+ var references: [ConversationReference] = []
+
+ if let reply = progress.reply {
+ content = reply
+ }
+ if let progressReferences = progress.references, !progressReferences.isEmpty {
+ progressReferences.forEach { item in
+ let reference = ConversationReference(
+ uri: item.uri,
+ status: .included,
+ kind: .other
+ )
+ references.append(reference)
+ }
+ }
+
+ if content.isEmpty && references.isEmpty {
+ return
+ }
+
+ // create immutable copies
+ let messageContent = content
+ let messageReferences = references
+
Task {
- let message = ChatMessage(id: progress.turnId, role: .assistant, content: reply)
+ let message = ChatMessage(id: id, role: .assistant, content: messageContent, references: messageReferences)
await memory.appendMessage(message)
}
}
- private func handleProgressEnd(token: String, progress: ConversationProgress) {
+ private func handleProgressEnd(token: String, progress: ConversationProgressEnd) {
guard let workDoneToken = activeRequestId, workDoneToken == token else { return }
+ let followUp = progress.followUp
+
+ if let CLSError = progress.error {
+ // CLS Error Code 402: reached monthly chat messages limit
+ if CLSError.code == 402 {
+ Task {
+ await Status.shared
+ .updateCLSStatus(.error, message: CLSError.message)
+ let errorMessage = ChatMessage(
+ id: progress.turnId,
+ role: .system,
+ content: CLSError.message
+ )
+ await memory.removeMessage(progress.turnId)
+ await memory.appendMessage(errorMessage)
+ }
+ } else {
+ Task {
+ let errorMessage = ChatMessage(
+ id: progress.turnId,
+ role: .assistant,
+ content: "",
+ errorMessage: CLSError.message
+ )
+ await memory.appendMessage(errorMessage)
+ }
+ }
+ resetOngoingRequest()
+ return
+ }
+
+ Task {
+ let message = ChatMessage(id: progress.turnId, role: .assistant, content: "", followUp: followUp, suggestedTitle: progress.suggestedTitle)
+ await memory.appendMessage(message)
+ }
+
resetOngoingRequest()
}
diff --git a/Core/Sources/ChatService/ConversationSkill.swift b/Core/Sources/ChatService/ConversationSkill.swift
new file mode 100644
index 0000000..df2735e
--- /dev/null
+++ b/Core/Sources/ChatService/ConversationSkill.swift
@@ -0,0 +1,8 @@
+import JSONRPC
+import GitHubCopilotService
+
+public protocol ConversationSkill {
+ var id: String { get }
+ func applies(params: ConversationContextParams) -> Bool
+ func resolveSkill(request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void)
+}
diff --git a/Core/Sources/ChatService/CurrentEditorSkill.swift b/Core/Sources/ChatService/CurrentEditorSkill.swift
new file mode 100644
index 0000000..28ab47b
--- /dev/null
+++ b/Core/Sources/ChatService/CurrentEditorSkill.swift
@@ -0,0 +1,33 @@
+import ConversationServiceProvider
+import Foundation
+import GitHubCopilotService
+import JSONRPC
+
+public class CurrentEditorSkill: ConversationSkill {
+ public static let ID = "current-editor"
+ private var currentFile: FileReference
+ public var id: String {
+ return CurrentEditorSkill.ID
+ }
+
+ public init(
+ currentFile: FileReference
+ ) {
+ self.currentFile = currentFile
+ }
+
+ public func applies(params: ConversationContextParams) -> Bool {
+ return params.skillId == self.id
+ }
+
+ public func resolveSkill(request: ConversationContextRequest, completion: (AnyJSONRPCResponse) -> Void){
+ let uri: String? = self.currentFile.url.absoluteString
+ completion(
+ AnyJSONRPCResponse(id: request.id,
+ result: JSONValue.array([
+ JSONValue.hash(["uri" : .string(uri ?? "")]),
+ JSONValue.null
+ ]))
+ )
+ }
+}
diff --git a/Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift b/Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift
new file mode 100644
index 0000000..22f6d3d
--- /dev/null
+++ b/Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift
@@ -0,0 +1,52 @@
+import ConversationServiceProvider
+import Foundation
+import GitHubCopilotService
+import JSONRPC
+import XcodeInspector
+
+public class ProblemsInActiveDocumentSkill: ConversationSkill {
+ public static let ID = "problems-in-active-document"
+ public var id: String {
+ return ProblemsInActiveDocumentSkill.ID
+ }
+
+ public init() {
+ }
+
+ public func applies(params: ConversationContextParams) -> Bool {
+ return params.skillId == self.id
+ }
+
+ public func resolveSkill(request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) {
+ Task {
+ let editor = await XcodeInspector.shared.getFocusedEditorContent()
+ let result: JSONValue = JSONValue.hash([
+ "uri": JSONValue.string(editor?.documentURL.absoluteString ?? ""),
+ "problems": JSONValue.array(editor?.editorContent?.lineAnnotations.map { annotation in
+ JSONValue.hash([
+ "message": JSONValue.string(annotation.message),
+ "range": JSONValue.hash([
+ "start": JSONValue.hash([
+ "line": JSONValue.number(Double(annotation.line)),
+ "character": JSONValue.number(0)
+ ]),
+ "end": JSONValue.hash([
+ "line": JSONValue.number(Double(annotation.line)),
+ "character": JSONValue.number(0)
+ ])
+ ])
+ ])
+ } ?? [])
+ ])
+
+ completion(
+ AnyJSONRPCResponse(id: request.id,
+ result: JSONValue.array([
+ result,
+ JSONValue.null
+ ]))
+ )
+ }
+ }
+}
+
diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift
index c7f2649..eaee3a5 100644
--- a/Core/Sources/ConversationTab/Chat.swift
+++ b/Core/Sources/ConversationTab/Chat.swift
@@ -10,44 +10,26 @@ public struct DisplayedChatMessage: Equatable {
public enum Role: Equatable {
case user
case assistant
- case tool
+ case system
case ignored
}
- public struct Reference: Equatable {
- public typealias Kind = ChatMessage.Reference.Kind
-
- public var title: String
- public var subtitle: String
- public var uri: String
- public var startLine: Int?
- public var kind: Kind
-
- public init(
- title: String,
- subtitle: String,
- uri: String,
- startLine: Int?,
- kind: Kind
- ) {
- self.title = title
- self.subtitle = subtitle
- self.uri = uri
- self.startLine = startLine
- self.kind = kind
- }
- }
-
public var id: String
public var role: Role
public var text: String
- public var references: [Reference] = []
+ public var references: [ConversationReference] = []
+ public var followUp: ConversationFollowUp? = nil
+ public var suggestedTitle: String? = nil
+ public var errorMessage: String? = nil
- public init(id: String, role: Role, text: String, references: [Reference]) {
+ public init(id: String, role: Role, text: String, references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, errorMessage: String? = nil) {
self.id = id
self.role = role
self.text = text
self.references = references
+ self.followUp = followUp
+ self.suggestedTitle = suggestedTitle
+ self.errorMessage = errorMessage
}
}
@@ -61,12 +43,16 @@ struct Chat {
@ObservableState
struct State: Equatable {
- var title: String = "Chat"
+ var title: String = "New Chat"
+ var isTitleSet: Bool = false
+
var typedMessage = ""
var history: [DisplayedChatMessage] = []
var isReceivingMessage = false
var chatMenu = ChatMenu.State()
var focusedField: Field?
+ var currentEditor: FileReference? = nil
+ var selectedFiles: [FileReference] = []
enum Field: String, Hashable {
case textField
@@ -86,10 +72,11 @@ struct Chat {
case resendMessageButtonTapped(MessageID)
case setAsExtraPromptButtonTapped(MessageID)
case focusOnTextField
- case referenceClicked(DisplayedChatMessage.Reference)
+ case referenceClicked(ConversationReference)
case upvote(MessageID, ConversationRating)
case downvote(MessageID, ConversationRating)
case copyCode(MessageID)
+ case insertCode(String)
case observeChatService
case observeHistoryChange
@@ -99,6 +86,15 @@ struct Chat {
case isReceivingMessageChanged
case chatMenu(ChatMenu.Action)
+
+ // context
+ case addSelectedFile(FileReference)
+ case removeSelectedFile(FileReference)
+ case resetCurrentEditor
+ case setCurrentEditor(FileReference)
+
+ case followUpButtonClicked(String, String)
+ case setTitle(DisplayedChatMessage)
}
let service: ChatService
@@ -137,11 +133,25 @@ struct Chat {
}
case let .sendButtonTapped(id):
- guard !state.typedMessage.isEmpty else { return .none }
+ guard !state.typedMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .none }
let message = state.typedMessage
+ let skillSet = state.buildSkillSet()
state.typedMessage = ""
+
+ let selectedFiles = state.selectedFiles
+
return .run { _ in
- try await service.send(id, content: message)
+ try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles)
+ }.cancellable(id: CancelID.sendMessage(self.id))
+
+ case let .followUpButtonClicked(id, message):
+ guard !message.isEmpty else { return .none }
+ let skillSet = state.buildSkillSet()
+
+ let selectedFiles = state.selectedFiles
+
+ return .run { _ in
+ try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles)
}.cancellable(id: CancelID.sendMessage(self.id))
case .returnButtonTapped:
@@ -177,7 +187,9 @@ struct Chat {
}
case let .referenceClicked(reference):
- let fileURL = URL(fileURLWithPath: reference.uri)
+ guard let fileURL = reference.url else {
+ return .none
+ }
return .run { _ in
if FileManager.default.fileExists(atPath: fileURL.path) {
let terminal = Terminal()
@@ -186,7 +198,7 @@ struct Chat {
"/bin/bash",
arguments: [
"-c",
- "xed -l \(reference.startLine ?? 0) \"\(reference.uri)\"",
+ "xed -l 0 \"\(reference.filePath)\"",
],
environment: [:]
)
@@ -253,42 +265,41 @@ struct Chat {
id: message.id,
role: {
switch message.role {
- case .system: return .ignored
+ case .system: return .system
case .user: return .user
case .assistant: return .assistant
}
}(),
- text: message.summary ?? message.content,
+ text: message.content,
references: message.references.map {
.init(
- title: $0.title,
- subtitle: $0.subTitle,
uri: $0.uri,
- startLine: $0.startLine,
+ status: $0.status,
kind: $0.kind
)
- }
+ },
+ followUp: message.followUp,
+ suggestedTitle: message.suggestedTitle,
+ errorMessage: message.errorMessage
))
return all
}
-
- state.title = {
- let defaultTitle = "Chat"
- guard let lastMessageText = state.history
- .filter({ $0.role == .assistant || $0.role == .user })
- .last?
- .text else { return defaultTitle }
- if lastMessageText.isEmpty { return defaultTitle }
- let trimmed = lastMessageText
- .trimmingCharacters(in: .punctuationCharacters)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.starts(with: "```") {
- return "Code Block"
- } else {
- return trimmed
- }
- }()
+
+ guard let lastChatMessage = state.history.last else { return .none }
+ return .run { send in
+ await send(.setTitle(lastChatMessage))
+ }
+
+ case let .setTitle(message):
+ guard state.isTitleSet == false,
+ message.role == .assistant,
+ let suggestedTitle = message.suggestedTitle
+ else { return .none }
+
+ state.title = suggestedTitle
+ state.isTitleSet = true
+
return .none
case .isReceivingMessageChanged:
@@ -312,6 +323,25 @@ struct Chat {
return .run { _ in
await service.copyCode(id)
}
+
+ case let .insertCode(code):
+ ChatInjector().insertCodeBlock(codeBlock: code)
+ return .none
+
+ case let .addSelectedFile(fileReference):
+ guard !state.selectedFiles.contains(fileReference) else { return .none }
+ state.selectedFiles.append(fileReference)
+ return .none
+ case let .removeSelectedFile(fileReference):
+ guard let index = state.selectedFiles.firstIndex(of: fileReference) else { return .none }
+ state.selectedFiles.remove(at: index)
+ return .none
+ case .resetCurrentEditor:
+ state.currentEditor = nil
+ return .none
+ case let .setCurrentEditor(fileReference):
+ state.currentEditor = fileReference
+ return .none
}
}
}
diff --git a/Core/Sources/ConversationTab/ChatContextMenu.swift b/Core/Sources/ConversationTab/ChatContextMenu.swift
index c47f3c4..3e1ac09 100644
--- a/Core/Sources/ConversationTab/ChatContextMenu.swift
+++ b/Core/Sources/ConversationTab/ChatContextMenu.swift
@@ -14,6 +14,17 @@ struct ChatTabItemView: View {
}
}
+struct ChatConversationItemView: View {
+ let chat: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ Text(chat.title)
+ .frame(alignment: .leading)
+ }
+ }
+}
+
struct ChatContextMenu: View {
let store: StoreOf
@AppStorage(\.customCommands) var customCommands
diff --git a/Core/Sources/ConversationTab/ChatExtension.swift b/Core/Sources/ConversationTab/ChatExtension.swift
new file mode 100644
index 0000000..0e3537b
--- /dev/null
+++ b/Core/Sources/ConversationTab/ChatExtension.swift
@@ -0,0 +1,17 @@
+import ChatService
+import ConversationServiceProvider
+
+extension Chat.State {
+ func buildSkillSet() -> [ConversationSkill] {
+ guard let currentFile = self.currentEditor else {
+ return []
+ }
+ let fileReference = FileReference(
+ url: currentFile.url,
+ relativePath: currentFile.relativePath,
+ fileName: currentFile.fileName,
+ isCurrentEditor: currentFile.isCurrentEditor
+ )
+ return [CurrentEditorSkill(currentFile: fileReference), ProblemsInActiveDocumentSkill()]
+ }
+}
diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift
index e53730e..ff231d6 100644
--- a/Core/Sources/ConversationTab/ChatPanel.swift
+++ b/Core/Sources/ConversationTab/ChatPanel.swift
@@ -1,11 +1,14 @@
import AppKit
import Combine
import ComposableArchitecture
+import ConversationServiceProvider
import MarkdownUI
import ChatAPIService
import SharedUIComponents
import SwiftUI
import ChatService
+import SwiftUIFlowLayout
+import XcodeInspector
private let r: Double = 8
@@ -15,10 +18,33 @@ public struct ChatPanel: View {
public var body: some View {
VStack(spacing: 0) {
- ChatPanelMessages(chat: chat)
- Divider()
+
+ if chat.history.isEmpty {
+ VStack {
+ Spacer()
+ Instruction()
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
+ .padding(.leading, -16)
+ } else {
+ ChatPanelMessages(chat: chat)
+
+ if chat.history.last?.role == .system {
+ ChatCLSError(chat: chat).padding(.trailing, 16)
+ } else {
+ ChatFollowUp(chat: chat)
+ .padding(.trailing, 16)
+ .padding(.vertical, 8)
+
+ }
+ }
+
ChatPanelInputArea(chat: chat)
+ .padding(.trailing, 16)
}
+ .padding(.leading, 16)
+ .padding(.bottom, 16)
.background(Color(nsColor: .windowBackgroundColor))
.onAppear { chat.send(.appear) }
}
@@ -60,10 +86,6 @@ struct ChatPanelMessages: View {
GeometryReader { listGeo in
List {
Group {
- Spacer(minLength: 12)
- .id(topID)
-
- Instruction(chat: chat)
ChatHistory(chat: chat)
.listItemTint(.clear)
@@ -94,11 +116,11 @@ struct ChatPanelMessages: View {
if #available(macOS 13.0, *) {
view
.listRowSeparator(.hidden)
- .listSectionSeparator(.hidden)
} else {
view
}
}
+ .padding(.leading, -8)
}
.listStyle(.plain)
.listRowBackground(EmptyView())
@@ -190,8 +212,8 @@ struct ChatPanelMessages: View {
proxy.scrollTo(bottomID, anchor: .bottom)
}
}) {
- Image(systemName: "arrow.down")
- .padding(4)
+ Image(systemName: "chevron.down")
+ .padding(8)
.background {
Circle()
.fill(.thickMaterial)
@@ -201,11 +223,12 @@ struct ChatPanelMessages: View {
Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1)
}
.foregroundStyle(.secondary)
- .padding(4)
}
+ .buttonStyle(HoverButtonStyle(padding: 0))
+ .padding(4)
.keyboardShortcut(.downArrow, modifiers: [.command])
.opacity(isScrollToBottomButtonDisplayed ? 1 : 0)
- .buttonStyle(.plain)
+ .help("Scroll Down")
}
struct ExtraSpacingInResponding: View {
@@ -240,6 +263,8 @@ struct ChatPanelMessages: View {
scrollToBottom()
}
}
+ } else {
+ Task { pinnedToBottom = false }
}
}
.onChange(of: chat.history.last) { _ in
@@ -271,9 +296,18 @@ struct ChatHistory: View {
var body: some View {
WithPerceptionTracking {
- ForEach(chat.history, id: \.id) { message in
- WithPerceptionTracking {
- ChatHistoryItem(chat: chat, message: message).id(message.id)
+ ForEach(Array(chat.history.enumerated()), id: \.element.id) { index, message in
+ VStack(spacing: 0) {
+ WithPerceptionTracking {
+ ChatHistoryItem(chat: chat, message: message)
+ .id(message.id)
+ .padding(.top, 4)
+ .padding(.bottom, 12)
+ }
+
+ // add divider between messages
+ if message.role != .ignored && index < chat.history.count - 1 {
+ Divider() }
}
}
}
@@ -290,29 +324,17 @@ struct ChatHistoryItem: View {
switch message.role {
case .user:
UserMessage(id: message.id, text: text, chat: chat)
- .listRowInsets(EdgeInsets(
- top: 0,
- leading: -8,
- bottom: 0,
- trailing: -8
- ))
- .padding(.vertical, 4)
case .assistant:
BotMessage(
id: message.id,
text: text,
references: message.references,
+ followUp: message.followUp,
+ errorMessage: message.errorMessage,
chat: chat
)
- .listRowInsets(EdgeInsets(
- top: 0,
- leading: -8,
- bottom: 0,
- trailing: -8
- ))
- .padding(.vertical, 4)
- case .tool:
- FunctionMessage(id: message.id, text: text)
+ case .system:
+ FunctionMessage(chat: chat, id: message.id, text: text)
case .ignored:
EmptyView()
}
@@ -357,17 +379,79 @@ private struct StopRespondingButton: View {
}
}
+struct ChatFollowUp: View {
+ let chat: StoreOf
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ if let followUp = chat.history.last?.followUp {
+ Button(action: {
+ chat.send(.followUpButtonClicked(UUID().uuidString, followUp.message))
+ }) {
+ HStack(spacing: 4) {
+ Image(systemName: "sparkles")
+ .foregroundColor(.blue)
+
+ Text(followUp.message)
+ .font(.system(size: chatFontSize))
+ .foregroundColor(.blue)
+ }
+ }
+ .buttonStyle(.plain)
+ .onHover { isHovered in
+ if isHovered {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+}
+
+struct ChatCLSError: View {
+ let chat: StoreOf
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack(alignment: .top) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.blue)
+ .padding(.leading, 8)
+
+ Text("Monthly chat limit reached. [Upgrade now](https://github.com/github-copilot/signup/copilot_individual) or wait until your usage resets.")
+ .font(.system(size: chatFontSize))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 8)
+ .background(
+ RoundedCorners(tl: r, tr: r, bl: 0, br: 0)
+ .fill(.ultraThickMaterial)
+ )
+ .overlay(
+ RoundedCorners(tl: r, tr: r, bl: 0, br: 0)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ .padding(.top, 4)
+ }
+ }
+}
+
struct ChatPanelInputArea: View {
let chat: StoreOf
@FocusState var focusedField: Chat.State.Field?
var body: some View {
HStack {
- clearButton
InputAreaTextEditor(chat: chat, focusedField: $focusedField)
}
- .padding(8)
- .background(.ultraThickMaterial)
+ .background(Color.clear)
}
@MainActor
@@ -396,36 +480,103 @@ struct ChatPanelInputArea: View {
struct InputAreaTextEditor: View {
@Perception.Bindable var chat: StoreOf
var focusedField: FocusState.Binding
+ @State var cancellable = Set()
+ @State private var isFilePickerPresented = false
+ @State private var allFiles: [FileReference] = []
+ @State private var searchText = ""
+ @State private var selectedFiles: [FileReference] = []
+ @State private var filteredTemplates: [ChatTemplate] = []
+ @State private var showingTemplates = false
var body: some View {
WithPerceptionTracking {
- HStack(spacing: 0) {
- AutoresizingCustomTextEditor(
- text: $chat.typedMessage,
- font: .systemFont(ofSize: 14),
- isEditable: true,
- maxHeight: 400,
- onSubmit: {
- chat.send(.sendButtonTapped(UUID().uuidString))
- },
- completions: chatAutoCompletion
- )
- .focused(focusedField, equals: .textField)
- .bind($chat.focusedField, to: focusedField)
- .padding(8)
- .fixedSize(horizontal: false, vertical: true)
+ VStack(spacing: 0) {
+ ZStack(alignment: .topLeading) {
+ if chat.typedMessage.isEmpty {
+ Text("Ask Copilot")
+ .font(.system(size: 14))
+ .foregroundColor(Color(nsColor: .placeholderTextColor))
+ .padding(8)
+ .padding(.horizontal, 4)
+ }
- Button(action: {
- chat.send(.sendButtonTapped(UUID().uuidString))
- }) {
- Image(systemName: "paperplane.fill")
+ HStack(spacing: 0) {
+ AutoresizingCustomTextEditor(
+ text: $chat.typedMessage,
+ font: .systemFont(ofSize: 14),
+ isEditable: true,
+ maxHeight: 400,
+ onSubmit: {
+ if (!showingTemplates) {
+ submitChatMessage()
+ }
+ showingTemplates = false
+ },
+ completions: chatAutoCompletion
+ )
+ .focused(focusedField, equals: .textField)
+ .bind($chat.focusedField, to: focusedField)
.padding(8)
+ .fixedSize(horizontal: false, vertical: true)
+ .onChange(of: chat.typedMessage) { newValue in
+ Task {
+ filteredTemplates = await chatTemplateCompletion(text: newValue)
+ showingTemplates = !filteredTemplates.isEmpty
+ }
+ }
+ }
+ .frame(maxWidth: .infinity)
}
- .buttonStyle(.plain)
- .disabled(chat.isReceivingMessage)
- .keyboardShortcut(KeyEquivalent.return, modifiers: [])
+ .padding(.top, 4)
+
+ attachedFilesView
+
+ if isFilePickerPresented {
+ filePickerView
+ .transition(.move(edge: .bottom))
+ .onAppear() {
+ allFiles = ContextUtils.getFilesInActiveWorkspace()
+ }
+ }
+
+ HStack(spacing: 0) {
+ Button(action: {
+ withAnimation {
+ isFilePickerPresented.toggle()
+ }
+ }) {
+ Image(systemName: "paperclip")
+ .padding(4)
+ }
+ .buttonStyle(HoverButtonStyle(padding: 0))
+ .help("Attach Context")
+
+ Spacer()
+
+ Button(action: {
+ submitChatMessage()
+ }) {
+ Image(systemName: "paperplane.fill")
+ .padding(4)
+ }
+ .buttonStyle(HoverButtonStyle(padding: 0))
+ .disabled(chat.isReceivingMessage)
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [])
+ .help("Send")
+ }
+ .padding(8)
+ .padding(.top, -4)
+ }
+ .overlay(alignment: .top) {
+ if showingTemplates {
+ ChatTemplateDropdownView(templates: $filteredTemplates) { template in
+ chat.typedMessage = "/" + template.id + " "
+ }
+ }
+ }
+ .onAppear() {
+ subscribeToActiveDocumentChangeEvent()
}
- .frame(maxWidth: .infinity)
.background {
RoundedRectangle(cornerRadius: 6)
.fill(Color(nsColor: .controlBackgroundColor))
@@ -452,11 +603,146 @@ struct ChatPanelInputArea: View {
}
}
+ private var attachedFilesView: some View {
+ FlowLayout(mode: .scrollable, items: [chat.state.currentEditor] + chat.state.selectedFiles, itemSpacing: 4) { file in
+ if let select = file {
+ HStack(spacing: 4) {
+ drawFileIcon(select.url)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ .foregroundColor(.secondary)
+
+ Text(select.url.lastPathComponent)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .help(select.getPathRelativeToHome())
+
+ Button(action: {
+ if select.isCurrentEditor {
+ chat.send(.resetCurrentEditor)
+ } else {
+ chat.send(.removeSelectedFile(select))
+ }
+ }) {
+ Image(systemName: "xmark")
+ .resizable()
+ .frame(width: 8, height: 8)
+ .foregroundColor(.secondary)
+ }
+ .buttonStyle(HoverButtonStyle())
+ .help("Remove from Context")
+ }
+ .padding(4)
+ .cornerRadius(6)
+ .shadow(radius: 2)
+// .background(
+// RoundedRectangle(cornerRadius: r)
+// .fill(.ultraThickMaterial)
+// )
+ .overlay(
+ RoundedRectangle(cornerRadius: r)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ }
+ }
+ .padding(.horizontal, 8)
+ }
+
+ private var filePickerView: some View {
+ VStack(spacing: 8) {
+ HStack {
+ Image(systemName: "magnifyingglass")
+ .foregroundColor(.secondary)
+
+ TextField("Search files...", text: $searchText)
+ .textFieldStyle(PlainTextFieldStyle())
+ .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor))
+
+ Button(action: {
+ withAnimation {
+ isFilePickerPresented = false
+ }
+ }) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.secondary)
+ }
+ .buttonStyle(HoverButtonStyle())
+ .help("Close")
+ }
+ .padding(8)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .fill(Color.gray.opacity(0.1))
+ )
+ .cornerRadius(6)
+ .padding(.horizontal, 4)
+ .padding(.top, 4)
+
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 4) {
+ ForEach(filteredFiles, id: \.self) { doc in
+ FileRowView(doc: doc)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ chat.send(.addSelectedFile(doc))
+ }
+ }
+
+ if filteredFiles.isEmpty {
+ Text("No results found")
+ .foregroundColor(.secondary)
+ .padding(.leading, 4)
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ .frame(maxHeight: 200)
+ .padding(.horizontal, 4)
+ .padding(.bottom, 4)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(6)
+ .shadow(radius: 2)
+// .background(
+// RoundedRectangle(cornerRadius: r)
+// .fill(.ultraThickMaterial)
+// )
+ .overlay(
+ RoundedRectangle(cornerRadius: r)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ .padding(.horizontal, 12)
+ }
+
+ private var filteredFiles: [FileReference] {
+ if searchText.isEmpty {
+ return allFiles
+ }
+
+ return allFiles.filter { doc in
+ (doc.fileName ?? doc.url.lastPathComponent) .localizedCaseInsensitiveContains(searchText)
+ }
+ }
+
+ func chatTemplateCompletion(text: String) async -> [ChatTemplate] {
+ guard text.count >= 1 && text.first == "/" else { return [] }
+ let prefix = text.dropFirst()
+ let templates = await ChatService.shared.loadChatTemplates() ?? []
+ guard !templates.isEmpty else {
+ return []
+ }
+
+ let skippedTemplates = [ "feedback", "help" ]
+ return templates.filter { $0.scopes.contains(.chatPanel) &&
+ $0.id.hasPrefix(prefix) && !skippedTemplates.contains($0.id)}
+ }
+
func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] {
guard text.count == 1 else { return [] }
let plugins = [String]() // chat.pluginIdentifiers.map { "/\($0)" }
let availableFeatures = plugins + [
- "/exit",
+// "/exit",
"@code",
"@sense",
"@project",
@@ -475,6 +761,51 @@ struct ChatPanelInputArea: View {
}
return result
}
+ func subscribeToActiveDocumentChangeEvent() {
+ XcodeInspector.shared.$activeDocumentURL.receive(on: DispatchQueue.main)
+ .sink { newDocURL in
+ if supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") {
+ let currentEditor = FileReference(url: newDocURL!, isCurrentEditor: true)
+ chat.send(.setCurrentEditor(currentEditor))
+ }
+ }
+ .store(in: &cancellable)
+ }
+
+ func submitChatMessage() {
+ chat.send(.sendButtonTapped(UUID().uuidString))
+ }
+ }
+
+ struct FileRowView: View {
+ @State private var isHovered = false
+ let doc: FileReference
+
+ var body: some View {
+ HStack {
+ drawFileIcon(doc.url)
+ .resizable()
+ .frame(width: 16, height: 16)
+ .foregroundColor(.secondary)
+ .padding(.leading, 4)
+
+ VStack(alignment: .leading) {
+ Text(doc.fileName ?? doc.url.lastPathComponent)
+ .font(.body)
+ .hoverPrimaryForeground(isHovered: isHovered)
+ Text(doc.relativePath ?? doc.url.path)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+ }
+ .padding(.vertical, 4)
+ .hoverRadiusBackground(isHovered: isHovered, cornerRadius: 6)
+ .onHover(perform: { hovering in
+ isHovered = hovering
+ })
+ }
}
}
@@ -499,10 +830,8 @@ struct ChatPanel_Preview: PreviewProvider {
""",
references: [
.init(
- title: "Hello Hello Hello Hello",
- subtitle: "Hi Hi Hi Hi",
- uri: "https://google.com",
- startLine: nil,
+ uri: "Hi Hi Hi Hi",
+ status: .included,
kind: .class
),
]
@@ -513,18 +842,6 @@ struct ChatPanel_Preview: PreviewProvider {
text: "Ignored",
references: []
),
- .init(
- id: "6",
- role: .tool,
- text: """
- Searching for something...
- - abc
- - [def](https://1.com)
- > hello
- > hi
- """,
- references: []
- ),
.init(
id: "5",
role: .assistant,
@@ -554,7 +871,8 @@ struct ChatPanel_Preview: PreviewProvider {
- (void)bar {}
```
"""#,
- references: []
+ references: [],
+ followUp: .init(message: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce turpis dolor, malesuada quis fringilla sit amet, placerat at nunc. Suspendisse orci tortor, tempor nec blandit a, malesuada vel tellus. Nunc sed leo ligula. Ut at ligula eget turpis pharetra tristique. Integer luctus leo non elit rhoncus fermentum.", id: "3", type: "type")
),
]
diff --git a/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift b/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift
new file mode 100644
index 0000000..f99167a
--- /dev/null
+++ b/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift
@@ -0,0 +1,105 @@
+import ConversationServiceProvider
+import AppKit
+import SwiftUI
+
+public struct ChatTemplateDropdownView: View {
+ @Binding var templates: [ChatTemplate]
+ let onSelect: (ChatTemplate) -> Void
+ @State private var selectedIndex = 0
+ @State private var frameHeight: CGFloat = 0
+ @State private var localMonitor: Any? = nil
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(Array(templates.enumerated()), id: \.element.id) { index, template in
+ HStack {
+ Text("/" + template.id)
+ .hoverPrimaryForeground(isHovered: selectedIndex == index)
+ Spacer()
+ Text(template.shortDescription)
+ .hoverSecondaryForeground(isHovered: selectedIndex == index)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ onSelect(template)
+ }
+ .hoverBackground(isHovered: selectedIndex == index)
+ .onHover { isHovered in
+ if isHovered {
+ selectedIndex = index
+ }
+ }
+ }
+ }
+ .background(
+ GeometryReader { geometry in
+ Color.clear
+ .onAppear { frameHeight = geometry.size.height }
+ .onChange(of: geometry.size.height) { newHeight in
+ frameHeight = newHeight
+ }
+ }
+ )
+ .background(.ultraThickMaterial)
+ .cornerRadius(6)
+ .shadow(radius: 2)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+ .frame(maxWidth: .infinity)
+ .offset(y: -1 * frameHeight)
+ .onChange(of: templates) { _ in
+ selectedIndex = 0
+ }
+ .onAppear {
+ selectedIndex = 0
+ localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+ switch event.keyCode {
+ case 126: // Up arrow
+ moveSelection(up: true)
+ case 125: // Down arrow
+ moveSelection(up: false)
+ case 36: // Return key
+ handleEnter()
+ case 48: // Tab key
+ handleTab()
+ return nil // not forwarding the Tab Event which will replace the typed message to "\t"
+ default:
+ break
+ }
+ return event
+ }
+ }
+ .onDisappear {
+ if let monitor = localMonitor {
+ NSEvent.removeMonitor(monitor)
+ localMonitor = nil
+ }
+ }
+ }
+
+ private func moveSelection(up: Bool) {
+ guard !templates.isEmpty else { return }
+ let lowerBound = 0
+ let upperBound = templates.count - 1
+ let newIndex = selectedIndex + (up ? -1 : 1)
+ selectedIndex = newIndex < lowerBound ? upperBound : (newIndex > upperBound ? lowerBound : newIndex)
+ }
+
+ private func handleEnter() {
+ handleTemplateSelection()
+ }
+
+ private func handleTab() {
+ handleTemplateSelection()
+ }
+
+ private func handleTemplateSelection() {
+ if templates.count > 0 && selectedIndex < templates.count {
+ onSelect(templates[selectedIndex])
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift
index cfbde1c..553f597 100644
--- a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift
+++ b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift
@@ -86,10 +86,13 @@ struct AsyncCodeBlockView: View {
Group {
if let highlighted = storage.highlighted {
Text(highlighted)
+ .frame(maxWidth: .infinity, alignment: .leading)
} else {
Text(content).font(.init(font))
+ .frame(maxWidth: .infinity, alignment: .leading)
}
}
+ .frame(maxWidth: .infinity)
.onAppear {
storage.highlight(debounce: false, for: self)
}
diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift
new file mode 100644
index 0000000..71277a6
--- /dev/null
+++ b/Core/Sources/ConversationTab/ContextUtils.swift
@@ -0,0 +1,77 @@
+import ConversationServiceProvider
+import XcodeInspector
+import Foundation
+import Logger
+
+public let supportedFileExtensions: Set = ["swift", "m", "mm", "h", "cpp", "c", "js", "py", "rb", "java", "applescript", "scpt", "plist", "entitlements"]
+private let skipPatterns: [String] = [
+ ".git",
+ ".svn",
+ ".hg",
+ "CVS",
+ ".DS_Store",
+ "Thumbs.db",
+ "node_modules",
+ "bower_components"
+]
+
+public struct ContextUtils {
+ static func matchesPatterns(_ url: URL, patterns: [String]) -> Bool {
+ let fileName = url.lastPathComponent
+ for pattern in patterns {
+ if fnmatch(pattern, fileName, 0) == 0 {
+ return true
+ }
+ }
+ return false
+ }
+
+ public static func getFilesInActiveWorkspace() -> [FileReference] {
+ guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL,
+ let projectURL = XcodeInspector.shared.realtimeActiveProjectURL else {
+ return []
+ }
+
+ do {
+ let fileManager = FileManager.default
+ let enumerator = fileManager.enumerator(
+ at: projectURL,
+ includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey],
+ options: [.skipsHiddenFiles]
+ )
+
+ var files: [FileReference] = []
+ while let fileURL = enumerator?.nextObject() as? URL {
+ // Skip items matching the specified pattern
+ if matchesPatterns(fileURL, patterns: skipPatterns) {
+ enumerator?.skipDescendants()
+ continue
+ }
+
+ let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey])
+ // Handle directories if needed
+ if resourceValues.isDirectory == true {
+ continue
+ }
+
+ guard resourceValues.isRegularFile == true else { continue }
+ if supportedFileExtensions.contains(fileURL.pathExtension.lowercased()) == false {
+ continue
+ }
+
+ let relativePath = fileURL.path.replacingOccurrences(of: projectURL.path, with: "")
+ let fileName = fileURL.lastPathComponent
+
+ let file = FileReference(url: fileURL,
+ relativePath: relativePath,
+ fileName: fileName)
+ files.append(file)
+ }
+
+ return files
+ } catch {
+ Logger.client.error("Failed to get files in workspace: \(error)")
+ return []
+ }
+ }
+}
diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift
index 440c91f..0aa6026 100644
--- a/Core/Sources/ConversationTab/ConversationTab.swift
+++ b/Core/Sources/ConversationTab/ConversationTab.swift
@@ -11,6 +11,7 @@ import SwiftUI
/// A chat tab that provides a context aware chat bot, powered by Chat.
public class ConversationTab: ChatTab {
+
public static var name: String { "Chat" }
public let service: ChatService
@@ -18,6 +19,13 @@ public class ConversationTab: ChatTab {
private var cancellable = Set()
private var observer = NSObject()
private let updateContentDebounce = DebounceRunner(duration: 0.5)
+
+ // Get chat tab title. As the tab title is always "Chat" and won't be modified.
+ // Use the chat title as the tab title.
+ // TODO: modify tab title dynamicly
+ public func getChatTabTitle() -> String {
+ return chat.title
+ }
struct RestorableState: Codable {
var history: [ChatAPIService.ChatMessage]
@@ -45,6 +53,10 @@ public class ConversationTab: ChatTab {
public func buildTabItem() -> any View {
ChatTabItemView(chat: chat)
}
+
+ public func buildChatConversationItem() -> any View {
+ ChatConversationItemView(chat: chat)
+ }
public func buildIcon() -> any View {
WithPerceptionTracking {
diff --git a/Core/Sources/ConversationTab/Styles.swift b/Core/Sources/ConversationTab/Styles.swift
index 6c117c9..a4b5ddf 100644
--- a/Core/Sources/ConversationTab/Styles.swift
+++ b/Core/Sources/ConversationTab/Styles.swift
@@ -42,14 +42,16 @@ extension View {
FontFamilyVariant(.monospaced)
FontSize(.em(0.85))
}
- .padding(16)
- .padding(.top, 14)
+ .padding(.leading, 8)
+ .padding(.top, 24)
+ .padding(.bottom, 8)
}
func codeBlockStyle(
_ configuration: CodeBlockConfiguration,
backgroundColor: Color,
- labelColor: Color
+ labelColor: Color,
+ insertAction: (() -> Void)? = nil
) -> some View {
background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 6))
@@ -61,16 +63,27 @@ extension View {
.padding(.leading, 8)
.lineLimit(1)
Spacer()
- CopyButton {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(configuration.content, forType: .string)
+
+ HStack(spacing: 4) {
+ CopyButton {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(configuration.content, forType: .string)
+ }
+
+ InsertButton {
+ if let insertAction = insertAction {
+ insertAction()
+ }
+ }
}
}
+ .padding(.trailing, 8)
}
.overlay {
RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.05), lineWidth: 1)
}
.markdownMargin(top: 4, bottom: 16)
+ .frame(maxWidth: .infinity)
}
}
@@ -165,3 +178,11 @@ struct RoundedCorners: Shape {
}
}
+// Chat Message Styles
+extension View {
+ func chatMessageHeaderTextStyle() -> some View {
+ // semibold -> 600
+ font(.system(size: 13, weight: .semibold))
+ }
+}
+
diff --git a/Core/Sources/ConversationTab/ViewExtension.swift b/Core/Sources/ConversationTab/ViewExtension.swift
new file mode 100644
index 0000000..6a4a0f8
--- /dev/null
+++ b/Core/Sources/ConversationTab/ViewExtension.swift
@@ -0,0 +1,59 @@
+import SwiftUI
+
+let BLUE_IN_LIGHT_THEME = Color(red: 98/255, green: 154/255, blue: 248/255)
+let BLUE_IN_DARK_THEME = Color(red: 55/255, green: 108/255, blue: 194/255)
+
+struct HoverBackgroundModifier: ViewModifier {
+ @Environment(\.colorScheme) var colorScheme
+ var isHovered: Bool
+
+ func body(content: Content) -> some View {
+ content
+ .background(isHovered ? (colorScheme == .dark ? BLUE_IN_DARK_THEME : BLUE_IN_LIGHT_THEME) : Color.clear)
+ }
+}
+
+struct HoverRadiusBackgroundModifier: ViewModifier {
+ @Environment(\.colorScheme) var colorScheme
+ var isHovered: Bool
+ var cornerRadius: CGFloat = 0
+
+ func body(content: Content) -> some View {
+ content.background(
+ RoundedRectangle(cornerRadius: cornerRadius)
+ .fill(isHovered ? (colorScheme == .dark ? BLUE_IN_DARK_THEME : BLUE_IN_LIGHT_THEME) : Color.clear)
+ )
+ }
+}
+
+struct HoverForegroundModifier: ViewModifier {
+ @Environment(\.colorScheme) var colorScheme
+ var isHovered: Bool
+ var defaultColor: Color
+
+ func body(content: Content) -> some View {
+ content.foregroundColor(isHovered ? Color.white : defaultColor)
+ }
+}
+
+extension View {
+ public func hoverBackground(isHovered: Bool) -> some View {
+ self.modifier(HoverBackgroundModifier(isHovered: isHovered))
+ }
+
+ public func hoverRadiusBackground(isHovered: Bool, cornerRadius: CGFloat) -> some View {
+ self.modifier(HoverRadiusBackgroundModifier(isHovered: isHovered, cornerRadius: cornerRadius))
+ }
+
+ public func hoverForeground(isHovered: Bool, defaultColor: Color) -> some View {
+ self.modifier(HoverForegroundModifier(isHovered: isHovered, defaultColor: defaultColor))
+ }
+
+ public func hoverPrimaryForeground(isHovered: Bool) -> some View {
+ self.hoverForeground(isHovered: isHovered, defaultColor: .primary)
+ }
+
+ public func hoverSecondaryForeground(isHovered: Bool) -> some View {
+ self.hoverForeground(isHovered: isHovered, defaultColor: .secondary)
+ }
+}
diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift
index d71fc31..511d603 100644
--- a/Core/Sources/ConversationTab/Views/BotMessage.swift
+++ b/Core/Sources/ConversationTab/Views/BotMessage.swift
@@ -4,76 +4,128 @@ import Foundation
import MarkdownUI
import SharedUIComponents
import SwiftUI
+import ConversationServiceProvider
+
struct BotMessage: View {
var r: Double { messageBubbleCornerRadius }
let id: String
let text: String
- let references: [DisplayedChatMessage.Reference]
+ let references: [ConversationReference]
+ let followUp: ConversationFollowUp?
+ let errorMessage: String?
let chat: StoreOf
@Environment(\.colorScheme) var colorScheme
+ @AppStorage(\.chatFontSize) var chatFontSize
@State var isReferencesPresented = false
- @State var isReferencesHovered = false
-
- var body: some View {
- HStack(alignment: .bottom) {
- VStack(alignment: .leading, spacing: 0) {
- HStack(spacing: 0) {
- Spacer() // Pushes the buttons to the right
- UpvoteButton { rating in
- chat.send(.upvote(id, rating))
- }
-
- DownvoteButton { rating in
- chat.send(.downvote(id, rating))
+
+ struct ResponseToolBar: View {
+ let id: String
+ let chat: StoreOf
+ let text: String
+
+ var body: some View {
+ HStack(spacing: 4) {
+
+ UpvoteButton { rating in
+ chat.send(.upvote(id, rating))
+ }
+
+ DownvoteButton { rating in
+ chat.send(.downvote(id, rating))
+ }
+
+ CopyButton {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ chat.send(.copyCode(id))
+ }
+
+ Spacer() // Pushes the buttons to the left
+ }
+ }
+ }
+
+ struct ReferenceButton: View {
+ var r: Double { messageBubbleCornerRadius }
+ let references: [ConversationReference]
+ let chat: StoreOf
+
+ @Binding var isReferencesPresented: Bool
+
+ @State var isReferencesHovered = false
+
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ func MakeReferenceTitle(references: [ConversationReference]) -> String {
+ guard !references.isEmpty else {
+ return ""
+ }
+
+ let count = references.count
+ let title = count > 1 ? "Used \(count) references" : "Used \(count) reference"
+ return title
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Button(action: {
+ isReferencesPresented.toggle()
+ }, label: {
+ HStack(spacing: 4) {
+ Image(systemName: isReferencesPresented ? "chevron.down" : "chevron.right")
+
+ Text(MakeReferenceTitle(references: references))
+ .font(.system(size: chatFontSize))
}
-
- CopyButton {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
- chat.send(.copyCode(id))
+ .background {
+ RoundedRectangle(cornerRadius: r - 4)
+ .fill(isReferencesHovered ? Color.gray.opacity(0.1) : Color.clear)
}
+ .foregroundStyle(.secondary)
+ })
+ .buttonStyle(HoverButtonStyle())
+
+ if isReferencesPresented {
+ ReferenceList(references: references, chat: chat)
+ .background(
+ RoundedRectangle(cornerRadius: 5)
+ .stroke(Color.gray, lineWidth: 0.2)
+ )
}
+ }
+ }
+ }
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 8) {
+ CopilotMessageHeader()
+ .padding(.leading, 6)
if !references.isEmpty {
- Button(action: {
- isReferencesPresented.toggle()
- }, label: {
- HStack(spacing: 4) {
- Image(systemName: "plus.circle")
- Text("Used \(references.count) references")
- }
- .padding(8)
- .background {
- RoundedRectangle(cornerRadius: r - 4)
- .foregroundStyle(Color(isReferencesHovered ? .black : .clear))
- }
- .overlay {
- RoundedRectangle(cornerRadius: r - 4)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- }
- .foregroundStyle(.secondary)
- })
- .buttonStyle(.plain)
- .popover(isPresented: $isReferencesPresented, arrowEdge: .trailing) {
- ReferenceList(references: references, chat: chat)
+ WithPerceptionTracking {
+ ReferenceButton(
+ references: references,
+ chat: chat,
+ isReferencesPresented: $isReferencesPresented
+ )
}
}
- ThemedMarkdownText(text)
- }
- .frame(alignment: .trailing)
- .padding()
- .background {
- RoundedCorners(tl: r, tr: r, bl: 0, br: r)
- .fill(Color.contentBackground)
- }
- .overlay {
- RoundedCorners(tl: r, tr: r, bl: 0, br: r)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ ThemedMarkdownText(text: text, chat: chat)
+
+ if errorMessage != nil {
+ HStack(spacing: 4) {
+ Image(systemName: "info.circle")
+ Text(errorMessage!)
+ .font(.system(size: chatFontSize))
+ }
+ }
+
+ ResponseToolBar(id: id, chat: chat, text: text)
}
- .padding(.leading, 8)
.shadow(color: .black.opacity(0.05), radius: 6)
.contextMenu {
Button("Copy") {
@@ -92,132 +144,76 @@ struct BotMessage: View {
}
}
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.trailing, 2)
}
}
struct ReferenceList: View {
- let references: [DisplayedChatMessage.Reference]
+ let references: [ConversationReference]
let chat: StoreOf
- var body: some View {
- WithPerceptionTracking {
- ScrollView {
- VStack(alignment: .leading, spacing: 8) {
- ForEach(0..
+ @AppStorage(\.chatFontSize) var chatFontSize
+ @Binding var itemHeight: CGFloat
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(0..
let id: String
let text: String
@AppStorage(\.chatFontSize) var chatFontSize
+ @Environment(\.openURL) private var openURL
+
+ private let displayFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .long
+ formatter.timeStyle = .short
+ return formatter
+ }()
+
+ private func extractDate(from text: String) -> Date? {
+ guard let match = (try? NSRegularExpression(pattern: "until (.*?) for"))?
+ .firstMatch(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)),
+ let dateRange = Range(match.range(at: 1), in: text) else {
+ return nil
+ }
+
+ let dateString = String(text[dateRange])
+ let formatter = DateFormatter()
+ formatter.dateFormat = "M/d/yyyy, h:mm:ss a"
+ return formatter.date(from: dateString)
+ }
var body: some View {
- Markdown(text)
- .textSelection(.enabled)
- .markdownTheme(.functionCall(fontSize: chatFontSize))
- .padding(.vertical, 2)
- .padding(.trailing, 2)
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Image("CopilotLogo")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .frame(width: 12, height: 12)
+ .overlay(
+ Circle()
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ .frame(width: 24, height: 24)
+ )
+ .padding(.leading, 8)
+
+ Text("GitHub Copilot")
+ .font(.system(size: 13))
+ .fontWeight(.semibold)
+ .padding(4)
+
+ Spacer()
+ }
+
+ VStack(alignment: .leading, spacing: 16) {
+ Text("You've reached your monthly chat limit for GitHub Copilot Free.")
+ .font(.system(size: 13))
+ .fontWeight(.medium)
+
+ if let date = extractDate(from: text) {
+ Text("Upgrade to Copilot Pro with a 30-day free trial or wait until \(displayFormatter.string(from: date)) for your limit to reset.")
+ .font(.system(size: 13))
+ }
+
+ Button("Update to Copilot Pro") {
+ if let url = URL(string: "https://github.com/github-copilot/signup/copilot_individual") {
+ openURL(url)
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ }
+ .padding(.vertical, 10)
+ .padding(.horizontal, 12)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
+
+// HStack {
+// Button(action: {
+// // Add your refresh action here
+// }) {
+// Image(systemName: "arrow.clockwise")
+// .resizable()
+// .aspectRatio(contentMode: .fit)
+// .frame(width: 14, height: 14)
+// .frame(width: 20, height: 20, alignment: .center)
+// .foregroundColor(.secondary)
+// .background(
+// .regularMaterial,
+// in: RoundedRectangle(cornerRadius: 4, style: .circular)
+// )
+// .padding(4)
+// }
+// .buttonStyle(.borderless)
+//
+// DownvoteButton { rating in
+// chat.send(.downvote(id, rating))
+// }
+//
+// Button(action: {
+// // Add your more options action here
+// }) {
+// Image(systemName: "ellipsis")
+// .resizable()
+// .aspectRatio(contentMode: .fit)
+// .frame(width: 14, height: 14)
+// .frame(width: 20, height: 20, alignment: .center)
+// .foregroundColor(.secondary)
+// .background(
+// .regularMaterial,
+// in: RoundedRectangle(cornerRadius: 4, style: .circular)
+// )
+// .padding(4)
+// }
+// .buttonStyle(.borderless)
+// }
+ }
+ .padding(.vertical, 12)
}
}
#Preview {
- FunctionMessage(id: "1", text: """
- Searching for something...
- - abc
- - [def](https://1.com)
- > hello
- > hi
- """)
+ FunctionMessage(
+ chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service()) }),
+ id: "1",
+ text: "You've reached your monthly chat limit. Upgrade to Copilot Pro (30-day free trial) or wait until 1/17/2025, 8:00:00 AM for your limit to reset."
+ )
.padding()
.fixedSize()
}
diff --git a/Core/Sources/ConversationTab/Views/Instructions.swift b/Core/Sources/ConversationTab/Views/Instructions.swift
deleted file mode 100644
index 8ee892c..0000000
--- a/Core/Sources/ConversationTab/Views/Instructions.swift
+++ /dev/null
@@ -1,39 +0,0 @@
-import ComposableArchitecture
-import Foundation
-import MarkdownUI
-import SwiftUI
-
-struct Instruction: View {
- let chat: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- Group {
- Markdown(
- """
- Hello, I am your AI programming assistant. I can identify issues, explain and even improve code.
- """
- )
- .modifier(InstructionModifier())
- }
- }
- }
-
- struct InstructionModifier: ViewModifier {
- @AppStorage(\.chatFontSize) var chatFontSize
-
- func body(content: Content) -> some View {
- content
- .textSelection(.enabled)
- .markdownTheme(.instruction(fontSize: chatFontSize))
- .opacity(0.8)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding()
- .overlay {
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- }
- }
- }
-}
-
diff --git a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift
index eca57a2..fa133e6 100644
--- a/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift
+++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift
@@ -1,6 +1,9 @@
import Foundation
import MarkdownUI
import SwiftUI
+import ChatService
+import ComposableArchitecture
+import SuggestionBasic
struct ThemedMarkdownText: View {
@AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme
@@ -13,9 +16,11 @@ struct ThemedMarkdownText: View {
@Environment(\.colorScheme) var colorScheme
let text: String
+ let chat: StoreOf
- init(_ text: String) {
+ init(text: String, chat: StoreOf) {
self.text = text
+ self.chat = chat
}
var body: some View {
@@ -46,7 +51,8 @@ struct ThemedMarkdownText: View {
}
}
return Color.secondary.opacity(0.7)
- }()
+ }(),
+ chat: chat
))
}
}
@@ -58,7 +64,8 @@ extension MarkdownUI.Theme {
fontSize: Double,
codeFont: NSFont,
codeBlockBackgroundColor: Color,
- codeBlockLabelColor: Color
+ codeBlockLabelColor: Color,
+ chat: StoreOf
) -> MarkdownUI.Theme {
.gitHub.text {
ForegroundColor(.primary)
@@ -66,37 +73,73 @@ extension MarkdownUI.Theme {
FontSize(fontSize)
}
.codeBlock { configuration in
- let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock)
+ MarkdownCodeBlockView(
+ codeBlockConfiguration: configuration,
+ codeFont: codeFont,
+ codeBlockBackgroundColor: codeBlockBackgroundColor,
+ codeBlockLabelColor: codeBlockLabelColor,
+ chat: chat
+ )
+ }
+ }
+}
+
+struct MarkdownCodeBlockView: View {
+ let codeBlockConfiguration: CodeBlockConfiguration
+ let codeFont: NSFont
+ let codeBlockBackgroundColor: Color
+ let codeBlockLabelColor: Color
+ let chat: StoreOf
+
+ func insertCode() {
+ chat.send(.insertCode(codeBlockConfiguration.content))
+ }
+
+ var body: some View {
+ let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock)
- if wrapCode {
+ if wrapCode {
+ AsyncCodeBlockView(
+ fenceInfo: codeBlockConfiguration.language,
+ content: codeBlockConfiguration.content,
+ font: codeFont
+ )
+ .codeBlockLabelStyle()
+ .codeBlockStyle(
+ codeBlockConfiguration,
+ backgroundColor: codeBlockBackgroundColor,
+ labelColor: codeBlockLabelColor,
+ insertAction: insertCode
+ )
+ } else {
+ ScrollView(.horizontal) {
AsyncCodeBlockView(
- fenceInfo: configuration.language,
- content: configuration.content,
+ fenceInfo: codeBlockConfiguration.language,
+ content: codeBlockConfiguration.content,
font: codeFont
)
.codeBlockLabelStyle()
- .codeBlockStyle(
- configuration,
- backgroundColor: codeBlockBackgroundColor,
- labelColor: codeBlockLabelColor
- )
- } else {
- ScrollView(.horizontal) {
- AsyncCodeBlockView(
- fenceInfo: configuration.language,
- content: configuration.content,
- font: codeFont
- )
- .codeBlockLabelStyle()
- }
- .workaroundForVerticalScrollingBugInMacOS()
- .codeBlockStyle(
- configuration,
- backgroundColor: codeBlockBackgroundColor,
- labelColor: codeBlockLabelColor
- )
}
+ .workaroundForVerticalScrollingBugInMacOS()
+ .codeBlockStyle(
+ codeBlockConfiguration,
+ backgroundColor: codeBlockBackgroundColor,
+ labelColor: codeBlockLabelColor,
+ insertAction: insertCode
+ )
}
}
}
+#Preview("Themed Markdown Text") {
+ ThemedMarkdownText(
+ text:"""
+```swift
+let sumClosure: (Int, Int) -> Int = { (a: Int, b: Int) in
+ return a + b
+}
+```
+""",
+ chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service()) }))
+}
+
diff --git a/Core/Sources/ConversationTab/Views/UserMessage.swift b/Core/Sources/ConversationTab/Views/UserMessage.swift
index e6917bb..0342bda 100644
--- a/Core/Sources/ConversationTab/Views/UserMessage.swift
+++ b/Core/Sources/ConversationTab/Views/UserMessage.swift
@@ -4,6 +4,8 @@ import Foundation
import MarkdownUI
import SharedUIComponents
import SwiftUI
+import Status
+import Cache
struct UserMessage: View {
var r: Double { messageBubbleCornerRadius }
@@ -11,27 +13,44 @@ struct UserMessage: View {
let text: String
let chat: StoreOf
@Environment(\.colorScheme) var colorScheme
+ @ObservedObject private var statusObserver = StatusObserver.shared
+
+ struct AvatarView: View {
+ @ObservedObject private var avatarViewModel = AvatarViewModel.shared
+
+ var body: some View {
+ if let avatarImage = avatarViewModel.avatarImage {
+ avatarImage
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 24, height: 24)
+ .clipShape(Circle())
+ } else {
+ Image(systemName: "person.circle")
+ .resizable()
+ .frame(width: 24, height: 24)
+ }
+ }
+ }
var body: some View {
- HStack() {
- Spacer()
- VStack(alignment: .trailing) {
- ThemedMarkdownText(text)
- .frame(alignment: .leading)
- .padding()
- }
- .background {
- RoundedCorners(tl: r, tr: r, bl: r, br: 0)
- .fill(Color.userChatContentBackground)
- }
- .overlay {
- RoundedCorners(tl: r, tr: r, bl: r, br: 0)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ HStack {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 4) {
+ AvatarView()
+
+ Text(statusObserver.authStatus.username ?? "")
+ .chatMessageHeaderTextStyle()
+ .padding(2)
+
+ Spacer()
+ }
+
+ ThemedMarkdownText(text: text, chat: chat)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
- .shadow(color: .black.opacity(0.05), radius: 6)
}
- .padding(.leading, 8)
- .padding(.trailing, 8)
+ .shadow(color: .black.opacity(0.05), radius: 6)
}
}
@@ -58,5 +77,6 @@ struct UserMessage: View {
)
.padding()
.fixedSize(horizontal: true, vertical: true)
+ .background(Color.yellow)
}
diff --git a/Core/Sources/HostApp/GitHubCopilotViewModel.swift b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
similarity index 54%
rename from Core/Sources/HostApp/GitHubCopilotViewModel.swift
rename to Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
index bbd01f2..7c369ed 100644
--- a/Core/Sources/HostApp/GitHubCopilotViewModel.swift
+++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
@@ -3,48 +3,79 @@ import GitHubCopilotService
import ComposableArchitecture
import Status
import SwiftUI
+import Cache
-struct SignInResponse {
- let userCode: String
- let verificationURL: URL
+public struct SignInResponse {
+ public let status: SignInInitiateStatus
+ public let userCode: String
+ public let verificationURL: URL
}
@MainActor
-class GitHubCopilotViewModel: ObservableObject {
+public class GitHubCopilotViewModel: ObservableObject {
+ // Add static shared instance
+ public static let shared = GitHubCopilotViewModel()
+
@Dependency(\.toast) var toast
@Dependency(\.openURL) var openURL
@AppStorage("username") var username: String = ""
- @Published var isRunningAction: Bool = false
- @Published var status: GitHubCopilotAccountStatus?
- @Published var version: String?
- @Published var userCode: String?
- @Published var isSignInAlertPresented = false
- @Published var signInResponse: SignInResponse?
- @Published var waitingForSignIn = false
+ @Published public var isRunningAction: Bool = false
+ @Published public var status: GitHubCopilotAccountStatus?
+ @Published public var version: String?
+ @Published public var userCode: String?
+ @Published public var isSignInAlertPresented = false
+ @Published public var signInResponse: SignInResponse?
+ @Published public var waitingForSignIn = false
+
+ static var copilotAuthService: GitHubCopilotService?
- static var copilotAuthService: GitHubCopilotAuthServiceType?
+ // Make init private to enforce singleton pattern
+ private init() {}
- func getGitHubCopilotAuthService() throws -> GitHubCopilotAuthServiceType {
+ public func getGitHubCopilotAuthService() throws -> GitHubCopilotService {
if let service = Self.copilotAuthService { return service }
let service = try GitHubCopilotService()
Self.copilotAuthService = service
return service
}
- func signIn() {
+ public func preSignIn() async throws -> SignInResponse? {
+ let service = try getGitHubCopilotAuthService()
+ let result = try await service.signInInitiate()
+
+ if result.status == .alreadySignedIn {
+ guard let user = result.user else {
+ toast("Missing user info.", .error)
+ throw NSError(domain: "Missing user info.", code: 0, userInfo: nil)
+ }
+ await Status.shared.updateAuthStatus(.loggedIn, username: user)
+ self.username = user
+ broadcastStatusChange()
+ return nil
+ }
+
+ guard let uri = result.verificationUri,
+ let userCode = result.userCode,
+ let url = URL(string: uri) else {
+ toast("Verification URI is incorrect.", .error)
+ throw NSError(domain: "Verification URI is incorrect.", code: 0, userInfo: nil)
+ }
+ return SignInResponse(
+ status: SignInInitiateStatus.promptUserDeviceFlow,
+ userCode: userCode,
+ verificationURL: url
+ )
+ }
+
+ public func signIn() {
Task {
isRunningAction = true
defer { isRunningAction = false }
do {
- let service = try getGitHubCopilotAuthService()
- let (uri, userCode) = try await service.signInInitiate()
- guard let url = URL(string: uri) else {
- toast("Verification URI is incorrect.", .error)
- return
- }
- self.signInResponse = .init(userCode: userCode, verificationURL: url)
+ guard let result = try await preSignIn() else { return }
+ self.signInResponse = result
self.isSignInAlertPresented = true
} catch {
toast(error.localizedDescription, .error)
@@ -52,7 +83,7 @@ class GitHubCopilotViewModel: ObservableObject {
}
}
- func checkStatus() {
+ public func checkStatus() {
Task {
isRunningAction = true
defer { isRunningAction = false }
@@ -67,25 +98,35 @@ class GitHubCopilotViewModel: ObservableObject {
}
}
- func signOut() {
+ public func signOut() {
Task {
isRunningAction = true
defer { isRunningAction = false }
do {
let service = try getGitHubCopilotAuthService()
status = try await service.signOut()
+ await Status.shared.updateAuthStatus(.notLoggedIn)
+ await Status.shared.updateCLSStatus(.unknown, message: "")
+ username = ""
broadcastStatusChange()
} catch {
toast(error.localizedDescription, .error)
}
+
+ // Sign out all other CLS instances
+ do {
+ try await GitHubCopilotService.signOutAll()
+ } catch {
+ // ignore
+ }
}
}
- func cancelWaiting() {
+ public func cancelWaiting() {
waitingForSignIn = false
}
- func copyAndOpen() {
+ public func copyAndOpen() {
waitingForSignIn = true
guard let signInResponse else {
toast("Missing sign in details.", .error)
@@ -101,7 +142,7 @@ class GitHubCopilotViewModel: ObservableObject {
}
}
- func waitForSignIn() {
+ public func waitForSignIn() {
Task {
do {
guard waitingForSignIn else { return }
@@ -114,6 +155,7 @@ class GitHubCopilotViewModel: ObservableObject {
waitingForSignIn = false
self.username = username
self.status = status
+ await Status.shared.updateAuthStatus(.loggedIn, username: username)
broadcastStatusChange()
} catch let error as GitHubCopilotError {
if case .languageServerError(.timeout) = error {
@@ -129,7 +171,7 @@ class GitHubCopilotViewModel: ObservableObject {
}
}
- func broadcastStatusChange() {
+ public func broadcastStatusChange() {
DistributedNotificationCenter.default().post(
name: .authStatusDidChange,
object: nil
diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift
index 5bcfe18..80bfcf5 100644
--- a/Core/Sources/HostApp/General.swift
+++ b/Core/Sources/HostApp/General.swift
@@ -67,6 +67,7 @@ struct General {
_ = try await service
.send(requestBody: ExtensionServiceRequests.OpenExtensionManager())
} catch {
+ Logger.ui.error("Failed to open extension manager. \(error.localizedDescription)")
toast(error.localizedDescription, .error)
await send(.failedReloading)
}
diff --git a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift
index 8fd3a5e..837f304 100644
--- a/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift
+++ b/Core/Sources/HostApp/GeneralSettings/AppInfoView.swift
@@ -1,5 +1,6 @@
import ComposableArchitecture
import GitHubCopilotService
+import GitHubCopilotViewModel
import SwiftUI
struct AppInfoView: View {
@@ -68,7 +69,7 @@ struct AppInfoView: View {
#Preview {
AppInfoView(
- viewModel: .init(),
+ viewModel: GitHubCopilotViewModel.shared,
store: .init(initialState: .init(), reducer: { General() })
)
}
diff --git a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift
index 0b62b86..aeb8bd7 100644
--- a/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift
+++ b/Core/Sources/HostApp/GeneralSettings/CopilotConnectionView.swift
@@ -1,10 +1,11 @@
import ComposableArchitecture
+import GitHubCopilotViewModel
import SwiftUI
struct CopilotConnectionView: View {
@AppStorage("username") var username: String = ""
@Environment(\.toast) var toast
- @StateObject var viewModel = GitHubCopilotViewModel()
+ @StateObject var viewModel: GitHubCopilotViewModel
let store: StoreOf
@@ -99,16 +100,16 @@ struct CopilotConnectionView: View {
#Preview {
CopilotConnectionView(
- viewModel: .init(),
+ viewModel: GitHubCopilotViewModel.shared,
store: .init(initialState: .init(), reducer: { General() })
)
}
#Preview("Running") {
- let runningModel = GitHubCopilotViewModel()
+ let runningModel = GitHubCopilotViewModel.shared
runningModel.isRunningAction = true
return CopilotConnectionView(
- viewModel: runningModel,
+ viewModel: GitHubCopilotViewModel.shared,
store: .init(initialState: .init(), reducer: { General() })
)
}
diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift
index b4f5d8a..7ba6283 100644
--- a/Core/Sources/HostApp/GeneralView.swift
+++ b/Core/Sources/HostApp/GeneralView.swift
@@ -1,9 +1,10 @@
import ComposableArchitecture
+import GitHubCopilotViewModel
import SwiftUI
struct GeneralView: View {
let store: StoreOf
- @StateObject private var viewModel = GitHubCopilotViewModel()
+ @StateObject private var viewModel = GitHubCopilotViewModel.shared
var body: some View {
ScrollView {
diff --git a/Core/Sources/HostApp/SharedComponents/SettingsSection.swift b/Core/Sources/HostApp/SharedComponents/SettingsSection.swift
index e52d9ad..1526e80 100644
--- a/Core/Sources/HostApp/SharedComponents/SettingsSection.swift
+++ b/Core/Sources/HostApp/SharedComponents/SettingsSection.swift
@@ -21,7 +21,7 @@ struct SettingsSection: View {
.padding(.horizontal, 10)
if showWarning {
HStack{
- Text("GitHub Copilot features are disabled. Please check your subscription to access them.")
+ Text("GitHub Copilot features are disabled. Please [check your subscription](https://github.com/settings/copilot) to access them.")
.foregroundColor(Color("WarningForegroundColor"))
.padding(4)
Spacer()
diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
index f21e2da..7d7ac08 100644
--- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
+++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
@@ -19,9 +19,9 @@ struct GUI {
struct State: Equatable {
var suggestionWidgetState = WidgetFeature.State()
- var chatTabGroup: ChatPanelFeature.ChatTabGroup {
- get { suggestionWidgetState.chatPanelState.chatTabGroup }
- set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue }
+ var chatHistory: ChatHistory {
+ get { suggestionWidgetState.chatPanelState.chatHistory }
+ set { suggestionWidgetState.chatPanelState.chatHistory = newValue }
}
var promptToCodeGroup: PromptToCodeGroup.State {
@@ -34,11 +34,13 @@ struct GUI {
case start
case openChatPanel(forceDetach: Bool)
case createAndSwitchToChatTabIfNeeded
- case createAndSwitchToBrowserTabIfNeeded(url: URL)
+// case createAndSwitchToBrowserTabIfNeeded(url: URL)
case sendCustomCommandToActiveChat(CustomCommand)
case toggleWidgetsHotkeyPressed
case suggestionWidget(WidgetFeature.Action)
+ case switchWorkspace(path: String, name: String)
+ case initWorkspaceChatTabIfNeeded(path: String)
static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self {
.suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action))))
@@ -63,22 +65,27 @@ struct GUI {
}
Scope(
- state: \.chatTabGroup,
+ state: \.chatHistory,
action: \.suggestionWidget.chatPanel
) {
Reduce { _, action in
switch action {
case let .createNewTapButtonClicked(kind):
+// return .run { send in
+// if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) {
+// await send(.createNewTab(chatTabInfo))
+// }
+// }
return .run { send in
if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) {
await send(.appendAndSelectTab(chatTabInfo))
}
}
- case let .closeTabButtonClicked(id):
- return .run { _ in
- chatTabPool.removeTab(of: id)
- }
+// case let .closeTabButtonClicked(id):
+// return .run { _ in
+// chatTabPool.removeTab(of: id)
+// }
case let .chatTab(_, .openNewTab(builder)):
return .run { send in
@@ -125,14 +132,15 @@ struct GUI {
}
case .createAndSwitchToChatTabIfNeeded:
- if let selectedTabInfo = state.chatTabGroup.selectedTabInfo,
+
+ if let currentChatWorkspace = state.chatHistory.currentChatWorkspace, let selectedTabInfo = currentChatWorkspace.selectedTabInfo,
chatTabPool.getTab(of: selectedTabInfo.id) is ConversationTab
{
// Already in Chat tab
return .none
}
- if let firstChatTabInfo = state.chatTabGroup.tabInfo.first(where: {
+ if let firstChatTabInfo = state.chatHistory.currentChatWorkspace?.tabInfo.first(where: {
chatTabPool.getTab(of: $0.id) is ConversationTab
}) {
return .run { send in
@@ -149,52 +157,69 @@ struct GUI {
}
}
- case let .createAndSwitchToBrowserTabIfNeeded(url):
- #if canImport(BrowserChatTab)
- func match(_ tabURL: URL?) -> Bool {
- guard let tabURL else { return false }
- return tabURL == url
- || tabURL.absoluteString.hasPrefix(url.absoluteString)
- }
-
- if let selectedTabInfo = state.chatTabGroup.selectedTabInfo,
- let tab = chatTabPool.getTab(of: selectedTabInfo.id) as? BrowserChatTab,
- match(tab.url)
- {
- // Already in the target Browser tab
- return .none
- }
-
- if let firstChatTabInfo = state.chatTabGroup.tabInfo.first(where: {
- guard let tab = chatTabPool.getTab(of: $0.id) as? BrowserChatTab,
- match(tab.url)
- else { return false }
- return true
- }) {
- return .run { send in
- await send(.suggestionWidget(.chatPanel(.tabClicked(
- id: firstChatTabInfo.id
- ))))
- }
+ case let .switchWorkspace(path, name):
+ return .run { send in
+ await send(
+ .suggestionWidget(.chatPanel(.switchWorkspace(path, name)))
+ )
+ await send(.initWorkspaceChatTabIfNeeded(path: path))
}
-
+ case let .initWorkspaceChatTabIfNeeded(path):
+ guard let chatWorkspace = state.chatHistory.workspaces[id: path], chatWorkspace.tabInfo.isEmpty
+ else { return .none }
return .run { send in
- if let (_, chatTabInfo) = await chatTabPool.createTab(
- for: .init(BrowserChatTab.urlChatBuilder(
- url: url,
- externalDependency: ChatTabFactory
- .externalDependenciesForBrowserChatTab()
- ))
- ) {
+ if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) {
await send(
- .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))
- )
+ .suggestionWidget(.chatPanel(.appendTabToWorkspace(chatTabInfo, chatWorkspace)))
+ )
}
}
-
- #else
- return .none
- #endif
+// case let .createAndSwitchToBrowserTabIfNeeded(url):
+// #if canImport(BrowserChatTab)
+// func match(_ tabURL: URL?) -> Bool {
+// guard let tabURL else { return false }
+// return tabURL == url
+// || tabURL.absoluteString.hasPrefix(url.absoluteString)
+// }
+//
+// if let selectedTabInfo = state.chatTabGroup.selectedTabInfo,
+// let tab = chatTabPool.getTab(of: selectedTabInfo.id) as? BrowserChatTab,
+// match(tab.url)
+// {
+// // Already in the target Browser tab
+// return .none
+// }
+//
+// if let firstChatTabInfo = state.chatTabGroup.tabInfo.first(where: {
+// guard let tab = chatTabPool.getTab(of: $0.id) as? BrowserChatTab,
+// match(tab.url)
+// else { return false }
+// return true
+// }) {
+// return .run { send in
+// await send(.suggestionWidget(.chatPanel(.tabClicked(
+// id: firstChatTabInfo.id
+// ))))
+// }
+// }
+//
+// return .run { send in
+// if let (_, chatTabInfo) = await chatTabPool.createTab(
+// for: .init(BrowserChatTab.urlChatBuilder(
+// url: url,
+// externalDependency: ChatTabFactory
+// .externalDependenciesForBrowserChatTab()
+// ))
+// ) {
+// await send(
+// .suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))
+// )
+// }
+// }
+//
+// #else
+// return .none
+// #endif
case let .sendCustomCommandToActiveChat(command):
@Sendable func stopAndHandleCommand(_ tab: ConversationTab) async {
@@ -204,7 +229,7 @@ struct GUI {
try? await tab.service.handleCustomCommand(command)
}
- if let info = state.chatTabGroup.selectedTabInfo,
+ if let info = state.chatHistory.currentChatWorkspace?.selectedTabInfo,
let activeTab = chatTabPool.getTab(of: info.id) as? ConversationTab
{
return .run { send in
@@ -213,13 +238,15 @@ struct GUI {
}
}
- if let info = state.chatTabGroup.tabInfo.first(where: {
+ if var chatWorkspace = state.chatHistory.currentChatWorkspace, let info = chatWorkspace.tabInfo.first(where: {
chatTabPool.getTab(of: $0.id) is ConversationTab
}),
let chatTab = chatTabPool.getTab(of: info.id) as? ConversationTab
{
- state.chatTabGroup.selectedTabId = chatTab.id
+ chatWorkspace.selectedTabId = chatTab.id
+ let updatedChatWorkspace = chatWorkspace
return .run { send in
+ await send(.suggestionWidget(.chatPanel(.updateChatHistory(updatedChatWorkspace))))
await send(.openChatPanel(forceDetach: false))
await stopAndHandleCommand(chatTab)
}
@@ -252,15 +279,15 @@ struct GUI {
return .none
#endif
- case let .suggestionWidget(.chatPanel(.closeTabButtonClicked(id))):
- #if canImport(ChatTabPersistent)
- // when a tab is closed, remove it from persistence.
- return .run { send in
- await send(.persistent(.chatTabClosed(id: id)))
- }
- #else
- return .none
- #endif
+// case let .suggestionWidget(.chatPanel(.closeTabButtonClicked(id))):
+// #if canImport(ChatTabPersistent)
+// // when a tab is closed, remove it from persistence.
+// return .run { send in
+// await send(.persistent(.chatTabClosed(id: id)))
+// }
+// #else
+// return .none
+// #endif
case .suggestionWidget:
return .none
@@ -271,20 +298,21 @@ struct GUI {
#endif
}
}
- }.onChange(of: \.chatTabGroup.tabInfo) { old, new in
- Reduce { _, _ in
- guard old.map(\.id) != new.map(\.id) else {
- return .none
- }
- #if canImport(ChatTabPersistent)
- return .run { send in
- await send(.persistent(.chatOrderChanged))
- }.debounce(id: Debounce.updateChatTabOrder, for: 1, scheduler: DispatchQueue.main)
- #else
- return .none
- #endif
- }
}
+// .onChange(of: \.chatCollection.selectedChatGroup?.tabInfo) { old, new in
+// Reduce { _, _ in
+// guard old.map(\.id) != new.map(\.id) else {
+// return .none
+// }
+// #if canImport(ChatTabPersistent)
+// return .run { send in
+// await send(.persistent(.chatOrderChanged))
+// }.debounce(id: Debounce.updateChatTabOrder, for: 1, scheduler: DispatchQueue.main)
+// #else
+// return .none
+// #endif
+// }
+// }
}
}
@@ -340,7 +368,7 @@ public final class GraphicalUserInterfaceController {
chatTabPool.createStore = { id in
store.scope(
state: { state in
- state.chatTabGroup.tabInfo[id: id] ?? .init(id: id, title: "")
+ state.chatHistory.currentChatWorkspace?.tabInfo[id: id] ?? .init(id: id, title: "")
},
action: { childAction in
.suggestionWidget(.chatPanel(.chatTab(id: id, action: childAction)))
diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift
index 3fb9afa..463f7e9 100644
--- a/Core/Sources/Service/Service.swift
+++ b/Core/Sources/Service/Service.swift
@@ -77,6 +77,7 @@ public final class Service {
let scheduledCleaner = ScheduledCleaner()
scheduledCleaner.service = self
+ Logger.telemetryLogger = TelemetryLogger()
}
@MainActor
@@ -95,10 +96,23 @@ public final class Service {
.compactMap { $0 }
.sink { [weak self] fileURL in
Task {
- try await self?.workspacePool
- .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
+ do {
+ try await self?.workspacePool
+ .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
+ } catch {
+ Logger.workspacePool.error(error)
+ }
}
}.store(in: &cancellable)
+
+ await XcodeInspector.shared.safe.$activeWorkspaceURL.receive(on: DispatchQueue.main)
+ .sink { newURL in
+ if let path = newURL?.path, self.guiController.store.chatHistory.selectedWorkspacePath != path {
+ let name = self.getDisplayNameOfXcodeWorkspace(url: newURL!)
+ self.guiController.store.send(.switchWorkspace(path: path, name: name))
+ }
+
+ }.store(in: &cancellable)
}
}
@@ -108,6 +122,18 @@ public final class Service {
keyBindingManager.stopForExit()
await scheduledCleaner.closeAllChildProcesses()
}
+
+ private func getDisplayNameOfXcodeWorkspace(url: URL) -> String {
+ var name = url.lastPathComponent
+ let suffixes = [".xcworkspace", ".xcodeproj"]
+ for suffix in suffixes {
+ if name.hasSuffix(suffix) {
+ name = String(name.dropLast(suffix.count))
+ break
+ }
+ }
+ return name
+ }
}
public extension Service {
diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
index ac0b46e..f919ae7 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
@@ -9,6 +9,7 @@ import Workspace
import WorkspaceSuggestionService
import XcodeInspector
import XPCShared
+import AXHelper
/// It's used to run some commands without really triggering the menu bar item.
///
@@ -89,6 +90,13 @@ struct PseudoCommandHandler {
cursorPosition: editorContent.cursorPosition
)
}
+ if !filespace.errorMessage.isEmpty {
+ presenter
+ .presentWarningMessage(
+ filespace.errorMessage,
+ url: "https://github.com/github-copilot/signup/copilot_individual"
+ )
+ }
if filespace.presentingSuggestion != nil {
presenter.presentSuggestion(fileURL: fileURL)
workspace.notifySuggestionShown(fileFileAt: fileURL)
@@ -324,56 +332,14 @@ extension PseudoCommandHandler {
_ result: UpdatedContent,
focusElement: AXUIElement
) throws {
- let oldPosition = focusElement.selectedTextRange
- let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue
-
- let error = AXUIElementSetAttributeValue(
- focusElement,
- kAXValueAttribute as CFString,
- result.content as CFTypeRef
- )
-
- if error != AXError.success {
- PresentInWindowSuggestionPresenter()
- .presentErrorMessage("Fail to set editor content.")
- }
-
- // recover selection range
-
- if let selection = result.newSelection {
- var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content)
- if let value = AXValueCreate(.cfRange, &range) {
- AXUIElementSetAttributeValue(
- focusElement,
- kAXSelectedTextRangeAttribute as CFString,
- value
- )
- }
- } else if let oldPosition {
- var range = CFRange(
- location: oldPosition.lowerBound,
- length: 0
- )
- if let value = AXValueCreate(.cfRange, &range) {
- AXUIElementSetAttributeValue(
- focusElement,
- kAXSelectedTextRangeAttribute as CFString,
- value
- )
+ try AXHelper().injectUpdatedCodeWithAccessibilityAPI(
+ result,
+ focusElement: focusElement,
+ onError: {
+ PresentInWindowSuggestionPresenter()
+ .presentErrorMessage("Fail to set editor content.")
}
- }
-
- // recover scroll position
-
- if let oldScrollPosition,
- let scrollBar = focusElement.parent?.verticalScrollBar
- {
- AXUIElementSetAttributeValue(
- scrollBar,
- kAXValueAttribute as CFString,
- oldScrollPosition as CFTypeRef
- )
- }
+ )
}
func getFileContent(sourceEditor: AXUIElement?) async
diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
index 29780d4..4007a06 100644
--- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
+++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
@@ -49,6 +49,20 @@ struct PresentInWindowSuggestionPresenter {
}
}
+ func presentWarningMessage(_ message: String, url: String?) {
+ Task { @MainActor in
+ let controller = Service.shared.guiController.widgetController
+ controller.presentWarning(message: message, url: url)
+ }
+ }
+
+ func dismissWarning() {
+ Task { @MainActor in
+ let controller = Service.shared.guiController.widgetController
+ controller.dismissWarning()
+ }
+ }
+
func closeChatRoom(fileURL: URL) {
Task { @MainActor in
let controller = Service.shared.guiController.widgetController
diff --git a/Core/Sources/Service/TelemetryLogger.swift b/Core/Sources/Service/TelemetryLogger.swift
new file mode 100644
index 0000000..1bdf318
--- /dev/null
+++ b/Core/Sources/Service/TelemetryLogger.swift
@@ -0,0 +1,42 @@
+import Logger
+import Foundation
+import TelemetryService
+
+public class TelemetryLogger: TelemetryLoggerProvider {
+ public func sendError(
+ error: any Error,
+ category: String,
+ file: StaticString,
+ line: UInt,
+ function: StaticString,
+ callStackSymbols: [String]
+ ) {
+ TelemetryService.shared.sendError(
+ error,
+ category: category,
+ file: file,
+ line: line,
+ function: function,
+ from: callStackSymbols
+ )
+ }
+
+ public func sendError(
+ message: String,
+ category: String,
+ file: StaticString,
+ line: UInt,
+ function: StaticString,
+ callStackSymbols: [String]
+ ) {
+ TelemetryService.shared
+ .sendError(
+ message,
+ category: category,
+ file: file,
+ line: line,
+ function: function,
+ from: callStackSymbols
+ )
+ }
+}
diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift
index a2ef2ca..49cab1f 100644
--- a/Core/Sources/Service/XPCService.swift
+++ b/Core/Sources/Service/XPCService.swift
@@ -148,7 +148,7 @@ public class XPCService: NSObject, XPCServiceProtocol {
withReply reply: @escaping (Data?, Error?) -> Void
) {
let handler = PseudoCommandHandler()
- handler.openChat(forceDetach: false)
+ handler.openChat(forceDetach: true)
reply(nil, nil)
}
diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
index 3f1b772..6282b21 100644
--- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
+++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
@@ -20,7 +20,7 @@ final class ChatPanelWindow: NSWindow {
self.minimizeWindow = minimizeWindow
super.init(
contentRect: .zero,
- styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView],
+ styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView, .closable],
backing: .buffered,
defer: false
)
@@ -103,5 +103,8 @@ final class ChatPanelWindow: NSWindow {
override func miniaturize(_: Any?) {
minimizeWindow()
}
-}
+ override func close() {
+ minimizeWindow()
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift
new file mode 100644
index 0000000..29357d8
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatHistoryView.swift
@@ -0,0 +1,233 @@
+import ActiveApplicationMonitor
+import ConversationTab
+import AppKit
+import ComposableArchitecture
+import SwiftUI
+import ChatTab
+import SharedUIComponents
+
+
+struct ChatHistoryView: View {
+ let store: StoreOf
+ @Environment(\.chatTabPool) var chatTabPool
+ @Binding var isChatHistoryVisible: Bool
+ @State private var searchText = ""
+
+ var body: some View {
+ WithPerceptionTracking {
+ let _ = store.currentChatWorkspace?.tabInfo
+
+ VStack(alignment: .center, spacing: 0) {
+ Header(isChatHistoryVisible: $isChatHistoryVisible)
+ .frame(height: 32)
+ .padding(.leading, 16)
+ .padding(.trailing, 12)
+
+ Divider()
+
+ ChatHistorySearchBarView(searchText: $searchText)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 4)
+
+ ItemView(store: store, searchText: $searchText, isChatHistoryVisible: $isChatHistoryVisible)
+ .padding(.horizontal, 16)
+ }
+ }
+ }
+
+ struct Header: View {
+ @Binding var isChatHistoryVisible: Bool
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var body: some View {
+ HStack {
+ Text("Chat History")
+ .font(.system(size: 13, weight: .bold))
+ .lineLimit(nil)
+
+ Spacer()
+
+ Button(action: {
+ isChatHistoryVisible = false
+ }) {
+ Image(systemName: "xmark")
+ }
+ .buttonStyle(HoverButtonStyle())
+ .help("Close")
+ }
+ }
+ }
+
+ struct ItemView: View {
+ let store: StoreOf
+ @Binding var searchText: String
+ @Binding var isChatHistoryVisible: Bool
+
+ @Environment(\.chatTabPool) var chatTabPool
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ ForEach(filteredTabInfo, id: \.id) { info in
+ if let tab = chatTabPool.getTab(of: info.id){
+ ChatHistoryItemView(
+ store: store,
+ info: info,
+ content: { tab.chatConversationItem },
+ isChatHistoryVisible: $isChatHistoryVisible
+ )
+ .id(info.id)
+ .frame(height: 49)
+ }
+ else {
+ EmptyView()
+ }
+ }
+ }
+ }
+ }
+
+ var filteredTabInfo: IdentifiedArray {
+ guard let tabInfo = store.currentChatWorkspace?.tabInfo else {
+ return []
+ }
+
+ guard !searchText.isEmpty else { return tabInfo }
+ let result = tabInfo.filter { info in
+ if let tab = chatTabPool.getTab(of: info.id),
+ let conversationTab = tab as? ConversationTab {
+ return conversationTab.getChatTabTitle().localizedCaseInsensitiveContains(searchText)
+ }
+
+ return false
+ }
+ return result
+ }
+ }
+}
+
+
+struct ChatHistorySearchBarView: View {
+ @Binding var searchText: String
+ @FocusState private var isSearchBarFocused: Bool
+
+ var body: some View {
+ HStack(spacing: 5) {
+ Image(systemName: "magnifyingglass")
+ .foregroundColor(.secondary)
+
+ TextField("Search", text: $searchText)
+ .textFieldStyle(PlainTextFieldStyle())
+ .focused($isSearchBarFocused)
+ .foregroundColor(searchText.isEmpty ? Color(nsColor: .placeholderTextColor) : Color(nsColor: .textColor))
+ }
+ .cornerRadius(10)
+ .padding(.vertical, 8)
+ .padding(.horizontal, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .fill(Color.gray.opacity(0.1))
+ )
+ .onAppear {
+ isSearchBarFocused = true
+ }
+ }
+}
+
+struct ChatHistoryItemView: View {
+ let store: StoreOf
+ let info: ChatTabInfo
+ let content: () -> Content
+ @Binding var isChatHistoryVisible: Bool
+ @State private var isHovered = false
+
+ func isTabSelected() -> Bool {
+ return store.state.currentChatWorkspace?.selectedTabId == info.id
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(alignment: .center, spacing: 0) {
+ HStack(spacing: 8) {
+ content()
+ .font(.system(size: 14, weight: .regular))
+ .lineLimit(1)
+ .hoverPrimaryForeground(isHovered: isHovered)
+
+ if isTabSelected() {
+ Text("Current")
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Spacer()
+
+ if !isTabSelected() {
+ if isHovered {
+ Button(action: {
+ store.send(.chatHisotryDeleteButtonClicked(id: info.id))
+ }) {
+ Image(systemName: "trash")
+ }
+ .buttonStyle(HoverButtonStyle())
+ .help("Delete")
+ }
+ }
+ }
+ .padding(.horizontal, 12)
+ }
+ .frame(maxHeight: .infinity)
+ .onHover(perform: {
+ isHovered = $0
+ })
+ .hoverRadiusBackground(isHovered: isHovered, cornerRadius: 4)
+ .onTapGesture {
+ store.send(.chatHistoryItemClicked(id: info.id))
+ isChatHistoryVisible = false
+ }
+ }
+}
+
+struct ChatHistoryView_Previews: PreviewProvider {
+ static let pool = ChatTabPool([
+ "2": EmptyChatTab(id: "2"),
+ "3": EmptyChatTab(id: "3"),
+ "4": EmptyChatTab(id: "4"),
+ "5": EmptyChatTab(id: "5"),
+ "6": EmptyChatTab(id: "6")
+ ])
+
+ static func createStore() -> StoreOf {
+ StoreOf(
+ initialState: .init(
+ chatHistory: .init(
+ workspaces: [.init(
+ id: "activeWorkspacePath",
+ tabInfo: [
+ .init(id: "2", title: "Empty-2"),
+ .init(id: "3", title: "Empty-3"),
+ .init(id: "4", title: "Empty-4"),
+ .init(id: "5", title: "Empty-5"),
+ .init(id: "6", title: "Empty-6")
+ ] as IdentifiedArray,
+ selectedTabId: "2"
+ )] as IdentifiedArray,
+ selectedWorkspacePath: "activeWorkspacePath",
+ selectedWorkspaceName: "activeWorkspacePath"
+ ),
+ isPanelDisplayed: true
+ ),
+ reducer: { ChatPanelFeature() }
+ )
+ }
+
+ static var previews: some View {
+ ChatHistoryView(
+ store: createStore(),
+ isChatHistoryVisible: .constant(true)
+ )
+ .xcodeStyleFrame()
+ .padding()
+ .environment(\.chatTabPool, pool)
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
new file mode 100644
index 0000000..d017d78
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
@@ -0,0 +1,82 @@
+import SwiftUI
+import Perception
+import GitHubCopilotViewModel
+import SharedUIComponents
+
+struct ChatLoginView: View {
+ @StateObject var viewModel: GitHubCopilotViewModel
+ @Environment(\.openURL) private var openURL
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 0){
+ VStack(spacing: 20) {
+ Spacer()
+ Image("CopilotLogo")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .frame(width: 60.0, height: 60.0)
+ .foregroundColor(.secondary)
+
+ Text("Welcome to Copilot")
+ .font(.system(size: 24))
+
+ Text("Your AI-powered coding assistant\nI use the power of AI to help you:")
+ .font(.system(size: 12))
+
+ Button("Sign Up for Copilot Free") {
+ if let url = URL(string: "https://github.com/features/copilot/plans") {
+ openURL(url)
+ }
+ }
+ .buttonStyle(.borderedProminent)
+
+ HStack{
+ Text("Already have an account?")
+ Button("Sign In") { viewModel.signIn() }
+ .buttonStyle(.borderless)
+ .foregroundColor(Color("TextLinkForegroundColor"))
+
+ if viewModel.isRunningAction || viewModel.waitingForSignIn {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ Spacer()
+ Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).")
+ .font(.system(size: 12))
+ }
+ .padding()
+ .frame(
+ maxWidth: .infinity,
+ maxHeight: .infinity
+ )
+ }
+ .xcodeStyleFrame(cornerRadius: 10)
+ .ignoresSafeArea(edges: .top)
+ .alert(
+ viewModel.signInResponse?.userCode ?? "",
+ isPresented: $viewModel.isSignInAlertPresented,
+ presenting: viewModel.signInResponse
+ ) { _ in
+ Button("Cancel", role: .cancel, action: {})
+ Button("Copy Code and Open", action: viewModel.copyAndOpen)
+ } message: { response in
+ Text("""
+ Please enter the above code in the GitHub website \
+ to authorize your GitHub account with Copilot for Xcode.
+
+ \(response?.verificationURL.absoluteString ?? "")
+ """)
+ }
+ }
+ }
+}
+
+struct ChatLoginView_Previews: PreviewProvider {
+ static var previews: some View {
+ ChatLoginView(viewModel: GitHubCopilotViewModel.shared)
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift
new file mode 100644
index 0000000..5c05241
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoSubscriptionView.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+import Perception
+import GitHubCopilotViewModel
+import SharedUIComponents
+
+struct ChatNoSubscriptionView: View {
+ @StateObject var viewModel: GitHubCopilotViewModel
+ @Environment(\.openURL) private var openURL
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ VStack(alignment: .center, spacing: 20) {
+ Spacer()
+ Image("CopilotIssue")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .frame(width: 60.0, height: 60.0)
+ .foregroundColor(.primary)
+
+ Text("No Copilot Subscription Found")
+ .font(.system(size: 24))
+ .multilineTextAlignment(.center)
+
+ Text("Request a license from your organization manager \nor start a 30-day [free trial](https://github.com/github-copilot/signup/copilot_individual) to explore Copilot")
+ .font(.system(size: 12))
+ .multilineTextAlignment(.center)
+
+ HStack{
+ Button("Check Subscription Plans") {
+ if let url = URL(string: "https://github.com/settings/copilot") {
+ openURL(url)
+ }
+ }
+ .buttonStyle(.borderedProminent)
+
+ Button("Retry") { viewModel.checkStatus() }
+ .buttonStyle(.bordered)
+
+ if viewModel.isRunningAction || viewModel.waitingForSignIn {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+
+ Spacer()
+
+ Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).")
+ .font(.system(size: 12))
+ }
+ .padding()
+ .frame(
+ maxWidth: .infinity,
+ maxHeight: .infinity
+ )
+ }
+ .xcodeStyleFrame(cornerRadius: 10)
+ .ignoresSafeArea(edges: .top)
+ }
+ }
+}
+
+struct ChatNoSubcription_Previews: PreviewProvider {
+ static var previews: some View {
+ ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared)
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift
index f9f68a4..d12e889 100644
--- a/Core/Sources/SuggestionWidget/ChatWindowView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift
@@ -1,38 +1,120 @@
import ActiveApplicationMonitor
+import ConversationTab
import AppKit
import ChatTab
import ComposableArchitecture
import SwiftUI
+import SharedUIComponents
+import GitHubCopilotViewModel
+import Status
private let r: Double = 8
struct ChatWindowView: View {
let store: StoreOf
let toggleVisibility: (Bool) -> Void
+ @State private var isChatHistoryVisible: Bool = false
+ @ObservedObject private var statusObserver = StatusObserver.shared
var body: some View {
WithPerceptionTracking {
- let _ = store.chatTabGroup.selectedTabId // force re-evaluation
- VStack(spacing: 0) {
- Rectangle().fill(.regularMaterial).frame(height: 28)
+ let _ = store.currentChatWorkspace?.selectedTabId // force re-evaluation
+ ZStack {
+ switch statusObserver.authStatus.status {
+ case .loggedIn:
+ ChatView(store: store, isChatHistoryVisible: $isChatHistoryVisible)
+ case .notLoggedIn:
+ ChatLoginView(viewModel: GitHubCopilotViewModel.shared)
+ case .notAuthorized:
+ ChatNoSubscriptionView(viewModel: GitHubCopilotViewModel.shared)
+ default:
+ ChatLoadingView()
+ }
+ }
+ .onChange(of: store.isPanelDisplayed) { isDisplayed in
+ toggleVisibility(isDisplayed)
+ }
+ .preferredColorScheme(store.colorScheme)
+ }
+ }
+}
- Divider()
+struct ChatView: View {
+ let store: StoreOf
+ @Binding var isChatHistoryVisible: Bool
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Rectangle().fill(.regularMaterial).frame(height: 28)
- ChatTabBar(store: store)
- .frame(height: 26)
+ Divider()
- Divider()
+ ZStack {
+ VStack(spacing: 0) {
+ ChatBar(store: store, isChatHistoryVisible: $isChatHistoryVisible)
+ .frame(height: 32)
+ .background(Color(nsColor: .windowBackgroundColor))
+
+ Divider()
+
+ ChatTabContainer(store: store)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ }
+ .xcodeStyleFrame(cornerRadius: 10)
+ .ignoresSafeArea(edges: .top)
+
+ if isChatHistoryVisible {
+ VStack(spacing: 0) {
+ Rectangle().fill(.regularMaterial).frame(height: 28)
- ChatTabContainer(store: store)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ Divider()
+
+ ChatHistoryView(
+ store: store,
+ isChatHistoryVisible: $isChatHistoryVisible
+ )
+ .background(Color(nsColor: .windowBackgroundColor))
+ .frame(
+ maxWidth: .infinity,
+ maxHeight: .infinity
+ )
}
.xcodeStyleFrame(cornerRadius: 10)
.ignoresSafeArea(edges: .top)
- .onChange(of: store.isPanelDisplayed) { isDisplayed in
- toggleVisibility(isDisplayed)
- }
.preferredColorScheme(store.colorScheme)
+ .focusable()
+ .onExitCommand(perform: {
+ isChatHistoryVisible = false
+ })
+ }
+ }
+}
+
+struct ChatLoadingView: View {
+ var body: some View {
+ VStack(alignment: .center) {
+
+ Spacer()
+
+ VStack(spacing: 24) {
+ Instruction()
+
+ ProgressView("Loading...")
+
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ // keep same as chat view
+ .padding(.top, 20) // chat bar
+
+ Spacer()
+
}
+ .xcodeStyleFrame(cornerRadius: 10)
+ .ignoresSafeArea(edges: .top)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(nsColor: .windowBackgroundColor))
}
}
@@ -134,8 +216,9 @@ private extension View {
}
}
-struct ChatTabBar: View {
+struct ChatBar: View {
let store: StoreOf
+ @Binding var isChatHistoryVisible: Bool
struct TabBarState: Equatable {
var tabInfo: IdentifiedArray
@@ -143,36 +226,36 @@ struct ChatTabBar: View {
}
var body: some View {
- HStack(spacing: 0) {
- Divider()
- Tabs(store: store)
- CreateButton(store: store)
- }
- .background {
- Button(action: { store.send(.switchToNextTab) }) { EmptyView() }
- .opacity(0)
- .keyboardShortcut("]", modifiers: [.command, .shift])
- Button(action: { store.send(.switchToPreviousTab) }) { EmptyView() }
- .opacity(0)
- .keyboardShortcut("[", modifiers: [.command, .shift])
+ WithPerceptionTracking {
+ HStack(spacing: 0) {
+ if let name = store.chatHistory.selectedWorkspaceName {
+ ChatWindowHeader(store: store)
+ }
+
+ Spacer()
+
+ CreateButton(store: store)
+
+ ChatHistoryButton(store: store, isChatHistoryVisible: $isChatHistoryVisible)
+ }
+ .padding(.horizontal, 12)
}
}
struct Tabs: View {
let store: StoreOf
- @State var draggingTabId: String?
@Environment(\.chatTabPool) var chatTabPool
var body: some View {
WithPerceptionTracking {
- let tabInfo = store.chatTabGroup.tabInfo
- let selectedTabId = store.chatTabGroup.selectedTabId
- ?? store.chatTabGroup.tabInfo.first?.id
+ let tabInfo = store.currentChatWorkspace?.tabInfo
+ let selectedTabId = store.currentChatWorkspace?.selectedTabId
+ ?? store.currentChatWorkspace?.tabInfo.first?.id
?? ""
ScrollViewReader { proxy in
ScrollView(.horizontal) {
HStack(spacing: 0) {
- ForEach(tabInfo, id: \.id) { info in
+ ForEach(tabInfo!, id: \.id) { info in
if let tab = chatTabPool.getTab(of: info.id) {
ChatTabBarButton(
store: store,
@@ -185,20 +268,6 @@ struct ChatTabBar: View {
tab.menu
}
.id(info.id)
- .onDrag {
- draggingTabId = info.id
- return NSItemProvider(object: info.id as NSString)
- }
- .onDrop(
- of: [.text],
- delegate: ChatTabBarDropDelegate(
- store: store,
- tabs: tabInfo,
- itemId: info.id,
- draggingTabId: $draggingTabId
- )
- )
-
} else {
EmptyView()
}
@@ -216,77 +285,61 @@ struct ChatTabBar: View {
}
}
- struct CreateButton: View {
+ struct ChatWindowHeader: View {
let store: StoreOf
var body: some View {
WithPerceptionTracking {
- let collection = store.chatTabGroup.tabCollection
- Menu {
- ForEach(0..
- let tabs: IdentifiedArray
- let itemId: String
- @Binding var draggingTabId: String?
-
- func dropUpdated(info: DropInfo) -> DropProposal? {
- return DropProposal(operation: .move)
- }
+ struct CreateButton: View {
+ let store: StoreOf
- func performDrop(info: DropInfo) -> Bool {
- draggingTabId = nil
- return true
+ var body: some View {
+ WithPerceptionTracking {
+ Button(action: {
+ store.send(.createNewTapButtonClicked(kind: nil))
+ }) {
+ Image(systemName: "plus")
+ }
+ .buttonStyle(HoverButtonStyle())
+ .padding(.horizontal, 4)
+ .help("New Chat")
+ }
+ }
}
-
- func dropEntered(info: DropInfo) {
- guard itemId != draggingTabId else { return }
- let from = tabs.firstIndex { $0.id == draggingTabId }
- let to = tabs.firstIndex { $0.id == itemId }
- guard let from, let to, from != to else { return }
- store.send(.moveChatTab(from: from, to: to))
+
+ struct ChatHistoryButton: View {
+ let store: StoreOf
+ @Binding var isChatHistoryVisible: Bool
+
+ var body: some View {
+ WithPerceptionTracking {
+ Button(action: {
+ isChatHistoryVisible = true
+ }) {
+ Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+ }
+ .buttonStyle(HoverButtonStyle())
+ .help("Show Chats...")
+ }
+ }
}
}
@@ -299,39 +352,17 @@ struct ChatTabBarButton: View {
@State var isHovered: Bool = false
var body: some View {
- HStack(spacing: 0) {
- HStack(spacing: 4) {
- icon().foregroundColor(.secondary)
- content()
- }
- .font(.callout)
- .lineLimit(1)
- .frame(maxWidth: 120)
- .padding(.horizontal, 28)
- .contentShape(Rectangle())
- .onTapGesture {
- store.send(.tabClicked(id: info.id))
- }
- .overlay(alignment: .leading) {
- Button(action: {
- store.send(.closeTabButtonClicked(id: info.id))
- }) {
- Image(systemName: "xmark")
- .foregroundColor(.secondary)
+ if self.isSelected {
+ HStack(spacing: 0) {
+ HStack(spacing: 0) {
+ icon()
+ .buttonStyle(.plain)
}
- .buttonStyle(.plain)
- .padding(2)
- .padding(.leading, 8)
- .opacity(isHovered ? 1 : 0)
+ .font(.callout)
+ .lineLimit(1)
}
- .onHover { isHovered = $0 }
- .animation(.linear(duration: 0.1), value: isHovered)
- .animation(.linear(duration: 0.1), value: isSelected)
-
- Divider().padding(.vertical, 6)
+ .frame(maxHeight: .infinity)
}
- .background(isSelected ? Color(nsColor: .selectedControlColor) : Color.clear)
- .frame(maxHeight: .infinity)
}
}
@@ -341,16 +372,16 @@ struct ChatTabContainer: View {
var body: some View {
WithPerceptionTracking {
- let tabInfo = store.chatTabGroup.tabInfo
- let selectedTabId = store.chatTabGroup.selectedTabId
- ?? store.chatTabGroup.tabInfo.first?.id
+ let tabInfo = store.currentChatWorkspace?.tabInfo
+ let selectedTabId = store.currentChatWorkspace?.selectedTabId
+ ?? store.currentChatWorkspace?.tabInfo.first?.id
?? ""
ZStack {
- if tabInfo.isEmpty {
+ if tabInfo == nil || tabInfo!.isEmpty {
Text("Empty")
} else {
- ForEach(tabInfo) { tabInfo in
+ ForEach(tabInfo!) { tabInfo in
if let tab = chatTabPool.getTab(of: tabInfo.id) {
let isActive = tab.id == selectedTabId
tab.body
@@ -398,16 +429,23 @@ struct ChatWindowView_Previews: PreviewProvider {
static func createStore() -> StoreOf {
StoreOf(
initialState: .init(
- chatTabGroup: .init(
- tabInfo: [
- .init(id: "2", title: "Empty-2"),
- .init(id: "3", title: "Empty-3"),
- .init(id: "4", title: "Empty-4"),
- .init(id: "5", title: "Empty-5"),
- .init(id: "6", title: "Empty-6"),
- .init(id: "7", title: "Empty-7"),
- ] as IdentifiedArray,
- selectedTabId: "2"
+ chatHistory: .init(
+ workspaces: [
+ .init(
+ id: "activeWorkspacePath",
+ tabInfo: [
+ .init(id: "2", title: "Empty-2"),
+ .init(id: "3", title: "Empty-3"),
+ .init(id: "4", title: "Empty-4"),
+ .init(id: "5", title: "Empty-5"),
+ .init(id: "6", title: "Empty-6"),
+ .init(id: "7", title: "Empty-7"),
+ ] as IdentifiedArray,
+ selectedTabId: "2"
+ )
+ ] as IdentifiedArray,
+ selectedWorkspacePath: "activeWorkspacePath",
+ selectedWorkspaceName: "activeWorkspacePath"
),
isPanelDisplayed: true
),
@@ -423,3 +461,8 @@ struct ChatWindowView_Previews: PreviewProvider {
}
}
+struct ChatLoadingView_Previews: PreviewProvider {
+ static var previews: some View {
+ ChatLoadingView()
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
index 990b557..e27112f 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
@@ -23,32 +23,66 @@ public struct ChatTabKind: Equatable {
}
}
-@Reducer
-public struct ChatPanelFeature {
- public struct ChatTabGroup: Equatable {
- public var tabInfo: IdentifiedArray
- public var tabCollection: [ChatTabBuilderCollection]
- public var selectedTabId: String?
-
- public var selectedTabInfo: ChatTabInfo? {
- guard let id = selectedTabId else { return tabInfo.first }
- return tabInfo[id: id]
- }
+@ObservableState
+public struct ChatHistory: Equatable {
+ public var workspaces: IdentifiedArray
+ public var selectedWorkspacePath: String?
+ public var selectedWorkspaceName: String?
+
+ public var currentChatWorkspace: ChatWorkspace? {
+ guard let id = selectedWorkspacePath else { return workspaces.first }
+ return workspaces[id: id]
+ }
+
+ init(workspaces: IdentifiedArray = [],
+ selectedWorkspacePath: String? = nil,
+ selectedWorkspaceName: String? = nil) {
+ self.workspaces = workspaces
+ self.selectedWorkspacePath = selectedWorkspacePath
+ self.selectedWorkspaceName = selectedWorkspaceName
+ }
- init(
- tabInfo: IdentifiedArray = [],
- tabCollection: [ChatTabBuilderCollection] = [],
- selectedTabId: String? = nil
- ) {
- self.tabInfo = tabInfo
- self.tabCollection = tabCollection
- self.selectedTabId = selectedTabId
+ mutating func updateHistory(_ workspace: ChatWorkspace) {
+ if let index = workspaces.firstIndex(where: { $0.id == workspace.id }) {
+ workspaces[index] = workspace
}
}
+}
+
+@ObservableState
+public struct ChatWorkspace: Identifiable, Equatable {
+ public var id: String
+ public var tabInfo: IdentifiedArray
+ public var tabCollection: [ChatTabBuilderCollection]
+ public var selectedTabId: String?
+
+ public var selectedTabInfo: ChatTabInfo? {
+ guard let tabId = selectedTabId else { return tabInfo.first }
+ return tabInfo[id: tabId]
+ }
+ public init(
+ id: String = UUID().uuidString,
+ tabInfo: IdentifiedArray = [],
+ tabCollection: [ChatTabBuilderCollection] = [],
+ selectedTabId: String? = nil
+ ) {
+ self.id = id
+ self.tabInfo = tabInfo
+ self.tabCollection = tabCollection
+ self.selectedTabId = selectedTabId
+ }
+}
+
+@Reducer
+public struct ChatPanelFeature {
@ObservableState
public struct State: Equatable {
- public var chatTabGroup = ChatTabGroup()
+ public var chatHistory = ChatHistory()
+ public var currentChatWorkspace: ChatWorkspace? {
+ return chatHistory.currentChatWorkspace
+ }
+
var colorScheme: ColorScheme = .light
public internal(set) var isPanelDisplayed = false
var isDetached = false
@@ -65,18 +99,25 @@ public struct ChatPanelFeature {
case enterFullScreen
case exitFullScreen
case presentChatPanel(forceDetach: Bool)
+ case switchWorkspace(String, String)
// Tabs
- case updateChatTabInfo(IdentifiedArray)
- case createNewTapButtonHovered
+ case updateChatHistory(ChatWorkspace)
+// case updateChatTabInfo(IdentifiedArray)
+// case createNewTapButtonHovered
case closeTabButtonClicked(id: String)
case createNewTapButtonClicked(kind: ChatTabKind?)
case tabClicked(id: String)
case appendAndSelectTab(ChatTabInfo)
- case switchToNextTab
- case switchToPreviousTab
- case moveChatTab(from: Int, to: Int)
+ case appendTabToWorkspace(ChatTabInfo, ChatWorkspace)
+// case switchToNextTab
+// case switchToPreviousTab
+// case moveChatTab(from: Int, to: Int)
case focusActiveChatTab
+
+ // Chat History
+ case chatHistoryItemClicked(id: String)
+ case chatHisotryDeleteButtonClicked(id: String)
case chatTab(id: String, action: ChatTabItem.Action)
}
@@ -111,7 +152,7 @@ public struct ChatPanelFeature {
}
case .closeActiveTabClicked:
- if let id = state.chatTabGroup.selectedTabId {
+ if let id = state.currentChatWorkspace?.selectedTabId {
return .run { send in
await send(.closeTabButtonClicked(id: id))
}
@@ -165,133 +206,177 @@ public struct ChatPanelFeature {
activateExtensionService()
await send(.focusActiveChatTab)
}
-
- case let .updateChatTabInfo(chatTabInfo):
- let previousSelectedIndex = state.chatTabGroup.tabInfo
- .firstIndex(where: { $0.id == state.chatTabGroup.selectedTabId })
- state.chatTabGroup.tabInfo = chatTabInfo
- if !chatTabInfo.contains(where: { $0.id == state.chatTabGroup.selectedTabId }) {
- if let previousSelectedIndex {
- let proposedSelectedIndex = previousSelectedIndex - 1
- if proposedSelectedIndex >= 0,
- proposedSelectedIndex < chatTabInfo.endIndex
- {
- state.chatTabGroup.selectedTabId = chatTabInfo[proposedSelectedIndex].id
- } else {
- state.chatTabGroup.selectedTabId = chatTabInfo.first?.id
- }
- } else {
- state.chatTabGroup.selectedTabId = nil
- }
+ case let .switchWorkspace(path, name):
+ state.chatHistory.selectedWorkspacePath = path
+ state.chatHistory.selectedWorkspaceName = name
+ if state.chatHistory.currentChatWorkspace == nil {
+ state.chatHistory.workspaces[id: path] = ChatWorkspace(id: path)
}
return .none
+ case let .updateChatHistory(chatWorkspace):
+ state.chatHistory.updateHistory(chatWorkspace)
+ return .none
+// case let .updateChatTabInfo(chatTabInfo):
+// let previousSelectedIndex = state.chatTabGroup.tabInfo
+// .firstIndex(where: { $0.id == state.chatTabGroup.selectedTabId })
+// state.chatTabGroup.tabInfo = chatTabInfo
+// if !chatTabInfo.contains(where: { $0.id == state.chatTabGroup.selectedTabId }) {
+// if let previousSelectedIndex {
+// let proposedSelectedIndex = previousSelectedIndex - 1
+// if proposedSelectedIndex >= 0,
+// proposedSelectedIndex < chatTabInfo.endIndex
+// {
+// state.chatTabGroup.selectedTabId = chatTabInfo[proposedSelectedIndex].id
+// } else {
+// state.chatTabGroup.selectedTabId = chatTabInfo.first?.id
+// }
+// } else {
+// state.chatTabGroup.selectedTabId = nil
+// }
+// }
+// return .none
case let .closeTabButtonClicked(id):
- let firstIndex = state.chatTabGroup.tabInfo.firstIndex { $0.id == id }
+ guard var currentChatWorkspace = state.currentChatWorkspace else {
+ return .none
+ }
+ let firstIndex = currentChatWorkspace.tabInfo.firstIndex { $0.id == id }
let nextIndex = {
guard let firstIndex else { return 0 }
let nextIndex = firstIndex - 1
return max(nextIndex, 0)
}()
- state.chatTabGroup.tabInfo.removeAll { $0.id == id }
- if state.chatTabGroup.tabInfo.isEmpty {
+ currentChatWorkspace.tabInfo.removeAll { $0.id == id }
+ if currentChatWorkspace.tabInfo.isEmpty {
state.isPanelDisplayed = false
}
- if nextIndex < state.chatTabGroup.tabInfo.count {
- state.chatTabGroup.selectedTabId = state.chatTabGroup.tabInfo[nextIndex].id
+ if nextIndex < currentChatWorkspace.tabInfo.count {
+ currentChatWorkspace.selectedTabId = currentChatWorkspace.tabInfo[nextIndex].id
} else {
- state.chatTabGroup.selectedTabId = nil
+ currentChatWorkspace.selectedTabId = nil
}
+ state.chatHistory.updateHistory(currentChatWorkspace)
return .none
-
- case .createNewTapButtonHovered:
- state.chatTabGroup.tabCollection = chatTabBuilderCollection()
+
+ case let .chatHisotryDeleteButtonClicked(id):
+ // the current chat should not be deleted
+ guard var currentChatWorkspace = state.currentChatWorkspace, id != currentChatWorkspace.selectedTabId else {
+ return .none
+ }
+ currentChatWorkspace.tabInfo.removeAll { $0.id == id }
+ state.chatHistory.updateHistory(currentChatWorkspace)
return .none
+// case .createNewTapButtonHovered:
+// state.chatTabGroup.tabCollection = chatTabBuilderCollection()
+// return .none
+
case .createNewTapButtonClicked:
return .none // handled elsewhere
case let .tabClicked(id):
- guard state.chatTabGroup.tabInfo.contains(where: { $0.id == id }) else {
- state.chatTabGroup.selectedTabId = nil
+ guard var currentChatWorkspace = state.currentChatWorkspace, currentChatWorkspace.tabInfo.contains(where: { $0.id == id }) else {
+// chatTabGroup.selectedTabId = nil
return .none
}
- state.chatTabGroup.selectedTabId = id
+ currentChatWorkspace.selectedTabId = id
+ state.chatHistory.updateHistory(currentChatWorkspace)
return .run { send in
await send(.focusActiveChatTab)
}
-
- case let .appendAndSelectTab(tab):
- guard !state.chatTabGroup.tabInfo.contains(where: { $0.id == tab.id })
- else { return .none }
- state.chatTabGroup.tabInfo.append(tab)
- state.chatTabGroup.selectedTabId = tab.id
- return .run { send in
- await send(.focusActiveChatTab)
- }
-
- case .switchToNextTab:
- let selectedId = state.chatTabGroup.selectedTabId
- guard let index = state.chatTabGroup.tabInfo
- .firstIndex(where: { $0.id == selectedId })
- else { return .none }
- let nextIndex = index + 1
- if nextIndex >= state.chatTabGroup.tabInfo.endIndex {
+
+ case let .chatHistoryItemClicked(id):
+ guard var chatWorkspace = state.currentChatWorkspace, chatWorkspace.tabInfo.contains(where: { $0.id == id }) else {
+// state.chatGroupCollection.selectedChatGroup?.selectedTabId = nil
return .none
}
- let targetId = state.chatTabGroup.tabInfo[nextIndex].id
- state.chatTabGroup.selectedTabId = targetId
+ chatWorkspace.selectedTabId = id
+ state.chatHistory.updateHistory(chatWorkspace)
return .run { send in
await send(.focusActiveChatTab)
}
- case .switchToPreviousTab:
- let selectedId = state.chatTabGroup.selectedTabId
- guard let index = state.chatTabGroup.tabInfo
- .firstIndex(where: { $0.id == selectedId })
+ case let .appendAndSelectTab(tab):
+ guard var chatWorkspace = state.currentChatWorkspace, !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id })
else { return .none }
- let previousIndex = index - 1
- if previousIndex < 0 || previousIndex >= state.chatTabGroup.tabInfo.endIndex {
- return .none
- }
- let targetId = state.chatTabGroup.tabInfo[previousIndex].id
- state.chatTabGroup.selectedTabId = targetId
+ chatWorkspace.tabInfo.append(tab)
+ chatWorkspace.selectedTabId = tab.id
+ state.chatHistory.updateHistory(chatWorkspace)
return .run { send in
await send(.focusActiveChatTab)
}
-
- case let .moveChatTab(from, to):
- guard from >= 0, from < state.chatTabGroup.tabInfo.endIndex, to >= 0,
- to <= state.chatTabGroup.tabInfo.endIndex
- else {
- return .none
- }
- let tab = state.chatTabGroup.tabInfo[from]
- state.chatTabGroup.tabInfo.remove(at: from)
- state.chatTabGroup.tabInfo.insert(tab, at: to)
+ case let .appendTabToWorkspace(tab, chatWorkspace):
+ guard !chatWorkspace.tabInfo.contains(where: { $0.id == tab.id })
+ else { return .none }
+ var targetWorkspace = chatWorkspace
+ targetWorkspace.tabInfo.append(tab)
+ targetWorkspace.selectedTabId = tab.id
+ state.chatHistory.updateHistory(targetWorkspace)
return .none
+// case .switchToNextTab:
+// let selectedId = state.chatTabGroup.selectedTabId
+// guard let index = state.chatTabGroup.tabInfo
+// .firstIndex(where: { $0.id == selectedId })
+// else { return .none }
+// let nextIndex = index + 1
+// if nextIndex >= state.chatTabGroup.tabInfo.endIndex {
+// return .none
+// }
+// let targetId = state.chatTabGroup.tabInfo[nextIndex].id
+// state.chatTabGroup.selectedTabId = targetId
+// return .run { send in
+// await send(.focusActiveChatTab)
+// }
+
+// case .switchToPreviousTab:
+// let selectedId = state.chatTabGroup.selectedTabId
+// guard let index = state.chatTabGroup.tabInfo
+// .firstIndex(where: { $0.id == selectedId })
+// else { return .none }
+// let previousIndex = index - 1
+// if previousIndex < 0 || previousIndex >= state.chatTabGroup.tabInfo.endIndex {
+// return .none
+// }
+// let targetId = state.chatTabGroup.tabInfo[previousIndex].id
+// state.chatTabGroup.selectedTabId = targetId
+// return .run { send in
+// await send(.focusActiveChatTab)
+// }
+
+// case let .moveChatTab(from, to):
+// guard from >= 0, from < state.chatTabGroup.tabInfo.endIndex, to >= 0,
+// to <= state.chatTabGroup.tabInfo.endIndex
+// else {
+// return .none
+// }
+// let tab = state.chatTabGroup.tabInfo[from]
+// state.chatTabGroup.tabInfo.remove(at: from)
+// state.chatTabGroup.tabInfo.insert(tab, at: to)
+// return .none
+
case .focusActiveChatTab:
guard FeatureFlagNotifierImpl.shared.featureFlags.chat else {
return .none
}
- let id = state.chatTabGroup.selectedTabInfo?.id
+ let id = state.currentChatWorkspace?.selectedTabInfo?.id
guard let id else { return .none }
return .run { send in
await send(.chatTab(id: id, action: .focus))
}
- case let .chatTab(id, .close):
- return .run { send in
- await send(.closeTabButtonClicked(id: id))
- }
+// case let .chatTab(id, .close):
+// return .run { send in
+// await send(.closeTabButtonClicked(id: id))
+// }
case .chatTab:
return .none
}
- }.forEach(\.chatTabGroup.tabInfo, action: /Action.chatTab) {
- ChatTabItem()
}
+// .forEach(\.chatGroupCollection.selectedChatGroup?.tabInfo, action: /Action.chatTab) {
+// ChatTabItem()
+// }
}
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift
index 1e3f3dc..e76afbc 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift
@@ -21,6 +21,9 @@ public struct PanelFeature {
// MARK: SuggestionPanel
var suggestionPanelState = SuggestionPanelFeature.State()
+
+ var warningMessage: String?
+ var warningURL: String?
}
public enum Action: Equatable {
@@ -38,6 +41,9 @@ public struct PanelFeature {
case sharedPanel(SharedPanelFeature.Action)
case suggestionPanel(SuggestionPanelFeature.Action)
+
+ case presentWarning(message: String, url: String?)
+ case dismissWarning
}
@Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency
@@ -142,6 +148,20 @@ public struct PanelFeature {
case .suggestionPanel:
return .none
+
+ case .presentWarning(let message, let url):
+ state.warningMessage = message
+ state.warningURL = url
+ state.suggestionPanelState.warningMessage = message
+ state.suggestionPanelState.warningURL = url
+ return .none
+
+ case .dismissWarning:
+ state.warningMessage = nil
+ state.warningURL = nil
+ state.suggestionPanelState.warningMessage = nil
+ state.suggestionPanelState.warningURL = nil
+ return .none
}
}
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift
index 82010df..028ae77 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift
@@ -14,6 +14,8 @@ public struct SuggestionPanelFeature {
var lineHeight: Double = 17
var isPanelDisplayed: Bool = false
var isPanelOutOfFrame: Bool = false
+ var warningMessage: String?
+ var warningURL: String?
var opacity: Double {
guard isPanelDisplayed else { return 0 }
if isPanelOutOfFrame { return 0 }
@@ -24,9 +26,19 @@ public struct SuggestionPanelFeature {
public enum Action: Equatable {
case noAction
+ case dismissWarning
}
public var body: some ReducerOf {
- Reduce { _, _ in .none }
+ Reduce { state, action in
+ switch action {
+ case .dismissWarning:
+ state.warningMessage = nil
+ state.warningURL = nil
+ return .none
+ default:
+ return .none
+ }
+ }
}
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift
index be6c2e6..e0af56c 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift
@@ -66,8 +66,9 @@ public struct WidgetFeature {
}
return false
}(),
- isContentEmpty: chatPanelState.chatTabGroup.tabInfo.isEmpty
- && panelState.sharedPanelState.isEmpty,
+ isContentEmpty: chatPanelState.currentChatWorkspace == nil
+ || (chatPanelState.currentChatWorkspace!.tabInfo.isEmpty
+ && panelState.sharedPanelState.isEmpty),
isChatPanelDetached: chatPanelState.isDetached,
isChatOpen: chatPanelState.isPanelDisplayed
)
@@ -162,7 +163,7 @@ public struct WidgetFeature {
}
let isDisplayingContent = state._internalCircularWidgetState.isDisplayingContent
- let hasChat = state.chatPanelState.chatTabGroup.selectedTabInfo != nil
+ let hasChat = state.chatPanelState.currentChatWorkspace?.selectedTabInfo != nil
let hasPromptToCode = state.panelState.sharedPanelState.content
.promptToCodeGroup.activePromptToCode != nil
diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift
index d8be66d..c272077 100644
--- a/Core/Sources/SuggestionWidget/Styles.swift
+++ b/Core/Sources/SuggestionWidget/Styles.swift
@@ -5,7 +5,7 @@ import SwiftUI
enum Style {
static let panelHeight: Double = 560
- static let panelWidth: Double = 454
+ static let panelWidth: Double = 504
static let inlineSuggestionMaxHeight: Double = 400
static let inlineSuggestionPadding: Double = 25
static let widgetHeight: Double = 20
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift
new file mode 100644
index 0000000..f6c429c
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/WarningPanel.swift
@@ -0,0 +1,78 @@
+import SwiftUI
+import SharedUIComponents
+import XcodeInspector
+
+struct WarningPanel: View {
+ let message: String
+ let url: String?
+ let firstLineIndent: Double
+ let onDismiss: () -> Void
+
+ @Environment(\.colorScheme) var colorScheme
+ @Environment(CursorPositionTracker.self) var cursorPositionTracker
+ @AppStorage(\.clsWarningDismissedUntilRelaunch) var isDismissedUntilRelaunch
+
+ var foregroundColor: Color {
+ return colorScheme == .light ? .black.opacity(0.85) : .white.opacity(0.85)
+ }
+
+ var body: some View {
+ if !isDismissedUntilRelaunch {
+ HStack(spacing: 12) {
+ HStack(spacing: 8) {
+ Image("CopilotLogo")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFit()
+ .foregroundColor(.primary)
+ .frame(width: 14, height: 14)
+
+ Text("Monthly completion limit reached.")
+ .font(.system(size: 12))
+ .foregroundColor(.primary)
+ .lineLimit(1)
+ }
+ .padding(.horizontal, 9)
+ .background(
+ Capsule()
+ .fill(foregroundColor.opacity(0.1))
+ .frame(height: 17)
+ )
+ .fixedSize()
+
+ HStack(spacing: 8) {
+ if let url = url {
+ Button("Upgrade Now") {
+ NSWorkspace.shared.open(URL(string: url)!)
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 2)
+ .background(Color(nsColor: .controlAccentColor))
+ .foregroundColor(Color(nsColor: .white))
+ .cornerRadius(6)
+ .font(.system(size: 12))
+ .fixedSize()
+ }
+
+ Button("Dismiss") {
+ isDismissedUntilRelaunch = true
+ onDismiss()
+ }
+ .buttonStyle(.bordered)
+ .font(.system(size: 12))
+ .keyboardShortcut(.escape, modifiers: [])
+ .fixedSize()
+ }
+ }
+ .padding(.top, 24)
+ .padding(
+ .leading,
+ firstLineIndent + 20 + CGFloat(
+ cursorPositionTracker.cursorPosition.character
+ )
+ )
+ .background(.clear)
+ }
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift
index 2f2306d..a046985 100644
--- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift
@@ -7,30 +7,42 @@ struct SuggestionPanelView: View {
var body: some View {
WithPerceptionTracking {
- VStack(spacing: 0) {
- Content(store: store)
- .allowsHitTesting(
- store.isPanelDisplayed && !store.isPanelOutOfFrame
+ Group {
+ if let message = store.warningMessage {
+ WarningPanel(
+ message: message,
+ url: store.warningURL,
+ firstLineIndent: store.firstLineIndent
+ ) {
+ store.send(.dismissWarning)
+ }
+ } else {
+ VStack(spacing: 0) {
+ Content(store: store)
+ .allowsHitTesting(
+ store.isPanelDisplayed && !store.isPanelOutOfFrame
+ )
+ .frame(maxWidth: .infinity)
+ }
+ .preferredColorScheme(store.colorScheme)
+ .opacity(store.opacity)
+ .animation(
+ featureFlag: \.animationBCrashSuggestion,
+ .easeInOut(duration: 0.2),
+ value: store.isPanelDisplayed
)
- .frame(maxWidth: .infinity)
+ .animation(
+ featureFlag: \.animationBCrashSuggestion,
+ .easeInOut(duration: 0.2),
+ value: store.isPanelOutOfFrame
+ )
+ .frame(
+ maxWidth: .infinity,
+ maxHeight: Style.inlineSuggestionMaxHeight,
+ alignment: .top
+ )
+ }
}
- .preferredColorScheme(store.colorScheme)
- .opacity(store.opacity)
- .animation(
- featureFlag: \.animationBCrashSuggestion,
- .easeInOut(duration: 0.2),
- value: store.isPanelDisplayed
- )
- .animation(
- featureFlag: \.animationBCrashSuggestion,
- .easeInOut(duration: 0.2),
- value: store.isPanelOutOfFrame
- )
- .frame(
- maxWidth: .infinity,
- maxHeight: Style.inlineSuggestionMaxHeight,
- alignment: .top
- )
}
}
diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
index 1d80452..06adce2 100644
--- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
@@ -92,3 +92,13 @@ public extension SuggestionWidgetController {
}
}
+extension SuggestionWidgetController {
+ public func presentWarning(message: String, url: String?) {
+ store.send(.panel(.presentWarning(message: message, url: url)))
+ }
+
+ public func dismissWarning() {
+ store.send(.panel(.dismissWarning))
+ }
+}
+
diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
index 3cb6298..a7dcae3 100644
--- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
+++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
@@ -135,16 +135,10 @@ enum UpdateLocationStrategy {
}()
let alignPanelTopToAnchor = fixedAlignment ?? (y > activeScreen.frame.midY)
+ let chatPanelFrame = getChatPanelFrame(mainScreen)
+
if putPanelToTheRight {
let anchorFrame = proposedAnchorFrameOnTheRightSide
- let panelFrame = CGRect(
- x: proposedPanelX,
- y: alignPanelTopToAnchor
- ? anchorFrame.maxY - Style.panelHeight
- : anchorFrame.minY - editorFrameExpendedSize.height,
- width: Style.panelWidth,
- height: Style.panelHeight
- )
let tabFrame = CGRect(
x: anchorFrame.origin.x,
y: alignPanelTopToAnchor
@@ -158,7 +152,7 @@ enum UpdateLocationStrategy {
widgetFrame: widgetFrameOnTheRightSide,
tabFrame: tabFrame,
defaultPanelLocation: .init(
- frame: panelFrame,
+ frame: chatPanelFrame,
alignPanelTop: alignPanelTopToAnchor
),
suggestionPanelLocation: nil
@@ -197,14 +191,6 @@ enum UpdateLocationStrategy {
if putAnchorToTheLeft {
let anchorFrame = proposedAnchorFrameOnTheLeftSide
- let panelFrame = CGRect(
- x: proposedPanelX,
- y: alignPanelTopToAnchor
- ? anchorFrame.maxY - Style.panelHeight
- : anchorFrame.minY - editorFrameExpendedSize.height,
- width: Style.panelWidth,
- height: Style.panelHeight
- )
let tabFrame = CGRect(
x: anchorFrame.origin.x,
y: alignPanelTopToAnchor
@@ -217,23 +203,13 @@ enum UpdateLocationStrategy {
widgetFrame: widgetFrameOnTheLeftSide,
tabFrame: tabFrame,
defaultPanelLocation: .init(
- frame: panelFrame,
+ frame: chatPanelFrame,
alignPanelTop: alignPanelTopToAnchor
),
suggestionPanelLocation: nil
)
} else {
let anchorFrame = proposedAnchorFrameOnTheRightSide
- let panelFrame = CGRect(
- x: anchorFrame.maxX - Style.panelWidth,
- y: alignPanelTopToAnchor
- ? anchorFrame.maxY - Style.panelHeight - Style.widgetHeight
- - Style.widgetPadding
- : anchorFrame.maxY + Style.widgetPadding
- - editorFrameExpendedSize.height,
- width: Style.panelWidth,
- height: Style.panelHeight
- )
let tabFrame = CGRect(
x: anchorFrame.minX - Style.widgetPadding - Style.widgetWidth,
y: anchorFrame.origin.y,
@@ -244,7 +220,7 @@ enum UpdateLocationStrategy {
widgetFrame: widgetFrameOnTheRightSide,
tabFrame: tabFrame,
defaultPanelLocation: .init(
- frame: panelFrame,
+ frame: chatPanelFrame,
alignPanelTop: alignPanelTopToAnchor
),
suggestionPanelLocation: nil
@@ -342,5 +318,15 @@ enum UpdateLocationStrategy {
return selectionFrame
}
+
+ static func getChatPanelFrame(_ screen: NSScreen) -> CGRect {
+ let visibleScreenFrame = screen.visibleFrame
+ // avoid too wide
+ let width = min(Style.panelWidth, visibleScreenFrame.width * 0.3)
+ let height = visibleScreenFrame.height
+ let x = visibleScreenFrame.width - width
+
+ return CGRect(x: x, y: visibleScreenFrame.height, width: width, height: height)
+ }
}
diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
index 72ad057..6328530 100644
--- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
+++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
@@ -360,7 +360,8 @@ extension WidgetWindowsController {
await MainActor.run {
let state = store.withState { $0 }
let isChatPanelDetached = state.chatPanelState.isDetached
- let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty
+ let hasChat = state.chatPanelState.currentChatWorkspace != nil
+ && !state.chatPanelState.currentChatWorkspace!.tabInfo.isEmpty
if let activeApp, activeApp.isXcode {
let application = activeApp.appElement
diff --git a/Docs/chat_dark.gif b/Docs/chat_dark.gif
new file mode 100644
index 0000000..abd5cc2
Binary files /dev/null and b/Docs/chat_dark.gif differ
diff --git a/Docs/copilot-menu_dark.png b/Docs/copilot-menu_dark.png
new file mode 100644
index 0000000..3c8b4b4
Binary files /dev/null and b/Docs/copilot-menu_dark.png differ
diff --git a/Docs/xcode-menu.png b/Docs/xcode-menu.png
index a3dc21a..c30e539 100644
Binary files a/Docs/xcode-menu.png and b/Docs/xcode-menu.png differ
diff --git a/Docs/xcode-menu_dark.png b/Docs/xcode-menu_dark.png
new file mode 100644
index 0000000..28b957b
Binary files /dev/null and b/Docs/xcode-menu_dark.png differ
diff --git a/EditorExtension/Info.plist b/EditorExtension/Info.plist
index 12cf1cc..13a9bdb 100644
--- a/EditorExtension/Info.plist
+++ b/EditorExtension/Info.plist
@@ -42,5 +42,7 @@
TEAM_ID_PREFIX
$(TeamIdentifierPrefix)
+ STANDARD_TELEMETRY_CHANNEL_KEY
+ $(STANDARD_TELEMETRY_CHANNEL_KEY)
diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift
index 9453d31..959f914 100644
--- a/ExtensionService/AppDelegate+Menu.swift
+++ b/ExtensionService/AppDelegate+Menu.swift
@@ -5,6 +5,7 @@ import Status
import SuggestionBasic
import XcodeInspector
import Logger
+import StatusBarItemView
extension AppDelegate {
fileprivate var statusBarMenuIdentifier: NSUserInterfaceItemIdentifier {
@@ -19,14 +20,6 @@ extension AppDelegate {
.init("sourceEditorDebugMenu")
}
- fileprivate var toggleCompletionsMenuItemIdentifier: NSUserInterfaceItemIdentifier {
- .init("toggleCompletionsMenuItem")
- }
-
- fileprivate var toggleIgnoreLanguageMenuItemIdentifier: NSUserInterfaceItemIdentifier {
- .init("toggleIgnoreLanguageMenuItem")
- }
-
@MainActor
@objc func buildStatusBarMenu() {
let statusBar = NSStatusBar.system
@@ -34,14 +27,22 @@ extension AppDelegate {
withLength: NSStatusItem.squareLength
)
statusBarItem.button?.image = NSImage(named: "MenuBarIcon")
+ statusBarItem.button?.image?.isTemplate = false
let statusBarMenu = NSMenu(title: "Status Bar Menu")
statusBarMenu.identifier = statusBarMenuIdentifier
statusBarItem.menu = statusBarMenu
- let hostAppName = Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String
- ?? "GitHub Copilot for Xcode"
-
+ let boldTitle = NSAttributedString(
+ string: "Github Copilot",
+ attributes: [
+ .font: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize),
+ .foregroundColor: NSColor(.primary)
+ ]
+ )
+ let attributedTitle = NSMenuItem()
+ attributedTitle.attributedTitle = boldTitle
+
let checkForUpdate = NSMenuItem(
title: "Check for Updates",
action: #selector(checkForUpdate),
@@ -49,7 +50,7 @@ extension AppDelegate {
)
let openCopilotForXcodeItem = NSMenuItem(
- title: "Open \(hostAppName) Settings",
+ title: "Settings",
action: #selector(openCopilotForXcode),
keyEquivalent: ""
)
@@ -65,12 +66,12 @@ extension AppDelegate {
xcodeInspectorDebug.submenu = xcodeInspectorDebugMenu
xcodeInspectorDebug.isHidden = false
- statusMenuItem = NSMenuItem(
+ extensionStatusItem = NSMenuItem(
title: "",
- action: #selector(openStatusLink),
+ action: #selector(openExtensionStatusLink),
keyEquivalent: ""
)
- statusMenuItem.isHidden = true
+ extensionStatusItem.isHidden = true
let quitItem = NSMenuItem(
title: "Quit",
@@ -79,50 +80,82 @@ extension AppDelegate {
)
quitItem.target = self
- let toggleCompletions = NSMenuItem(
+ toggleCompletions = NSMenuItem(
title: "Enable/Disable Completions",
action: #selector(toggleCompletionsEnabled),
keyEquivalent: ""
)
- toggleCompletions.identifier = toggleCompletionsMenuItemIdentifier;
-
- let toggleIgnoreLanguage = NSMenuItem(
+
+ toggleIgnoreLanguage = NSMenuItem(
title: "No Active Document",
action: nil,
keyEquivalent: ""
)
- toggleIgnoreLanguage.identifier = toggleIgnoreLanguageMenuItemIdentifier;
- authMenuItem = NSMenuItem(
- title: "Copilot Connection: Checking...",
- action: #selector(openCopilotForXcode),
+ // Auth menu item with custom view
+ accountItem = NSMenuItem()
+ accountItem.view = AccountItemView(
+ target: self,
+ action: #selector(signIntoGitHub)
+ )
+
+ authStatusItem = NSMenuItem(
+ title: "",
+ action: nil,
+ keyEquivalent: ""
+ )
+ extensionStatusItem.isHidden = true
+
+ upSellItem = NSMenuItem(
+ title: "",
+ action: #selector(openUpSellLink),
keyEquivalent: ""
)
+ extensionStatusItem.isHidden = true
let openDocs = NSMenuItem(
- title: "View Copilot Documentation...",
+ title: "View Documentation",
action: #selector(openCopilotDocs),
keyEquivalent: ""
)
let openForum = NSMenuItem(
- title: "View Copilot Feedback Forum...",
+ title: "Feedback Forum",
action: #selector(openCopilotForum),
keyEquivalent: ""
)
+ openChat = NSMenuItem(
+ title: "Open Chat",
+ action: #selector(openGlobalChat),
+ keyEquivalent: ""
+ )
+
+ signOutItem = NSMenuItem(
+ title: "Sign Out",
+ action: #selector(signOutGitHub),
+ keyEquivalent: ""
+ )
+
+ statusBarMenu.addItem(attributedTitle)
+ statusBarMenu.addItem(accountItem)
+ statusBarMenu.addItem(authStatusItem)
+ statusBarMenu.addItem(upSellItem)
+ statusBarMenu.addItem(.separator())
+ statusBarMenu.addItem(extensionStatusItem)
+ statusBarMenu.addItem(.separator())
statusBarMenu.addItem(openCopilotForXcodeItem)
statusBarMenu.addItem(.separator())
statusBarMenu.addItem(checkForUpdate)
statusBarMenu.addItem(toggleCompletions)
statusBarMenu.addItem(toggleIgnoreLanguage)
- statusBarMenu.addItem(.separator())
- statusBarMenu.addItem(authMenuItem)
- statusBarMenu.addItem(statusMenuItem)
+ statusBarMenu.addItem(openChat)
statusBarMenu.addItem(.separator())
statusBarMenu.addItem(openDocs)
statusBarMenu.addItem(openForum)
statusBarMenu.addItem(.separator())
+ statusBarMenu.addItem(signOutItem)
+ statusBarMenu.addItem(.separator())
statusBarMenu.addItem(xcodeInspectorDebug)
statusBarMenu.addItem(quitItem)
@@ -142,21 +175,19 @@ extension AppDelegate: NSMenuDelegate {
.value(for: \.enableXcodeInspectorDebugMenu)
}
- if let toggleCompletions = menu.items.first(where: { item in
- item.identifier == toggleCompletionsMenuItemIdentifier
- }) {
+ if toggleCompletions != nil {
toggleCompletions.title = "\(UserDefaults.shared.value(for: \.realtimeSuggestionToggle) ? "Disable" : "Enable") Completions"
}
-
- if let toggleLanguage = menu.items.first(where: { item in
- item.identifier == toggleIgnoreLanguageMenuItemIdentifier
- }) {
+
+ if toggleIgnoreLanguage != nil {
if let lang = DisabledLanguageList.shared.activeDocumentLanguage {
- toggleLanguage.title = "\(DisabledLanguageList.shared.isEnabled(lang) ? "Disable" : "Enable") Completions for \(lang.rawValue)"
- toggleLanguage.action = #selector(toggleIgnoreLanguage)
+ toggleIgnoreLanguage.title = "\(DisabledLanguageList.shared.isEnabled(lang) ? "Disable" : "Enable") Completions for \(lang.rawValue)"
+ toggleIgnoreLanguage.action = #selector(
+ toggleIgnoreLanguageEnabled
+ )
} else {
- toggleLanguage.title = "No Active Document"
- toggleLanguage.action = nil
+ toggleIgnoreLanguage.title = "No Active Document"
+ toggleIgnoreLanguage.action = nil
}
}
@@ -271,8 +302,8 @@ private extension AppDelegate {
}
}
}
-
- @objc func toggleIgnoreLanguage() {
+
+ @objc func toggleIgnoreLanguageEnabled() {
guard let lang = DisabledLanguageList.shared.activeDocumentLanguage else { return }
if DisabledLanguageList.shared.isEnabled(lang) {
@@ -298,7 +329,7 @@ private extension AppDelegate {
}
}
- @objc func openStatusLink() {
+ @objc func openExtensionStatusLink() {
Task {
let status = await Status.shared.getStatus()
if let s = status.url, let url = URL(string: s) {
@@ -306,6 +337,21 @@ private extension AppDelegate {
}
}
}
+
+ @objc func openUpSellLink() {
+ Task {
+ let status = await Status.shared.getStatus()
+ if status.authStatus == AuthStatus.Status.notAuthorized {
+ if let url = URL(string: "https://github.com/features/copilot/plans") {
+ NSWorkspace.shared.open(url)
+ }
+ } else {
+ if let url = URL(string: "https://github.com/github-copilot/signup/copilot_individual") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+ }
+ }
}
private extension NSMenuItem {
@@ -319,4 +365,3 @@ private extension NSMenuItem {
return item
}
}
-
diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift
index dd4e888..f10a358 100644
--- a/ExtensionService/AppDelegate.swift
+++ b/ExtensionService/AppDelegate.swift
@@ -13,6 +13,8 @@ import UserDefaultsObserver
import UserNotifications
import XcodeInspector
import XPCShared
+import GitHubCopilotViewModel
+import StatusBarItemView
let bundleIdentifierBase = Bundle.main
.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String
@@ -31,8 +33,14 @@ class ExtensionUpdateCheckerDelegate: UpdateCheckerDelegate {
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
let service = Service.shared
var statusBarItem: NSStatusItem!
- var statusMenuItem: NSMenuItem!
- var authMenuItem: NSMenuItem!
+ var extensionStatusItem: NSMenuItem!
+ var accountItem: NSMenuItem!
+ var authStatusItem: NSMenuItem!
+ var upSellItem: NSMenuItem!
+ var toggleCompletions: NSMenuItem!
+ var toggleIgnoreLanguage: NSMenuItem!
+ var openChat: NSMenuItem!
+ var signOutItem: NSMenuItem!
var xpcController: XPCController?
let updateChecker =
UpdateChecker(
@@ -60,6 +68,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
watchAXStatus()
watchAuthStatus()
setInitialStatusBarStatus()
+ UserDefaults.shared.set(false, for: \.clsWarningDismissedUntilRelaunch)
}
@objc func quit() {
@@ -78,6 +87,44 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
task.launch()
task.waitUntilExit()
}
+
+ @objc func signIntoGitHub() {
+ Task { @MainActor in
+ let viewModel = GitHubCopilotViewModel.shared
+ // Don't trigger the shared viewModel's alert
+ do {
+ guard let signInResponse = try await viewModel.preSignIn() else {
+ return
+ }
+
+ NSApp.activate(ignoringOtherApps: true)
+ let alert = NSAlert()
+ alert.messageText = signInResponse.userCode
+ alert.informativeText = """
+ Please enter the above code in the GitHub website to authorize your \
+ GitHub account with Copilot for Xcode.
+ \(signInResponse.verificationURL.absoluteString)
+ """
+ alert.addButton(withTitle: "Copy Code and Open")
+ alert.addButton(withTitle: "Cancel")
+
+ let response = alert.runModal()
+ if response == .alertFirstButtonReturn {
+ viewModel.signInResponse = signInResponse
+ viewModel.copyAndOpen()
+ }
+ } catch {
+ Logger.service.error("GitHub copilot view model Sign in fails: \(error)")
+ }
+ }
+ }
+
+ @objc func signOutGitHub() {
+ Task { @MainActor in
+ let viewModel = GitHubCopilotViewModel.shared
+ viewModel.signOut()
+ }
+ }
@objc func openGlobalChat() {
Task { @MainActor in
@@ -210,20 +257,121 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
Logger.service.error("Failed to read auth status: \(error)")
}
}
+
+ private func configureNotLoggedIn() {
+ self.accountItem.view = AccountItemView(
+ target: self,
+ action: #selector(signIntoGitHub)
+ )
+ self.authStatusItem.isHidden = true
+ self.upSellItem.isHidden = true
+ self.toggleCompletions.isHidden = true
+ self.toggleIgnoreLanguage.isHidden = true
+ self.openChat.isHidden = true
+ self.signOutItem.isHidden = true
+ }
+
+ private func configureLoggedIn(status: StatusResponse) {
+ self.accountItem.view = AccountItemView(
+ target: self,
+ action: nil,
+ userName: status.userName ?? ""
+ )
+ if !status.clsMessage.isEmpty {
+ self.authStatusItem.isHidden = false
+ let CLSMessageSummary = getCLSMessageSummary(status.clsMessage)
+ self.authStatusItem.title = CLSMessageSummary.summary
+
+ let submenu = NSMenu()
+ let attributedCLSErrorItem = NSMenuItem()
+ attributedCLSErrorItem.view = ErrorMessageView(
+ errorMessage: CLSMessageSummary.detail
+ )
+ submenu.addItem(attributedCLSErrorItem)
+ submenu.addItem(.separator())
+ submenu.addItem(
+ NSMenuItem(
+ title: "View Details on GitHub",
+ action: #selector(openGitHubDetailsLink),
+ keyEquivalent: ""
+ )
+ )
+
+ self.authStatusItem.submenu = submenu
+ self.authStatusItem.isEnabled = true
+
+ self.upSellItem.title = "Upgrade Now"
+ self.upSellItem.isHidden = false
+ self.upSellItem.isEnabled = true
+ } else {
+ self.authStatusItem.isHidden = true
+ self.upSellItem.isHidden = true
+ }
+ self.toggleCompletions.isHidden = false
+ self.toggleIgnoreLanguage.isHidden = false
+ self.openChat.isHidden = false
+ self.signOutItem.isHidden = false
+ }
+
+ private func configureNotAuthorized(status: StatusResponse) {
+ self.accountItem.view = AccountItemView(
+ target: self,
+ action: nil,
+ userName: status.userName ?? ""
+ )
+ self.authStatusItem.isHidden = false
+ self.authStatusItem.title = "No Subscription"
+
+ let submenu = NSMenu()
+ let attributedNotAuthorizedItem = NSMenuItem()
+ attributedNotAuthorizedItem.view = ErrorMessageView(
+ errorMessage: "GitHub Copilot features are disabled. Check your subscription to enable them."
+ )
+ attributedNotAuthorizedItem.isEnabled = true
+ submenu.addItem(attributedNotAuthorizedItem)
+
+ self.authStatusItem.submenu = submenu
+ self.authStatusItem.isEnabled = true
+
+ self.upSellItem.title = "Check Subscription Plans"
+ self.upSellItem.isHidden = false
+ self.upSellItem.isEnabled = true
+ self.toggleCompletions.isHidden = true
+ self.toggleIgnoreLanguage.isHidden = true
+ self.openChat.isHidden = true
+ self.signOutItem.isHidden = false
+ }
+
+ private func configureUnknown() {
+ self.accountItem.view = AccountItemView(
+ target: self,
+ action: nil,
+ userName: "Unknown User"
+ )
+ self.authStatusItem.isHidden = true
+ self.upSellItem.isHidden = true
+ self.toggleCompletions.isHidden = false
+ self.toggleIgnoreLanguage.isHidden = false
+ self.openChat.isHidden = false
+ self.signOutItem.isHidden = false
+ }
func updateStatusBarItem() {
Task { @MainActor in
let status = await Status.shared.getStatus()
- let image = status.icon.nsImage
- self.statusBarItem.button?.image = image
- self.authMenuItem.title = status.authMessage
+ self.statusBarItem.button?.image = status.icon.nsImage
+ switch status.authStatus {
+ case .notLoggedIn: configureNotLoggedIn()
+ case .loggedIn: configureLoggedIn(status: status)
+ case .notAuthorized: configureNotAuthorized(status: status)
+ case .unknown: configureUnknown()
+ }
if let message = status.message {
- // TODO switch to attributedTitle to enable line breaks and color.
- self.statusMenuItem.title = message
- self.statusMenuItem.isHidden = false
- self.statusMenuItem.isEnabled = status.url != nil
+ self.extensionStatusItem.title = message
+ self.extensionStatusItem.isHidden = false
+ self.extensionStatusItem.isEnabled = status.url != nil
} else {
- self.statusMenuItem.isHidden = true
+ self.extensionStatusItem.isHidden = true
}
self.markAsProcessing(status.inProgress)
}
@@ -250,6 +398,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
statusBarItem.button?.image = nil
progressView = progress
}
+
+ @objc func openGitHubDetailsLink() {
+ Task {
+ if let url = URL(string: "https://github.com/copilot") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+ }
}
extension NSRunningApplication {
@@ -275,3 +431,35 @@ func locateHostBundleURL(url: URL) -> URL {
return devAppURL
}
+struct CLSMessage {
+ let summary: String
+ 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 summary: String
+ if message.contains("You've reached your monthly chat messages limit") {
+ summary = "Monthly Chat Limit Reached"
+ } else if message.contains("You've reached your monthly code completion limit") {
+ summary = "Monthly Completion Limit Reached"
+ } else {
+ summary = "CLS Error"
+ }
+
+ 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: summary, detail: detail)
+}
diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/Contents.json b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/Contents.json
new file mode 100644
index 0000000..57a72d4
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "insertButton.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "insertButton 1.svg",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "insertButton 2.svg",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 1.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 1.svg
new file mode 100644
index 0000000..b0e60fb
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 1.svg
@@ -0,0 +1,4 @@
+
diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 2.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 2.svg
new file mode 100644
index 0000000..b0e60fb
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton 2.svg
@@ -0,0 +1,4 @@
+
diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton.svg
new file mode 100644
index 0000000..b0e60fb
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconDark.imageset/insertButton.svg
@@ -0,0 +1,4 @@
+
diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/Contents.json b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/Contents.json
new file mode 100644
index 0000000..7f79e25
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "insert1.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "insert1 1.svg",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "insert1 2.svg",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 1.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 1.svg
new file mode 100644
index 0000000..1f52da3
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 1.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 2.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 2.svg
new file mode 100644
index 0000000..1f52da3
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1 2.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1.svg b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1.svg
new file mode 100644
index 0000000..1f52da3
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/CodeBlockInsertIconLight.imageset/insert1.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json
index 60c5e84..fe26a6c 100644
--- a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json
+++ b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json
@@ -1,17 +1,17 @@
{
"images" : [
{
- "filename" : "copilot-16.png",
+ "filename" : "active-16.png",
"idiom" : "universal",
"scale" : "1x"
},
{
- "filename" : "copilot-32.png",
+ "filename" : "active-32.png",
"idiom" : "universal",
"scale" : "2x"
},
{
- "filename" : "copilot-48.png",
+ "filename" : "active-48.png",
"idiom" : "universal",
"scale" : "3x"
}
diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-16.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-16.png
new file mode 100644
index 0000000..e53ee85
Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-16.png differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-32.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-32.png
new file mode 100644
index 0000000..dfab434
Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-32.png differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-48.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-48.png
new file mode 100644
index 0000000..43dafb5
Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/active-48.png differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png
deleted file mode 100644
index 6add79d..0000000
Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png
deleted file mode 100644
index f6cf654..0000000
Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png
deleted file mode 100644
index 9c76a88..0000000
Binary files a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json
index 60c5e84..5528911 100644
--- a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json
+++ b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/Contents.json
@@ -1,17 +1,17 @@
{
"images" : [
{
- "filename" : "copilot-16.png",
+ "filename" : "inactive-16.png",
"idiom" : "universal",
"scale" : "1x"
},
{
- "filename" : "copilot-32.png",
+ "filename" : "inactive-32.png",
"idiom" : "universal",
"scale" : "2x"
},
{
- "filename" : "copilot-48.png",
+ "filename" : "inactive-48.png",
"idiom" : "universal",
"scale" : "3x"
}
diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png
deleted file mode 100644
index 0884ac6..0000000
Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-16.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png
deleted file mode 100644
index 74aa2b4..0000000
Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-32.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png
deleted file mode 100644
index f7e21ee..0000000
Binary files a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/copilot-48.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-16.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-16.png
new file mode 100644
index 0000000..e737a2b
Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-16.png differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-32.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-32.png
new file mode 100644
index 0000000..57798c9
Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-32.png differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-48.png b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-48.png
new file mode 100644
index 0000000..c4d086e
Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarInactiveIcon.imageset/inactive-48.png differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json
index 60c5e84..7e671d9 100644
--- a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json
+++ b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/Contents.json
@@ -1,17 +1,17 @@
{
"images" : [
{
- "filename" : "copilot-16.png",
+ "filename" : "error-16.png",
"idiom" : "universal",
"scale" : "1x"
},
{
- "filename" : "copilot-32.png",
+ "filename" : "error-32.png",
"idiom" : "universal",
"scale" : "2x"
},
{
- "filename" : "copilot-48.png",
+ "filename" : "error-48.png",
"idiom" : "universal",
"scale" : "3x"
}
diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-16.png
deleted file mode 100644
index 6497d37..0000000
Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-16.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png
deleted file mode 100644
index c0073a7..0000000
Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-32.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png
deleted file mode 100644
index 0daf8ca..0000000
Binary files a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/copilot-48.png and /dev/null differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-16.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-16.png
new file mode 100644
index 0000000..7166cc2
Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-16.png differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-32.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-32.png
new file mode 100644
index 0000000..2f6ae68
Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-32.png differ
diff --git a/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-48.png b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-48.png
new file mode 100644
index 0000000..08ed245
Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarWarningIcon.imageset/error-48.png differ
diff --git a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json
new file mode 100644
index 0000000..4e408c3
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "images" : [
+ {
+ "filename" : "Xcode_16x16.svg",
+ "idiom" : "universal",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "Xcode_32x32.svg",
+ "idiom" : "universal",
+ "scale" : "2x",
+ "size" : "32x32"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_16x16.svg b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_16x16.svg
new file mode 100644
index 0000000..0e118ea
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_16x16.svg
@@ -0,0 +1,227 @@
+
diff --git a/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_32x32.svg b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_32x32.svg
new file mode 100644
index 0000000..af5de9b
--- /dev/null
+++ b/ExtensionService/Assets.xcassets/XcodeIcon.imageset/Xcode_32x32.svg
@@ -0,0 +1,227 @@
+
diff --git a/ExtensionService/Info.plist b/ExtensionService/Info.plist
index 2ad095c..19f114f 100644
--- a/ExtensionService/Info.plist
+++ b/ExtensionService/Info.plist
@@ -27,5 +27,7 @@
$(COPILOT_DOCS_URL)
COPILOT_FORUM_URL
$(COPILOT_FORUM_URL)
+ STANDARD_TELEMETRY_CHANNEL_KEY
+ $(STANDARD_TELEMETRY_CHANNEL_KEY)
diff --git a/README.md b/README.md
index 8eabfdb..791a922 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,23 @@
#
GitHub Copilot for Xcode
-
-
[GitHub Copilot](https://github.com/features/copilot) is an AI pair programmer
-tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode
-extension that provides inline coding suggestions as you type.
+tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode extension that provides inline coding suggestions as you type and a chat assistant to answer your coding questions.
-## Beta Preview Policy
+## Chat [Preview]
-Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Terms](https://docs.github.com/en/site-policy/github-terms/github-pre-release-license-terms). We want to remind you that:
+GitHub Copilot Chat provides suggestions to your specific coding tasks via chat.
+
-> Beta Previews may not be supported or may change at any time. You may receive confidential information through those programs that must remain confidential while the program is private. We'd love your feedback to make our Beta Previews better.
+## Code Completion
+You can receive auto-complete type suggestions from GitHub Copilot either by starting to write the code you want to use, or by writing a natural language comment describing what you want the code to do.
+
+
+## Preview Policy
+
+Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Terms](https://docs.github.com/en/site-policy/github-terms/github-pre-release-license-terms). We want to remind you that:
+
+> Previews may not be supported or may change at any time. You may receive confidential information through those programs that must remain confidential while the program is private. We'd love your feedback to make our Previews better.
## Requirements
@@ -39,7 +45,7 @@ Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Te
1. Open the `GitHub Copilot for Xcode` application (from the `Applications` folder). Accept the security warning.
-
+
@@ -104,6 +110,25 @@ Use of the GitHub Copilot Xcode Extension is subject to [GitHub's Pre-Release Te
+## How to use Chat [Preview]
+
+ Open Copilot Chat in GitHub Copilot.
+ - Open via the Xcode menu `Xcode -> Editor -> GitHub Copilot -> Open Chat`.
+
+
+
+
+ - Open via GitHub Copilot app menu `Open Chat`.
+
+
+
+
+
+## How to use Code Completion
+
+ Press `tab` to accept the first line of a suggestion, hold `option` to view
+ the full suggestion, and press `option` + `tab` to accept the full suggestion.
+
## License
This project is licensed under the terms of the MIT open source license. Please
@@ -128,4 +153,4 @@ forum](https://github.com/orgs/community/discussions/categories/copilot).
Thank you to @intitni for creating the original project that this is based on.
Attributions can be found under About when running the app or in
-[Credits.rtf](./Copilot%20for%20Xcode/Credits.rtf).
+[Credits.rtf](./Copilot%20for%20Xcode/Credits.rtf).
\ No newline at end of file
diff --git a/Server/package-lock.json b/Server/package-lock.json
index b3eefd7..6602031 100644
--- a/Server/package-lock.json
+++ b/Server/package-lock.json
@@ -8,13 +8,13 @@
"name": "@github/copilot-xcode",
"version": "0.0.1",
"dependencies": {
- "@github/copilot-language-server": "^1.263.0"
+ "@github/copilot-language-server": "^1.265.0"
}
},
"node_modules/@github/copilot-language-server": {
- "version": "1.263.0",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.263.0.tgz",
- "integrity": "sha512-kf8M5kN1gYp+8yjk+yaH6iR9c8pSamWVpD7M6S2ZRkd3E4NqFGJe90Jgk6nub19tv5/qC7h7fHgeWxuhMRAGLQ==",
+ "version": "1.265.0",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.265.0.tgz",
+ "integrity": "sha512-p74KG3jphQ8CPfzd+AvpNGrOV4EAvv/U1AXxI1iODjSp1m4kJRiDjI5nQAZVi6FWgoHb5wtNedCf3ZxKHwal5Q==",
"dependencies": {
"vscode-languageserver-protocol": "^3.17.5"
},
diff --git a/Server/package.json b/Server/package.json
index b82186e..60ed22f 100644
--- a/Server/package.json
+++ b/Server/package.json
@@ -4,6 +4,6 @@
"description": "Package for downloading @github/copilot-language-server",
"private": true,
"dependencies": {
- "@github/copilot-language-server": "^1.263.0"
+ "@github/copilot-language-server": "^1.265.0"
}
}
diff --git a/Tool/Package.swift b/Tool/Package.swift
index 7b97c58..b66e7b0 100644
--- a/Tool/Package.swift
+++ b/Tool/Package.swift
@@ -11,6 +11,7 @@ let package = Package(
.library(name: "Terminal", targets: ["Terminal"]),
.library(name: "Preferences", targets: ["Preferences", "Configs"]),
.library(name: "Logger", targets: ["Logger"]),
+ .library(name: "SystemUtils", targets: ["SystemUtils"]),
.library(name: "ChatAPIService", targets: ["ChatAPIService"]),
.library(name: "ChatTab", targets: ["ChatTab"]),
.library(name: "FileSystem", targets: ["FileSystem"]),
@@ -28,6 +29,14 @@ let package = Package(
name: "ConversationServiceProvider",
targets: ["ConversationServiceProvider"]
),
+ .library(
+ name: "TelemetryServiceProvider",
+ targets: ["TelemetryServiceProvider"]
+ ),
+ .library(
+ name: "TelemetryService",
+ targets: ["TelemetryService"]
+ ),
.library(
name: "GitHubCopilotService",
targets: ["GitHubCopilotService"]
@@ -49,6 +58,9 @@ let package = Package(
.library(name: "DebounceFunction", targets: ["DebounceFunction"]),
.library(name: "AsyncPassthroughSubject", targets: ["AsyncPassthroughSubject"]),
.library(name: "CustomAsyncAlgorithms", targets: ["CustomAsyncAlgorithms"]),
+ .library(name: "AXHelper", targets: ["AXHelper"]),
+ .library(name: "Cache", targets: ["Cache"]),
+ .library(name: "StatusBarItemView", targets: ["StatusBarItemView"])
],
dependencies: [
// TODO: Update LanguageClient some day.
@@ -198,6 +210,20 @@ let package = Package(
"GitHubCopilotService",
]
),
+
+ .target(
+ name: "AXHelper",
+ dependencies: [
+ "XPCShared",
+ "XcodeInspector"
+ ]
+ ),
+
+ .target(name: "StatusBarItemView", dependencies: ["Cache"]),
+
+ .target(
+ name: "Cache"
+ ),
.testTarget(
name: "WorkspaceSuggestionServiceTests",
@@ -209,7 +235,10 @@ let package = Package(
// MARK: - Services
- .target(name: "Status"),
+ .target(
+ name: "Status",
+ dependencies: ["Cache"]
+ ),
.target(name: "SuggestionProvider", dependencies: [
"SuggestionBasic",
@@ -223,6 +252,19 @@ let package = Package(
.target(name: "ConversationServiceProvider", dependencies: [
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
]),
+
+ .target(name: "TelemetryServiceProvider", dependencies: [
+ .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
+ ]),
+
+ .target(
+ name: "TelemetryService",
+ dependencies: [
+ "TelemetryServiceProvider",
+ "GitHubCopilotService",
+ "BuiltinExtension",
+ "SystemUtils",
+ ]),
// MARK: - GitHub Copilot
@@ -237,7 +279,9 @@ let package = Package(
"Terminal",
"BuiltinExtension",
"ConversationServiceProvider",
+ "TelemetryServiceProvider",
"Status",
+ "SystemUtils",
.product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"),
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
]
@@ -255,6 +299,7 @@ let package = Package(
dependencies: [
"Logger",
"Preferences",
+ "GitHubCopilotService",
.product(name: "JSONRPC", package: "JSONRPC"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(
@@ -273,6 +318,10 @@ let package = Package(
package: "swift-composable-architecture"
)]
),
+
+ // MARK: - SystemUtils
+
+ .target(name: "SystemUtils"),
]
)
diff --git a/Tool/Sources/AXHelper/AXHelper.swift b/Tool/Sources/AXHelper/AXHelper.swift
new file mode 100644
index 0000000..c6e7405
--- /dev/null
+++ b/Tool/Sources/AXHelper/AXHelper.swift
@@ -0,0 +1,70 @@
+import XPCShared
+import XcodeInspector
+import AppKit
+
+public struct AXHelper {
+ public init() {}
+
+ /// When Xcode commands are not available, we can fallback to directly
+ /// set the value of the editor with Accessibility API.
+ public func injectUpdatedCodeWithAccessibilityAPI(
+ _ result: UpdatedContent,
+ focusElement: AXUIElement,
+ onSuccess: (() -> Void)? = nil,
+ onError: (() -> Void)? = nil
+ ) throws {
+ let oldPosition = focusElement.selectedTextRange
+ let oldScrollPosition = focusElement.parent?.verticalScrollBar?.doubleValue
+
+ let error = AXUIElementSetAttributeValue(
+ focusElement,
+ kAXValueAttribute as CFString,
+ result.content as CFTypeRef
+ )
+
+ if error != AXError.success {
+ if let onError = onError {
+ onError()
+ }
+ }
+
+ // recover selection range
+ if let selection = result.newSelection {
+ var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content)
+ if let value = AXValueCreate(.cfRange, &range) {
+ AXUIElementSetAttributeValue(
+ focusElement,
+ kAXSelectedTextRangeAttribute as CFString,
+ value
+ )
+ }
+ } else if let oldPosition {
+ var range = CFRange(
+ location: oldPosition.lowerBound,
+ length: 0
+ )
+ if let value = AXValueCreate(.cfRange, &range) {
+ AXUIElementSetAttributeValue(
+ focusElement,
+ kAXSelectedTextRangeAttribute as CFString,
+ value
+ )
+ }
+ }
+
+ // recover scroll position
+ if let oldScrollPosition,
+ let scrollBar = focusElement.parent?.verticalScrollBar
+ {
+ AXUIElementSetAttributeValue(
+ scrollBar,
+ kAXValueAttribute as CFString,
+ oldScrollPosition as CFTypeRef
+ )
+ }
+
+ if let onSuccess = onSuccess {
+ onSuccess()
+ }
+ }
+}
diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift
index 9e30dd4..525b5c5 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift
@@ -2,14 +2,18 @@ import CopilotForXcodeKit
import Foundation
import Preferences
import ConversationServiceProvider
+import TelemetryServiceProvider
-public typealias CopilotForXcodeCapability = CopilotForXcodeExtensionCapability & CopilotForXcodeChatCapability
+public typealias CopilotForXcodeCapability = CopilotForXcodeExtensionCapability & CopilotForXcodeChatCapability & CopilotForXcodeTelemetryCapability
public protocol CopilotForXcodeChatCapability {
- /// Not implemented yet.
var conversationService: ConversationServiceType? { get }
}
+public protocol CopilotForXcodeTelemetryCapability {
+ var telemetryService: TelemetryServiceType? { get }
+}
+
public protocol BuiltinExtension: CopilotForXcodeCapability {
/// An id that let the extension manager determine whether the extension is in use.
var suggestionServiceId: BuiltInSuggestionFeatureProvider { get }
diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift
index b7ab3e3..eb1cf7a 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift
@@ -97,4 +97,17 @@ public final class BuiltinExtensionConversationServiceProvider<
}
try? await conversationService.copyCode(request: request, workspace: workspaceInfo)
}
+
+ public func templates() async throws -> [ChatTemplate]? {
+ guard let conversationService else {
+ Logger.service.error("Builtin chat service not found.")
+ return nil
+ }
+ guard let workspaceInfo = await activeWorkspace() else {
+ Logger.service.error("Could not get active workspace info")
+ return nil
+ }
+
+ return (try? await conversationService.templates(workspace: workspaceInfo))
+ }
}
diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionTelemetryServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionTelemetryServiceProvider.swift
new file mode 100644
index 0000000..c78899e
--- /dev/null
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionTelemetryServiceProvider.swift
@@ -0,0 +1,59 @@
+import TelemetryServiceProvider
+import CopilotForXcodeKit
+import Foundation
+import Logger
+import XcodeInspector
+
+public final class BuiltinExtensionTelemetryServiceProvider<
+ T: BuiltinExtension
+>: TelemetryServiceProvider {
+
+ private let extensionManager: BuiltinExtensionManager
+
+ public init(
+ extension: T.Type,
+ extensionManager: BuiltinExtensionManager = .shared
+ ) {
+ self.extensionManager = extensionManager
+ }
+
+ var telemetryService: TelemetryServiceType? {
+ extensionManager.extensions.first { $0 is T }?.telemetryService
+ }
+
+ private func activeWorkspace() async -> WorkspaceInfo? {
+ guard let workspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL,
+ let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL
+ else { return nil }
+
+ return WorkspaceInfo(workspaceURL: workspaceURL, projectURL: projectURL)
+ }
+
+ struct BuiltinExtensionTelemetryServiceNotFoundError: Error, LocalizedError {
+ var errorDescription: String? {
+ "Builtin telemetry service not found."
+ }
+ }
+
+ struct BuiltinExtensionActiveWorkspaceInfoNotFoundError: Error, LocalizedError {
+ var errorDescription: String? {
+ "Builtin active workspace info not found."
+ }
+ }
+
+ public func sendError(_ request: TelemetryExceptionRequest) async throws {
+ guard let telemetryService else {
+ Logger.service.error("Builtin telemetry service not found.")
+ throw BuiltinExtensionTelemetryServiceNotFoundError()
+ }
+ guard let workspaceInfo = await activeWorkspace() else {
+ Logger.service.error("Builtin active workspace info not found.")
+ throw BuiltinExtensionActiveWorkspaceInfoNotFoundError()
+ }
+
+ try await telemetryService.sendError(
+ request,
+ workspace: workspaceInfo
+ )
+ }
+}
diff --git a/Tool/Sources/Cache/AvatarCache.swift b/Tool/Sources/Cache/AvatarCache.swift
new file mode 100644
index 0000000..a0a91c4
--- /dev/null
+++ b/Tool/Sources/Cache/AvatarCache.swift
@@ -0,0 +1,49 @@
+import Foundation
+import SwiftUI
+import AppKit
+
+public final class AvatarCache {
+ public static let shared = AvatarCache()
+ private let cache = NSCache()
+
+ private init () {}
+
+ public func set(forUser username: String) async -> Void {
+ guard let data = await fetchAvatarData(forUser: username) else { return }
+ cache.setObject(data as NSData, forKey: username as NSString)
+ }
+
+ public func get(forUser username: String) -> Data? {
+ return cache.object(forKey: username as NSString) as Data?
+ }
+
+ public func remove(forUser username: String) {
+ cache.removeObject(forKey: username as NSString)
+ }
+}
+
+extension AvatarCache {
+ // Directly get the avatar from URL like https://avatars.githubusercontent.com/
+ // TODO: when the `agent` feature added, the avatarUrl could be obtained from the response of GitHub LSP
+ func fetchAvatarData(forUser username: String) async -> Data? {
+ let avatarUrl = "https://avatars.githubusercontent.com/\(username)"
+ guard let avatarUrl = URL(string: avatarUrl) else { return nil }
+
+ do {
+ let (data, _) = try await URLSession.shared.data(from: avatarUrl)
+ return data
+ } catch {
+ return nil
+ }
+ }
+
+ public func getAvatarImage(forUser username: String) -> Image? {
+ guard let data = get(forUser: username),
+ let nsImage = NSImage(data: data)
+ else {
+ return nil
+ }
+
+ return Image(nsImage: nsImage)
+ }
+}
diff --git a/Tool/Sources/Cache/AvatarViewModel.swift b/Tool/Sources/Cache/AvatarViewModel.swift
new file mode 100644
index 0000000..53dcafc
--- /dev/null
+++ b/Tool/Sources/Cache/AvatarViewModel.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+
+@MainActor
+public class AvatarViewModel: ObservableObject {
+ @Published private(set) public var avatarImage: Image?
+ public static let shared = AvatarViewModel()
+
+ public init() { }
+
+ public func loadAvatar(forUser userName: String?) {
+ guard let userName = userName, !userName.isEmpty
+ else {
+ avatarImage = nil
+ return
+ }
+
+ // Fetch if not in cache
+ Task {
+ await AvatarCache.shared.set(forUser: userName)
+ self.avatarImage = AvatarCache.shared.getAvatarImage(forUser: userName)
+ }
+ }
+}
diff --git a/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift
index ca9f06c..556e008 100644
--- a/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift
+++ b/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift
@@ -1,6 +1,7 @@
import Foundation
import Logger
import Preferences
+import ConversationServiceProvider
@globalActor
public enum AutoManagedChatMemoryActor: GlobalActor {
@@ -32,7 +33,7 @@ public actor AutoManagedChatMemory: ChatMemory {
public var systemPrompt: String
public var contextSystemPrompt: String
- public var retrievedContent: [ChatMessage.Reference] = []
+ public var retrievedContent: [ConversationReference] = []
var onHistoryChange: () -> Void = {}
@@ -71,7 +72,7 @@ public actor AutoManagedChatMemory: ChatMemory {
contextSystemPrompt = newPrompt
}
- public func mutateRetrievedContent(_ newContent: [ChatMessage.Reference]) {
+ public func mutateRetrievedContent(_ newContent: [ConversationReference]) {
retrievedContent = newContent
}
diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
index 19c6d3b..7ba7b9c 100644
--- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
+++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
@@ -13,6 +13,12 @@ public extension ChatMemory {
await mutateHistory { history in
if let index = history.firstIndex(where: { $0.id == message.id }) {
history[index].content = history[index].content + message.content
+ history[index].references.append(contentsOf: message.references)
+ history[index].followUp = message.followUp
+ history[index].suggestedTitle = message.suggestedTitle
+ if let errorMessage = message.errorMessage {
+ history[index].errorMessage = (history[index].errorMessage ?? "") + errorMessage
+ }
} else {
history.append(message)
}
diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift
index 80fbc39..83b9408 100644
--- a/Tool/Sources/ChatAPIService/Models.swift
+++ b/Tool/Sources/ChatAPIService/Models.swift
@@ -1,6 +1,7 @@
import CodableWrappers
import Foundation
import ConversationServiceProvider
+import GitHubCopilotService
public struct ChatMessage: Equatable, Codable {
public typealias ID = String
@@ -10,53 +11,7 @@ public struct ChatMessage: Equatable, Codable {
case user
case assistant
}
-
- public struct Reference: Codable, Equatable {
- public enum Kind: String, Codable {
- case `class`
- case `struct`
- case `enum`
- case `actor`
- case `protocol`
- case `extension`
- case `case`
- case property
- case `typealias`
- case function
- case method
- case text
- case webpage
- case other
- }
-
- public var title: String
- public var subTitle: String
- public var uri: String
- public var content: String
- public var startLine: Int?
- public var endLine: Int?
- @FallbackDecoding
- public var kind: Kind
-
- public init(
- title: String,
- subTitle: String,
- content: String,
- uri: String,
- startLine: Int?,
- endLine: Int?,
- kind: Kind
- ) {
- self.title = title
- self.subTitle = subTitle
- self.content = content
- self.uri = uri
- self.startLine = startLine
- self.endLine = endLine
- self.kind = kind
- }
- }
-
+
/// The role of a message.
@FallbackDecoding
public var role: Role
@@ -64,9 +19,6 @@ public struct ChatMessage: Equatable, Codable {
/// The content of the message, either the chat message, or a result of a function call.
public var content: String
- /// The summary of a message that is used for display.
- public var summary: String?
-
/// The id of the message.
public var id: ID
@@ -77,30 +29,38 @@ public struct ChatMessage: Equatable, Codable {
public var rating: ConversationRating = .unrated
/// The references of this message.
- @FallbackDecoding>
- public var references: [Reference]
+ @FallbackDecoding>
+ public var references: [ConversationReference]
+
+ /// The followUp question of this message
+ public var followUp: ConversationFollowUp?
+
+ public var suggestedTitle: String?
+
+ /// The error occurred during responding chat in server
+ public var errorMessage: String?
public init(
id: String = UUID().uuidString,
role: Role,
turnId: String? = nil,
content: String,
- summary: String? = nil,
- references: [Reference] = []
+ references: [ConversationReference] = [],
+ followUp: ConversationFollowUp? = nil,
+ suggestedTitle: String? = nil,
+ errorMessage: String? = nil
) {
self.role = role
self.content = content
- self.summary = summary
self.id = id
self.turnId = turnId
self.references = references
+ self.followUp = followUp
+ self.suggestedTitle = suggestedTitle
+ self.errorMessage = errorMessage
}
}
-public struct ReferenceKindFallback: FallbackValueProvider {
- public static var defaultValue: ChatMessage.Reference.Kind { .other }
-}
-
public struct ChatMessageRoleFallback: FallbackValueProvider {
public static var defaultValue: ChatMessage.Role { .user }
}
diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift
index 9396373..d559852 100644
--- a/Tool/Sources/ChatTab/ChatTab.swift
+++ b/Tool/Sources/ChatTab/ChatTab.swift
@@ -28,6 +28,9 @@ public protocol ChatTabType {
/// Build the tabItem for this chat tab.
@ViewBuilder
func buildTabItem() -> any View
+ /// Build the chatConversationItem
+ @ViewBuilder
+ func buildChatConversationItem() -> any View
/// Build the icon for this chat tab.
@ViewBuilder
func buildIcon() -> any View
@@ -108,6 +111,16 @@ open class BaseChatTab {
}
}
+ @ViewBuilder
+ public var chatConversationItem: some View {
+ let id = "ChatTabTab\(id)"
+ if let tab = self as? (any ChatTabType) {
+ ContentView(buildView: tab.buildChatConversationItem).id(id)
+ } else {
+ EmptyView().id(id)
+ }
+ }
+
/// The icon for this chat tab.
@ViewBuilder
public var icon: some View {
@@ -203,6 +216,10 @@ public class EmptyChatTab: ChatTab {
Text("Empty-\(id)")
}
+ public func buildChatConversationItem() -> any View {
+ Text("Empty-\(id)")
+ }
+
public func buildIcon() -> any View {
Image(systemName: "square")
}
diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
index 22e987f..e0bd0d5 100644
--- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
+++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
@@ -1,4 +1,6 @@
import CopilotForXcodeKit
+import Foundation
+import CodableWrappers
public protocol ConversationServiceType {
func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws
@@ -6,6 +8,7 @@ public protocol ConversationServiceType {
func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws
func rateConversation(turnId: String, rating: ConversationRating, workspace: WorkspaceInfo) async throws
func copyCode(request: CopyCodeRequest, workspace: WorkspaceInfo) async throws
+ func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]?
}
public protocol ConversationServiceProvider {
@@ -14,6 +17,51 @@ public protocol ConversationServiceProvider {
func stopReceivingMessage(_ workDoneToken: String) async throws
func rateConversation(turnId: String, rating: ConversationRating) async throws
func copyCode(_ request: CopyCodeRequest) async throws
+ func templates() async throws -> [ChatTemplate]?
+}
+
+public struct FileReference: Hashable {
+ public let url: URL
+ public let relativePath: String?
+ public let fileName: String?
+ public var isCurrentEditor: Bool = false
+
+ public init(url: URL, relativePath: String?, fileName: String?, isCurrentEditor: Bool = false) {
+ self.url = url
+ self.relativePath = relativePath
+ self.fileName = fileName
+ self.isCurrentEditor = isCurrentEditor
+ }
+
+ public init(url: URL, isCurrentEditor: Bool = false) {
+ self.url = url
+ self.relativePath = nil
+ self.fileName = nil
+ self.isCurrentEditor = isCurrentEditor
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(url)
+ hasher.combine(isCurrentEditor)
+ }
+
+ public static func == (lhs: FileReference, rhs: FileReference) -> Bool {
+ return lhs.url == rhs.url && lhs.isCurrentEditor == rhs.isCurrentEditor
+ }
+}
+
+extension FileReference {
+ public func getPathRelativeToHome() -> String {
+ let filePath = url.path
+ guard !filePath.isEmpty else { return "" }
+
+ let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path
+ if !homeDirectory.isEmpty {
+ return filePath.replacingOccurrences(of: homeDirectory, with: "~")
+ }
+
+ return filePath
+ }
}
public struct ConversationRequest {
@@ -21,17 +69,23 @@ public struct ConversationRequest {
public var content: String
public var workspaceFolder: String
public var skills: [String]
+ public var ignoredSkills: [String]?
+ public var references: [FileReference]?
public init(
workDoneToken: String,
content: String,
workspaceFolder: String,
- skills: [String]
+ skills: [String],
+ ignoredSkills: [String]? = nil,
+ references: [FileReference]? = nil
) {
self.workDoneToken = workDoneToken
self.content = content
self.workspaceFolder = workspaceFolder
self.skills = skills
+ self.ignoredSkills = ignoredSkills
+ self.references = references
}
}
@@ -63,3 +117,114 @@ public enum CopyKind: Int, Codable {
case keyboard = 1
case toolbar = 2
}
+
+public struct ConversationReference: Codable, Equatable {
+ public enum Kind: String, Codable {
+ case `class`
+ case `struct`
+ case `enum`
+ case `actor`
+ case `protocol`
+ case `extension`
+ case `case`
+ case property
+ case `typealias`
+ case function
+ case method
+ case text
+ case webpage
+ case other
+ }
+
+ public enum Status: String, Codable {
+ case included, blocked, notfound, empty
+ }
+
+ public var uri: String
+ public var status: Status?
+ @FallbackDecoding
+ public var kind: Kind
+
+ public var ext: String {
+ return url?.pathExtension ?? ""
+ }
+
+ public var fileName: String {
+ return url?.lastPathComponent ?? ""
+ }
+
+ public var filePath: String {
+ return url?.path ?? ""
+ }
+
+ public var url: URL? {
+ return URL(string: uri)
+ }
+
+ public init(
+ uri: String,
+ status: Status?,
+ kind: Kind
+ ) {
+ self.uri = uri
+ self.status = status
+ self.kind = kind
+
+ }
+}
+
+extension ConversationReference {
+ public func getPathRelativeToHome() -> String {
+ guard !filePath.isEmpty else { return filePath}
+
+ let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path
+ if !homeDirectory.isEmpty{
+ return filePath.replacingOccurrences(of: homeDirectory, with: "~")
+ }
+
+ return filePath
+ }
+}
+
+public struct ReferenceKindFallback: FallbackValueProvider {
+ public static var defaultValue: ConversationReference.Kind { .other }
+}
+
+public struct ConversationFollowUp: Codable, Equatable {
+ public var message: String
+ public var id: String
+ public var type: String
+
+ public init(message: String, id: String, type: String) {
+ self.message = message
+ self.id = id
+ self.type = type
+ }
+}
+
+public struct ChatTemplate: Codable, Equatable {
+ public var id: String
+ public var description: String
+ public var shortDescription: String
+ public var scopes: [ChatPromptTemplateScope]
+
+ public init(id: String, description: String, shortDescription: String, scopes: [ChatPromptTemplateScope]=[]) {
+ self.id = id
+ self.description = description
+ self.shortDescription = shortDescription
+ self.scopes = scopes
+ }
+}
+
+public enum ChatPromptTemplateScope: String, Codable, Equatable {
+ case chatPanel = "chat-panel"
+ case editor = "editor"
+ case inline = "inline"
+}
+
+public struct CopilotLanguageServerError: Codable {
+ public var code: Int?
+ public var message: String
+ public var responseIsIncomplete: Bool?
+ public var responseIsFiltered: Bool?
+}
diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ConversationContextHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ConversationContextHandler.swift
new file mode 100644
index 0000000..bd8ad82
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/Conversation/ConversationContextHandler.swift
@@ -0,0 +1,17 @@
+import JSONRPC
+import Combine
+
+public protocol ConversationContextHandler {
+ var onConversationContext: PassthroughSubject<(ConversationContextRequest, (AnyJSONRPCResponse) -> Void), Never> { get }
+ func handleConversationContext(_ request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void)
+}
+
+public final class ConversationContextHandlerImpl: ConversationContextHandler {
+ public static let shared = ConversationContextHandlerImpl()
+
+ public var onConversationContext = PassthroughSubject<(ConversationContextRequest, (AnyJSONRPCResponse) -> Void), Never>()
+
+ public func handleConversationContext(_ request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) {
+ onConversationContext.send((request, completion))
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift
index f88d3a7..4a7c559 100644
--- a/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift
+++ b/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift
@@ -2,46 +2,49 @@ import Combine
import Foundation
import JSONRPC
import LanguageServerProtocol
+import Logger
public enum ProgressKind: String {
case begin, report, end
}
public protocol ConversationProgressHandler {
- var onBegin: PassthroughSubject<(String, ConversationProgress), Never> { get }
- var onProgress: PassthroughSubject<(String, ConversationProgress), Never> { get }
- var onEnd: PassthroughSubject<(String, ConversationProgress), Never> { get }
+ var onBegin: PassthroughSubject<(String, ConversationProgressBegin), Never> { get }
+ var onProgress: PassthroughSubject<(String, ConversationProgressReport), Never> { get }
+ var onEnd: PassthroughSubject<(String, ConversationProgressEnd), Never> { get }
func handleConversationProgress(_ progressParams: ProgressParams)
}
public final class ConversationProgressHandlerImpl: ConversationProgressHandler {
public static let shared = ConversationProgressHandlerImpl()
- public var onBegin = PassthroughSubject<(String, ConversationProgress), Never>()
- public var onProgress = PassthroughSubject<(String, ConversationProgress), Never>()
- public var onEnd = PassthroughSubject<(String, ConversationProgress), Never>()
+ public var onBegin = PassthroughSubject<(String, ConversationProgressBegin), Never>()
+ public var onProgress = PassthroughSubject<(String, ConversationProgressReport), Never>()
+ public var onEnd = PassthroughSubject<(String, ConversationProgressEnd), Never>()
private var cancellables = Set()
public func handleConversationProgress(_ progressParams: ProgressParams) {
guard let token = getValueAsString(from: progressParams.token),
- let data = try? JSONEncoder().encode(progressParams.value),
- let progress = try? JSONDecoder().decode(ConversationProgress.self, from: data) else {
+ let data = try? JSONEncoder().encode(progressParams.value) else {
print("Error encountered while parsing conversation progress params")
+ Logger.gitHubCopilot.error("Error encountered while parsing conversation progress params")
return
}
- if let kind = ProgressKind(rawValue: progress.kind) {
- switch kind {
- case .begin:
- onBegin.send((token, progress))
- case .report:
- onProgress.send((token, progress))
- case .end:
- onEnd.send((token, progress))
- }
+ let progress = try? JSONDecoder().decode(ConversationProgressContainer.self, from: data)
+ switch progress {
+ case .begin(let begin):
+ onBegin.send((token, begin))
+ case .report(let report):
+ onProgress.send((token, report))
+ case .end(let end):
+ onEnd.send((token, end))
+ default:
+ print("Invalid progress kind")
+ return
}
- }
+}
private func getValueAsString(from token: ProgressToken) -> String? {
switch token {
diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift
index b99cf7d..407bdeb 100644
--- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift
+++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift
@@ -1,6 +1,7 @@
import BuiltinExtension
import CopilotForXcodeKit
import ConversationServiceProvider
+import TelemetryServiceProvider
import Foundation
import LanguageServerProtocol
import Logger
@@ -11,8 +12,8 @@ public final class GitHubCopilotExtension: BuiltinExtension {
public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .gitHubCopilot }
public let suggestionService: GitHubCopilotSuggestionService?
-
public let conversationService: ConversationServiceType?
+ public let telemetryService: TelemetryServiceType?
private var extensionUsage = ExtensionUsage(
isSuggestionServiceInUse: false,
@@ -33,6 +34,8 @@ public final class GitHubCopilotExtension: BuiltinExtension {
self.suggestionService = suggestionService
let conversationService = GitHubCopilotConversationService.init(serviceLocator: serviceLocator)
self.conversationService = conversationService
+ let telemetryService = GitHubCopilotTelemetryService.init(serviceLocator: serviceLocator)
+ self.telemetryService = telemetryService
}
public func workspaceDidOpen(_: WorkspaceInfo) {}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
index f97aec1..f0ee356 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
@@ -11,6 +11,7 @@ import Status
/// We need it because the original one does not allow us to handle custom notifications.
class CopilotLocalProcessServer {
public var notificationPublisher: PassthroughSubject = PassthroughSubject()
+ public var serverRequestPublisher: PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never> = PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never>()
private let transport: StdioDataTransport
private let customTransport: CustomDataTransport
@@ -64,6 +65,7 @@ class CopilotLocalProcessServer {
} catch {
// Handle decoding error
print("Error decoding ConversationCreateParams: \(error)")
+ Logger.gitHubCopilot.error("Error decoding ConversationCreateParams: \(error)")
}
}
}
@@ -76,6 +78,7 @@ class CopilotLocalProcessServer {
} catch {
// Handle decoding error
print("Error decoding TurnCreateParams: \(error)")
+ Logger.gitHubCopilot.error("Error decoding TurnCreateParams: \(error)")
}
}
}
@@ -85,6 +88,10 @@ class CopilotLocalProcessServer {
wrappedServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in
self?.notificationPublisher.send(notification)
}).store(in: &cancellables)
+
+ wrappedServer?.serverRequestPublisher.sink(receiveValue: { [weak self] (request, callback) in
+ self?.serverRequestPublisher.send((request, callback))
+ }).store(in: &cancellables)
process.standardInput = transport.stdinPipe
process.standardOutput = transport.stdoutPipe
@@ -198,6 +205,7 @@ final class CustomJSONRPCLanguageServer: Server {
public var requestHandler: RequestHandler?
public var notificationHandler: NotificationHandler?
public var notificationPublisher: PassthroughSubject = PassthroughSubject()
+ public var serverRequestPublisher: PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never> = PassthroughSubject<(AnyJSONRPCRequest, (AnyJSONRPCResponse) -> Void), Never>()
private var outOfBandError: Error?
@@ -313,6 +321,7 @@ extension CustomJSONRPCLanguageServer {
data: Data,
callback: @escaping (AnyJSONRPCResponse) -> Void
) -> Bool {
+ serverRequestPublisher.send((request: request, callback: callback))
return false
}
}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift
index e918ac6..77d54f2 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift
@@ -3,6 +3,8 @@ import Foundation
import LanguageServerProtocol
import SuggestionBasic
import ConversationServiceProvider
+import JSONRPC
+import Logger
enum ConversationSource: String, Codable {
case panel, inline
@@ -13,13 +15,14 @@ public struct Doc: Codable {
var uri: String
}
-struct Reference: Codable {
- let uri: String
- let position: Position?
- let visibleRange: SuggestionBasic.CursorRange?
- let selection: SuggestionBasic.CursorRange?
- let openedAt: String?
- let activeAt: String?
+public struct Reference: Codable {
+ public var type: String = "file"
+ public let uri: String
+ public let position: Position?
+ public let visibleRange: SuggestionBasic.CursorRange?
+ public let selection: SuggestionBasic.CursorRange?
+ public let openedAt: String?
+ public let activeAt: String?
}
struct ConversationCreateParams: Codable {
@@ -31,6 +34,7 @@ struct ConversationCreateParams: Codable {
var computeSuggestions: Bool?
var source: ConversationSource?
var workspaceFolder: String?
+ var ignoredSkills: [String]?
struct Capabilities: Codable {
var skills: [String]
@@ -38,27 +42,73 @@ struct ConversationCreateParams: Codable {
}
}
-public struct ConversationProgress: Codable {
- public struct FollowUp: Codable {
- public var message: String
- public var id: String
- public var type: String
- }
+// MARK: Conversation Progress
+
+public enum ConversationProgressKind: String, Codable {
+ case begin, report, end
+}
+
+protocol BaseConversationProgress: Codable {
+ var kind: ConversationProgressKind { get }
+ var conversationId: String { get }
+ var turnId: String { get }
+}
- public let kind: String
+public struct ConversationProgressBegin: BaseConversationProgress {
+ public let kind: ConversationProgressKind
+ public let conversationId: String
+ public let turnId: String
+}
+
+public struct ConversationProgressReport: BaseConversationProgress {
+
+ public let kind: ConversationProgressKind
public let conversationId: String
public let turnId: String
public let reply: String?
+ public let references: [Reference]?
+}
+
+public struct ConversationProgressEnd: BaseConversationProgress {
+ public let kind: ConversationProgressKind
+ public let conversationId: String
+ public let turnId: String
+ public let error: CopilotLanguageServerError?
+ public let followUp: ConversationFollowUp?
public let suggestedTitle: String?
+}
- init(kind: String, conversationId: String, turnId: String, reply: String = "", suggestedTitle: String? = nil) {
- self.kind = kind
- self.conversationId = conversationId
- self.turnId = turnId
- self.reply = reply
- self.suggestedTitle = suggestedTitle
+enum ConversationProgressContainer: Decodable {
+ case begin(ConversationProgressBegin)
+ case report(ConversationProgressReport)
+ case end(end: ConversationProgressEnd)
+
+ enum CodingKeys: String, CodingKey {
+ case kind
}
-}
+
+ init(from decoder: Decoder) throws {
+ do {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let kind = try container.decode(ConversationProgressKind.self, forKey: .kind)
+
+ switch kind {
+ case .begin:
+ let begin = try ConversationProgressBegin(from: decoder)
+ self = .begin(begin)
+ case .report:
+ let report = try ConversationProgressReport(from: decoder)
+ self = .report(report)
+ case .end:
+ let end = try ConversationProgressEnd(from: decoder)
+ self = .end(end: end)
+ }
+ } catch {
+ Logger.gitHubCopilot.error("Error decoding ConversationProgressContainer: \(error)")
+ throw error
+ }
+ }
+ }
// MARK: Conversation rating
@@ -82,6 +132,8 @@ struct TurnCreateParams: Codable {
var conversationId: String
var message: String
var doc: Doc?
+ var ignoredSkills: [String]?
+ var references: [Reference]?
}
// MARK: Copy
@@ -96,3 +148,35 @@ struct CopyCodeParams: Codable {
var doc: Doc?
var source: ConversationSource?
}
+
+// MARK: Conversation context
+
+public struct ConversationContextParams: Codable {
+ public var conversationId: String
+ public var turnId: String
+ public var skillId: String
+}
+
+public typealias ConversationContextRequest = JSONRPCRequest
+
+// MARK: Conversation template
+
+public struct Template: Codable {
+ public var id: String
+ public var description: String
+ public var shortDescription: String
+ public var scopes: [PromptTemplateScope]
+
+ public init(id: String, description: String, shortDescription: String, scopes: [PromptTemplateScope]) {
+ self.id = id
+ self.description = description
+ self.shortDescription = shortDescription
+ self.scopes = scopes
+ }
+}
+
+public enum PromptTemplateScope: String, Codable {
+ case chatPanel = "chat-panel"
+ case editor = "editor"
+ case inline = "inline"
+}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Telemetry.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Telemetry.swift
new file mode 100644
index 0000000..8d1b580
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Telemetry.swift
@@ -0,0 +1,32 @@
+import Foundation
+import TelemetryServiceProvider
+
+struct TelemetryExceptionParams: Codable {
+ public let transaction: String?
+ public let stacktrace: String?
+ public let properties: [String: String]?
+ public let platform: String?
+ public let exceptionDetail: [ExceptionDetail]?
+
+ public init(
+ transaction: String? = nil,
+ stacktrace: String? = nil,
+ properties: [String: String]? = nil,
+ platform: String? = nil,
+ exceptionDetail: [ExceptionDetail]? = nil
+ ) {
+ self.transaction = transaction
+ self.stacktrace = stacktrace
+ self.properties = properties
+ self.platform = platform
+ self.exceptionDetail = exceptionDetail
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case transaction
+ case stacktrace
+ case properties
+ case platform
+ case exceptionDetail = "exception_detail"
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
index 9b5bd1f..c13c3e8 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
@@ -88,6 +88,11 @@ public func editorConfiguration() -> JSONValue {
return .hash(d)
}
+public enum SignInInitiateStatus: String, Codable {
+ case promptUserDeviceFlow = "PromptUserDeviceFlow"
+ case alreadySignedIn = "AlreadySignedIn"
+}
+
enum GitHubCopilotRequest {
struct GetVersion: GitHubCopilotRequestType {
struct Response: Codable {
@@ -112,11 +117,12 @@ enum GitHubCopilotRequest {
struct SignInInitiate: GitHubCopilotRequestType {
struct Response: Codable {
- var verificationUri: String
- var status: String
- var userCode: String
- var expiresIn: Int
- var interval: Int
+ var status: SignInInitiateStatus
+ var userCode: String?
+ var verificationUri: String?
+ var expiresIn: Int?
+ var interval: Int?
+ var user: String?
}
var request: ClientRequest {
@@ -331,6 +337,16 @@ enum GitHubCopilotRequest {
return .custom("conversation/rating", dict)
}
}
+
+ // MARK: Conversation templates
+
+ struct GetTemplates: GitHubCopilotRequestType {
+ typealias Response = Array
+
+ var request: ClientRequest {
+ .custom("conversation/templates", .hash([:]))
+ }
+ }
// MARK: Copy code
@@ -345,6 +361,20 @@ enum GitHubCopilotRequest {
return .custom("conversation/copyCode", dict)
}
}
+
+ // MARK: Telemetry
+
+ struct TelemetryException: GitHubCopilotRequestType {
+ struct Response: Codable {}
+
+ var params: TelemetryExceptionParams
+
+ var request: ClientRequest {
+ let data = (try? JSONEncoder().encode(params)) ?? Data()
+ let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:])
+ return .custom("telemetry/exception", dict)
+ }
+ }
}
// MARK: Notifications
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
index e6d64c1..7b3c252 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
@@ -1,4 +1,5 @@
import AppKit
+import TelemetryServiceProvider
import Combine
import ConversationServiceProvider
import Foundation
@@ -9,10 +10,11 @@ import Logger
import Preferences
import Status
import SuggestionBasic
+import SystemUtils
public protocol GitHubCopilotAuthServiceType {
func checkStatus() async throws -> GitHubCopilotAccountStatus
- func signInInitiate() async throws -> (verificationUri: String, userCode: String)
+ func signInInitiate() async throws -> (status: SignInInitiateStatus, verificationUri: String?, userCode: String?, user: String?)
func signInConfirm(userCode: String) async throws
-> (username: String, status: GitHubCopilotAccountStatus)
func signOut() async throws -> GitHubCopilotAccountStatus
@@ -40,19 +42,32 @@ public protocol GitHubCopilotSuggestionServiceType {
func terminate() async
}
+public protocol GitHubCopilotTelemetryServiceType {
+ func sendError(transaction: String?,
+ stacktrace: String?,
+ properties: [String: String]?,
+ platform: String?,
+ exceptionDetail: [ExceptionDetail]?) async throws
+}
+
public protocol GitHubCopilotConversationServiceType {
func createConversation(_ message: String,
workDoneToken: String,
workspaceFolder: String,
doc: Doc?,
- skills: [String]) async throws
+ skills: [String],
+ ignoredSkills: [String]?,
+ references: [FileReference]) async throws
func createTurn(_ message: String,
workDoneToken: String,
conversationId: String,
- doc: Doc?) async throws
+ doc: Doc?,
+ ignoredSkills: [String]?,
+ references: [FileReference]) async throws
func rateConversation(turnId: String, rating: ConversationRating) async throws
func copyCode(turnId: String, codeBlockIndex: Int, copyType: CopyKind, copiedCharacters: Int, totalCharacters: Int, copiedText: String) async throws
func cancelProgress(token: String) async
+ func templates() async throws -> [Template]
}
protocol GitHubCopilotLSP {
@@ -115,21 +130,28 @@ public class GitHubCopilotBaseService {
let projectRootURL: URL
var server: GitHubCopilotLSP
var localProcessServer: CopilotLocalProcessServer?
+ let sessionId: String
init(designatedServer: GitHubCopilotLSP) {
projectRootURL = URL(fileURLWithPath: "/")
server = designatedServer
+ sessionId = UUID().uuidString
}
init(projectRootURL: URL) throws {
self.projectRootURL = projectRootURL
+ self.sessionId = UUID().uuidString
let (server, localServer) = try {
let urls = try GitHubCopilotBaseService.createFoldersIfNeeded()
- var path = SystemInfo().binaryPath()
+ var path = SystemUtils.shared.getXcodeBinaryPath()
var args = ["--stdio"]
let home = ProcessInfo.processInfo.homePath
- let versionNumber = JSONValue(stringLiteral: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "")
- let xcodeVersion = JSONValue(stringLiteral: SystemInfo().xcodeVersion() ?? "")
+ let versionNumber = JSONValue(
+ stringLiteral: SystemUtils.editorPluginVersion ?? ""
+ )
+ let xcodeVersion = JSONValue(
+ stringLiteral: SystemUtils.xcodeVersion ?? ""
+ )
#if DEBUG
// Use local language server if set and available
@@ -169,6 +191,8 @@ public class GitHubCopilotBaseService {
respond(.timeout)
}
let server = InitializingServer(server: localServer)
+ // TODO: set proper timeout against different request.
+ server.defaultTimeout = 60
server.initializeParamsProvider = {
let capabilities = ClientCapabilities(
workspace: nil,
@@ -268,6 +292,10 @@ public class GitHubCopilotBaseService {
return (supportURL, gitHubCopilotFolderURL, executableFolderURL, supportFolderURL)
}
+
+ public func getSessionId() -> String {
+ return sessionId
+ }
}
@globalActor public enum GitHubCopilotSuggestionActor {
@@ -275,25 +303,45 @@ public class GitHubCopilotBaseService {
public static let shared = TheActor()
}
-public final class GitHubCopilotService: GitHubCopilotBaseService,
- GitHubCopilotSuggestionServiceType, GitHubCopilotConversationServiceType, GitHubCopilotAuthServiceType
+public final class GitHubCopilotService:
+ GitHubCopilotBaseService,
+ GitHubCopilotSuggestionServiceType,
+ GitHubCopilotConversationServiceType,
+ GitHubCopilotAuthServiceType,
+ GitHubCopilotTelemetryServiceType
{
-
private var ongoingTasks = Set>()
private var serverNotificationHandler: ServerNotificationHandler = ServerNotificationHandlerImpl.shared
+ private var serverRequestHandler: ServerRequestHandler = ServerRequestHandlerImpl.shared
private var cancellables = Set()
private var statusWatcher: CopilotAuthStatusWatcher?
+ private static var services: [GitHubCopilotService] = [] // cache all alive copilot service instances
override init(designatedServer: any GitHubCopilotLSP) {
super.init(designatedServer: designatedServer)
}
override public init(projectRootURL: URL = URL(fileURLWithPath: "/")) throws {
- try super.init(projectRootURL: projectRootURL)
- localProcessServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in
- self?.serverNotificationHandler.handleNotification(notification)
- }).store(in: &cancellables)
- updateStatusInBackground()
+ do {
+ try super.init(projectRootURL: projectRootURL)
+ localProcessServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in
+ self?.serverNotificationHandler.handleNotification(notification)
+ }).store(in: &cancellables)
+ localProcessServer?.serverRequestPublisher.sink(receiveValue: { [weak self] (request, callback) in
+ self?.serverRequestHandler.handleRequest(request, callback: callback)
+ }).store(in: &cancellables)
+ updateStatusInBackground()
+
+ GitHubCopilotService.services.append(self)
+ } catch {
+ Logger.gitHubCopilot.error(error)
+ throw error
+ }
+
+ }
+
+ deinit {
+ GitHubCopilotService.services.removeAll { $0 === self }
}
@GitHubCopilotSuggestionActor
@@ -400,17 +448,28 @@ public final class GitHubCopilotService: GitHubCopilotBaseService,
workDoneToken: String,
workspaceFolder: String,
doc: Doc?,
- skills: [String]) async throws {
+ skills: [String],
+ ignoredSkills: [String]?,
+ references: [FileReference] ) async throws {
let params = ConversationCreateParams(workDoneToken: workDoneToken,
turns: [ConversationTurn(request: message)],
capabilities: ConversationCreateParams.Capabilities(
skills: skills,
allSkills: false),
doc: doc,
+ references: references.map {
+ Reference(uri: $0.url.absoluteString,
+ position: nil,
+ visibleRange: nil,
+ selection: nil,
+ openedAt: nil,
+ activeAt: nil)
+ },
source: .panel,
- workspaceFolder: workspaceFolder)
+ workspaceFolder: workspaceFolder,
+ ignoredSkills: ignoredSkills)
do {
- let _ = try await sendRequest(
+ _ = try await sendRequest(
GitHubCopilotRequest.CreateConversation(params: params)
)
} catch {
@@ -420,10 +479,23 @@ public final class GitHubCopilotService: GitHubCopilotBaseService,
}
@GitHubCopilotSuggestionActor
- public func createTurn(_ message: String, workDoneToken: String, conversationId: String, doc: Doc?) async throws {
+ public func createTurn(_ message: String, workDoneToken: String, conversationId: String, doc: Doc?, ignoredSkills: [String]?, references: [FileReference]) async throws {
do {
- let params = TurnCreateParams(workDoneToken: workDoneToken, conversationId: conversationId, message: message, doc: doc)
- let _ = try await sendRequest(
+ let params = TurnCreateParams(workDoneToken: workDoneToken,
+ conversationId: conversationId,
+ message: message,
+ doc: doc,
+ ignoredSkills: ignoredSkills,
+ references: references.map {
+ Reference(uri: $0.url.absoluteString,
+ position: nil,
+ visibleRange: nil,
+ selection: nil,
+ openedAt: nil,
+ activeAt: nil)
+ })
+
+ _ = try await sendRequest(
GitHubCopilotRequest.CreateTurn(params: params)
)
} catch {
@@ -432,6 +504,18 @@ public final class GitHubCopilotService: GitHubCopilotBaseService,
}
}
+ @GitHubCopilotSuggestionActor
+ public func templates() async throws -> [Template] {
+ do {
+ let response = try await sendRequest(
+ GitHubCopilotRequest.GetTemplates()
+ )
+ return response
+ } catch {
+ throw error
+ }
+ }
+
@GitHubCopilotSuggestionActor
public func rateConversation(turnId: String, rating: ConversationRating) async throws {
do {
@@ -578,6 +662,14 @@ public final class GitHubCopilotService: GitHubCopilotBaseService,
if status.status == .ok || status.status == .maybeOk {
await Status.shared.updateAuthStatus(.loggedIn, username: status.user)
await unwatchAuthStatus()
+ } else if status.status == .notAuthorized {
+ await Status.shared
+ .updateAuthStatus(
+ .notAuthorized,
+ username: status.user,
+ message: status.status.description
+ )
+ await watchAuthStatus()
} else {
await Status.shared.updateAuthStatus(.notLoggedIn, message: status.status.description)
await watchAuthStatus()
@@ -596,10 +688,27 @@ public final class GitHubCopilotService: GitHubCopilotBaseService,
}
@GitHubCopilotSuggestionActor
- public func signInInitiate() async throws -> (verificationUri: String, userCode: String) {
+ public func signInInitiate() async throws -> (
+ status: SignInInitiateStatus,
+ verificationUri: String?,
+ userCode: String?,
+ user: String?
+ ) {
do {
let result = try await sendRequest(GitHubCopilotRequest.SignInInitiate())
- return (result.verificationUri, result.userCode)
+ switch result.status {
+ case .promptUserDeviceFlow:
+ guard let verificationUri = result.verificationUri,
+ let userCode = result.userCode else {
+ throw GitHubCopilotError.languageServerError(.missingExpectedResult)
+ }
+ return (status: .promptUserDeviceFlow, verificationUri: verificationUri, userCode: userCode, user: nil)
+ case .alreadySignedIn:
+ guard let user = result.user else {
+ throw GitHubCopilotError.languageServerError(.missingExpectedResult)
+ }
+ return (status: .alreadySignedIn, verificationUri: nil, userCode: nil, user: user)
+ }
} catch let error as ServerError {
throw GitHubCopilotError.languageServerError(error)
} catch {
@@ -645,6 +754,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService,
@GitHubCopilotSuggestionActor
public func shutdown() async throws {
+ GitHubCopilotService.services.removeAll { $0 === self }
let stream = AsyncThrowingStream { continuation in
if let localProcessServer {
localProcessServer.shutdown() { err in
@@ -661,6 +771,7 @@ public final class GitHubCopilotService: GitHubCopilotBaseService,
@GitHubCopilotSuggestionActor
public func exit() async throws {
+ GitHubCopilotService.services.removeAll { $0 === self }
let stream = AsyncThrowingStream { continuation in
if let localProcessServer {
localProcessServer.exit() { err in
@@ -675,6 +786,31 @@ public final class GitHubCopilotService: GitHubCopilotBaseService,
}
}
+ @GitHubCopilotSuggestionActor
+ public func sendError(
+ transaction: String?,
+ stacktrace: String?,
+ properties: [String: String]?,
+ platform: String?,
+ exceptionDetail: [ExceptionDetail]?
+ ) async throws {
+ let params = TelemetryExceptionParams(
+ transaction: transaction,
+ stacktrace: stacktrace,
+ properties: properties,
+ platform: platform ?? "macOS",
+ exceptionDetail: exceptionDetail
+ )
+ do {
+ let _ = try await sendRequest(
+ GitHubCopilotRequest.TelemetryException(params: params)
+ )
+ } catch {
+ print("Failed to send telemetry exception. Error: \(error)")
+ throw error
+ }
+ }
+
private func sendRequest(_ endpoint: E) async throws -> E.Response {
do {
return try await server.sendRequest(endpoint)
@@ -685,9 +821,38 @@ public final class GitHubCopilotService: GitHubCopilotBaseService,
updateStatusInBackground()
}
}
+ let methodName: String
+ switch endpoint.request {
+ case .custom(let method, _):
+ methodName = method
+ default:
+ methodName = endpoint.request.method.rawValue
+ }
+ if methodName != "telemetry/exception" { // ignore telemetry request
+ Logger.gitHubCopilot.error(
+ "Failed to send request \(methodName). Error: \(GitHubCopilotError.languageServerError(error).localizedDescription)"
+ )
+ }
throw error
}
}
+
+ public static func signOutAll() async throws {
+ var signoutError: Error? = nil
+ for service in services {
+ do {
+ try await service.signOut()
+ } catch let error as ServerError {
+ signoutError = GitHubCopilotError.languageServerError(error)
+ } catch {
+ signoutError = error
+ }
+ }
+
+ if let signoutError {
+ throw signoutError
+ }
+ }
}
extension InitializingServer: GitHubCopilotLSP {
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift
new file mode 100644
index 0000000..abad912
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift
@@ -0,0 +1,46 @@
+import Foundation
+import Combine
+import JSONRPC
+import LanguageClient
+import LanguageServerProtocol
+import Logger
+
+protocol ServerRequestHandler {
+ func handleRequest(_ request: AnyJSONRPCRequest, callback: @escaping (AnyJSONRPCResponse) -> Void)
+}
+
+class ServerRequestHandlerImpl : ServerRequestHandler {
+ public static let shared = ServerRequestHandlerImpl()
+ private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared
+
+ func handleRequest(_ request: AnyJSONRPCRequest, callback: @escaping (AnyJSONRPCResponse) -> Void) {
+ let methodName = request.method
+ switch methodName {
+ case "conversation/context":
+ do {
+ let params = try JSONEncoder().encode(request.params)
+ let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: params)
+ conversationContextHandler.handleConversationContext(
+ ConversationContextRequest(id: request.id, method: request.method, params: contextParams),
+ completion: callback)
+
+ } catch {
+ callback(
+ AnyJSONRPCResponse(
+ id: request.id,
+ result: JSONValue.array([
+ JSONValue.null,
+ JSONValue.hash([
+ "code": .number(-32602/* Invalid params */),
+ "message": .string("Error: \(error.localizedDescription)")])
+ ])
+ )
+ )
+ Logger.gitHubCopilot.error(error)
+ }
+ break
+ default:
+ break
+ }
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/SystemInfo.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/SystemInfo.swift
deleted file mode 100644
index abf949f..0000000
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/SystemInfo.swift
+++ /dev/null
@@ -1,50 +0,0 @@
-import Darwin
-import Foundation
-
-final class SystemInfo {
- func binaryPath() -> String {
- var systemInfo = utsname()
- uname(&systemInfo)
-
- let machineMirror = Mirror(reflecting: systemInfo.machine)
- let identifier = machineMirror.children.reduce("") { identifier, element in
- guard let value = element.value as? Int8, value != 0 else { return identifier }
- return identifier + String(UnicodeScalar(UInt8(value)))
- }
-
- let path: String
- if identifier == "x86_64" {
- path = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/copilot-language-server").path
- } else if identifier == "arm64" {
- path = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/copilot-language-server-arm64").path
- } else {
- fatalError("Unsupported architecture")
- }
-
- return path
- }
-
- func xcodeVersion() -> String? {
- let process = Process()
- process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
- process.arguments = ["xcodebuild", "-version"]
-
- let pipe = Pipe()
- process.standardOutput = pipe
-
- do {
- try process.run()
- } catch {
- print("Error running xcrun xcodebuild: \(error)")
- return nil
- }
-
- let data = pipe.fileHandleForReading.readDataToEndOfFile()
- guard let output = String(data: data, encoding: .utf8) else {
- return nil
- }
-
- let lines = output.split(separator: "\n")
- return lines.first?.split(separator: " ").last.map(String.init)
- }
-}
diff --git a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift
index f14572a..2f0949c 100644
--- a/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift
+++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift
@@ -19,7 +19,7 @@ public class FeatureFlagNotifierImpl: FeatureFlagNotifier {
public static let shared = FeatureFlagNotifierImpl()
public var featureFlagsDidChange: PassthroughSubject
- init(featureFlags: FeatureFlags = FeatureFlags(rt: false, sn: false, chat: false),
+ init(featureFlags: FeatureFlags = FeatureFlags(rt: false, sn: false, chat: true),
featureFlagsDidChange: PassthroughSubject = PassthroughSubject()) {
self.featureFlags = featureFlags
self.featureFlagsDidChange = featureFlagsDidChange
diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift
index ae43a45..b6250ce 100644
--- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift
+++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift
@@ -18,13 +18,20 @@ public final class GitHubCopilotConversationService: ConversationServiceType {
workDoneToken: request.workDoneToken,
workspaceFolder: request.workspaceFolder,
doc: nil,
- skills: request.skills)
+ skills: request.skills,
+ ignoredSkills: request.ignoredSkills,
+ references: request.references ?? [])
}
public func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws {
guard let service = await serviceLocator.getService(from: workspace) else { return }
- return try await service.createTurn(request.content, workDoneToken: request.workDoneToken, conversationId: conversationId, doc: nil)
+ return try await service.createTurn(request.content,
+ workDoneToken: request.workDoneToken,
+ conversationId: conversationId,
+ doc: nil,
+ ignoredSkills: request.ignoredSkills,
+ references: request.references ?? [])
}
public func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws {
@@ -42,5 +49,25 @@ public final class GitHubCopilotConversationService: ConversationServiceType {
guard let service = await serviceLocator.getService(from: workspace) else { return }
try await service.copyCode(turnId: request.turnId, codeBlockIndex: request.codeBlockIndex, copyType: request.copyType, copiedCharacters: request.copiedCharacters, totalCharacters: request.totalCharacters, copiedText: request.copiedText)
}
+
+ public func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? {
+ guard let service = await serviceLocator.getService(from: workspace) else { return nil }
+ return try await service.templates().map { convertTemplateToChatTemplate($0) }
+ }
+
+ func convertTemplateToChatTemplate(_ template: Template) -> ChatTemplate {
+ ChatTemplate(
+ id: template.id,
+ description: template.description,
+ shortDescription: template.shortDescription,
+ scopes: template.scopes.map { scope in
+ switch scope {
+ case .chatPanel: return .chatPanel
+ case .editor: return .editor
+ case .inline: return .inline
+ }
+ }
+ )
+ }
}
diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotTelemetryService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotTelemetryService.swift
new file mode 100644
index 0000000..a38cbf8
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotTelemetryService.swift
@@ -0,0 +1,30 @@
+import CopilotForXcodeKit
+import Foundation
+import TelemetryServiceProvider
+import BuiltinExtension
+
+public final class GitHubCopilotTelemetryService: TelemetryServiceType {
+
+ private let serviceLocator: ServiceLocator
+
+ init(serviceLocator: ServiceLocator) {
+ self.serviceLocator = serviceLocator
+ }
+
+ public func sendError(_ request: TelemetryExceptionRequest,
+ workspace: WorkspaceInfo) async throws {
+ guard let service = await serviceLocator.getService(from: workspace) else { return }
+ let sessionId = service.getSessionId()
+ var properties = request.properties ?? [:]
+ properties.updateValue(sessionId, forKey: "common_vscodesessionid")
+ properties.updateValue(sessionId, forKey: "client_sessionid")
+
+ try await service.sendError(
+ transaction: request.transaction,
+ stacktrace: request.stacktrace,
+ properties: properties,
+ platform: request.platform,
+ exceptionDetail: request.exceptionDetail
+ )
+ }
+}
diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift
index 518fec1..ae52c32 100644
--- a/Tool/Sources/Logger/Logger.swift
+++ b/Tool/Sources/Logger/Logger.swift
@@ -25,6 +25,7 @@ public final class Logger {
public static let communicationBridge = Logger(category: "CommunicationBridge")
public static let workspacePool = Logger(category: "WorkspacePool")
public static let debug = Logger(category: "Debug")
+ public static var telemetryLogger: TelemetryLoggerProvider? = nil
#if DEBUG
/// Use a temp logger to log something temporary. I won't be available in release builds.
public static let temp = Logger(category: "Temp")
@@ -39,9 +40,11 @@ public final class Logger {
func log(
level: LogLevel,
message: String,
+ error: Error? = nil,
file: StaticString = #file,
line: UInt = #line,
- function: StaticString = #function
+ function: StaticString = #function,
+ callStackSymbols: [String] = []
) {
let osLogType: OSLogType
switch level {
@@ -55,6 +58,28 @@ public final class Logger {
os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg)
fileLogger.log(level: level, category: category, message: message)
+
+ if osLogType == .error {
+ if let error = error {
+ Logger.telemetryLogger?.sendError(
+ error: error,
+ category: category,
+ file: file,
+ line: line,
+ function: function,
+ callStackSymbols: callStackSymbols
+ )
+ } else {
+ Logger.telemetryLogger?.sendError(
+ message: message,
+ category: category,
+ file: file,
+ line: line,
+ function: function,
+ callStackSymbols: callStackSymbols
+ )
+ }
+ }
}
public func debug(
@@ -84,23 +109,34 @@ public final class Logger {
_ message: String,
file: StaticString = #file,
line: UInt = #line,
- function: StaticString = #function
+ function: StaticString = #function,
+ callStackSymbols: [String] = []
) {
- log(level: .error, message: message, file: file, line: line, function: function)
+ log(
+ level: .error,
+ message: message,
+ file: file,
+ line: line,
+ function: function,
+ callStackSymbols: callStackSymbols
+ )
}
public func error(
_ error: Error,
file: StaticString = #file,
line: UInt = #line,
- function: StaticString = #function
+ function: StaticString = #function,
+ callStackSymbols: [String] = Thread.callStackSymbols
) {
log(
level: .error,
message: error.localizedDescription,
+ error: error,
file: file,
line: line,
- function: function
+ function: function,
+ callStackSymbols: callStackSymbols
)
}
diff --git a/Tool/Sources/Logger/TelemetryLoggerProvider.swift b/Tool/Sources/Logger/TelemetryLoggerProvider.swift
new file mode 100644
index 0000000..db58061
--- /dev/null
+++ b/Tool/Sources/Logger/TelemetryLoggerProvider.swift
@@ -0,0 +1,18 @@
+public protocol TelemetryLoggerProvider {
+ func sendError(
+ message: String,
+ category: String,
+ file: StaticString,
+ line: UInt,
+ function: StaticString,
+ callStackSymbols: [String]
+ )
+ func sendError(
+ error: Error,
+ category: String,
+ file: StaticString,
+ line: UInt,
+ function: StaticString,
+ callStackSymbols: [String]
+ )
+}
diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift
index 3e4a4c1..aed2ef4 100644
--- a/Tool/Sources/Preferences/Keys.swift
+++ b/Tool/Sources/Preferences/Keys.swift
@@ -229,6 +229,10 @@ public extension UserDefaultPreferenceKeys {
var isSuggestionTypeInTheMiddleEnabled: PreferenceKey {
.init(defaultValue: true, key: "IsSuggestionTypeInTheMiddleEnabled")
}
+
+ var clsWarningDismissedUntilRelaunch: PreferenceKey {
+ .init(defaultValue: false, key: "CLSWarningDismissedUntilRelaunch")
+ }
}
// MARK: - Chat
@@ -236,7 +240,7 @@ public extension UserDefaultPreferenceKeys {
public extension UserDefaultPreferenceKeys {
var chatFontSize: PreferenceKey {
- .init(defaultValue: 12, key: "ChatFontSize")
+ .init(defaultValue: 13, key: "ChatFontSize")
}
var chatCodeFontSize: PreferenceKey {
diff --git a/Tool/Sources/SharedUIComponents/Base/FileIcon.swift b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift
new file mode 100644
index 0000000..039a492
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/Base/FileIcon.swift
@@ -0,0 +1,18 @@
+import Foundation
+import SwiftUI
+
+
+public func drawFileIcon(_ file: URL?) -> Image {
+ let defaultImage = Image(systemName: "doc.text")
+
+ guard let file = file else { return defaultImage }
+
+ let fileExtension = file.pathExtension.lowercased()
+ if fileExtension == "swift" {
+ if let nsImage = NSImage(named: "SwiftIcon") {
+ return Image(nsImage: nsImage)
+ }
+ }
+
+ return defaultImage
+}
diff --git a/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift
new file mode 100644
index 0000000..ad67aff
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/Base/HoverButtunStyle.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+
+// This is a custom button style that changes its background color when hovered
+public struct HoverButtonStyle: ButtonStyle {
+ @State private var isHovered: Bool
+ private var padding: CGFloat
+
+ public init(isHovered: Bool = false, padding: CGFloat = 4) {
+ self.isHovered = isHovered
+ self.padding = padding
+ }
+
+ public func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .padding(padding)
+ .background(
+ configuration.isPressed
+ ? Color.gray.opacity(0.2)
+ : isHovered
+ ? Color.gray.opacity(0.1)
+ : Color.clear
+ )
+ .cornerRadius(4)
+ .onHover { hover in
+ isHovered = hover
+ }
+ }
+}
diff --git a/Tool/Sources/SharedUIComponents/Base/HoverScrollView.swift b/Tool/Sources/SharedUIComponents/Base/HoverScrollView.swift
new file mode 100644
index 0000000..ec9ec30
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/Base/HoverScrollView.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+
+public struct HoverScrollView: View {
+ let content: Content
+ @State private var isHovered = false
+
+ public init(@ViewBuilder content: () -> Content) {
+ self.content = content()
+ }
+
+ public var body: some View {
+ ScrollView(showsIndicators: isHovered) {
+ content
+ }
+ .onHover { hovering in
+ isHovered = hovering
+ }
+ }
+}
diff --git a/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift
new file mode 100644
index 0000000..afaa607
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/CopilotMessageHeader.swift
@@ -0,0 +1,26 @@
+import SwiftUI
+
+public struct CopilotMessageHeader: View {
+ public init() {}
+
+ public var body: some View {
+ HStack {
+ Image("CopilotLogo")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .frame(width: 12, height: 12)
+ .overlay(
+ Circle()
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ .frame(width: 24, height: 24)
+ )
+ Text("GitHub Copilot")
+ .font(.system(size: 13))
+ .fontWeight(.semibold)
+ .padding(4)
+
+ Spacer()
+ }
+ }
+}
diff --git a/Tool/Sources/SharedUIComponents/CopyButton.swift b/Tool/Sources/SharedUIComponents/CopyButton.swift
index 022e84d..fcc0921 100644
--- a/Tool/Sources/SharedUIComponents/CopyButton.swift
+++ b/Tool/Sources/SharedUIComponents/CopyButton.swift
@@ -26,14 +26,15 @@ public struct CopyButton: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
- .frame(width: 20, height: 20, alignment: .center)
+// .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(.secondary)
- .background(
- .regularMaterial,
- in: RoundedRectangle(cornerRadius: 4, style: .circular)
- )
+// .background(
+// .regularMaterial,
+// in: RoundedRectangle(cornerRadius: 4, style: .circular)
+// )
.padding(4)
}
- .buttonStyle(.borderless)
+ .buttonStyle(HoverButtonStyle(padding: 0))
+ .help("Copy")
}
}
diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift
index d001da8..e1b0044 100644
--- a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift
+++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift
@@ -38,6 +38,7 @@ public struct AutoresizingCustomTextEditor: View {
CustomTextEditor(
text: $text,
font: font,
+ maxHeight: maxHeight,
onSubmit: onSubmit,
completions: completions
)
@@ -54,6 +55,7 @@ public struct CustomTextEditor: NSViewRepresentable {
@Binding public var text: String
public let font: NSFont
+ public let maxHeight: Double
public let isEditable: Bool
public let onSubmit: () -> Void
public var completions: (_ text: String, _ words: [String], _ range: NSRange) -> [String]
@@ -62,6 +64,7 @@ public struct CustomTextEditor: NSViewRepresentable {
text: Binding,
font: NSFont,
isEditable: Bool = true,
+ maxHeight: Double,
onSubmit: @escaping () -> Void,
completions: @escaping (_ text: String, _ words: [String], _ range: NSRange)
-> [String] = { _, _, _ in [] }
@@ -69,12 +72,13 @@ public struct CustomTextEditor: NSViewRepresentable {
_text = text
self.font = font
self.isEditable = isEditable
+ self.maxHeight = maxHeight
self.onSubmit = onSubmit
self.completions = completions
}
public func makeNSView(context: Context) -> NSScrollView {
- context.coordinator.completions = completions
+// context.coordinator.completions = completions
let textView = (context.coordinator.theTextView.documentView as! NSTextView)
textView.delegate = context.coordinator
textView.string = text
@@ -85,11 +89,15 @@ public struct CustomTextEditor: NSViewRepresentable {
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
- return context.coordinator.theTextView
+ // Configure scroll view
+ let scrollView = context.coordinator.theTextView
+ scrollView.hasHorizontalScroller = false
+ context.coordinator.observeHeight(scrollView: scrollView, maxHeight: maxHeight)
+ return scrollView
}
public func updateNSView(_ nsView: NSScrollView, context: Context) {
- context.coordinator.completions = completions
+// context.coordinator.completions = completions
let textView = (context.coordinator.theTextView.documentView as! NSTextView)
textView.isEditable = isEditable
guard textView.string != text else { return }
@@ -104,6 +112,7 @@ public extension CustomTextEditor {
var theTextView = NSTextView.scrollableTextView()
var affectedCharRange: NSRange?
var completions: (String, [String], _ range: NSRange) -> [String] = { _, _, _ in [] }
+ var heightObserver: NSKeyValueObservation?
init(_ view: CustomTextEditor) {
self.view = view
@@ -152,6 +161,19 @@ public extension CustomTextEditor {
index?.pointee = -1
return completions(textView.textStorage?.string ?? "", words, charRange)
}
+
+ func observeHeight(scrollView: NSScrollView, maxHeight: Double) {
+ let textView = scrollView.documentView as! NSTextView
+ heightObserver = textView.observe(\NSTextView.frame) { [weak scrollView] _, _ in
+ guard let scrollView = scrollView else { return }
+ let contentHeight = textView.frame.height
+ scrollView.hasVerticalScroller = contentHeight >= maxHeight
+ }
+ }
+
+ deinit {
+ heightObserver?.invalidate()
+ }
}
}
diff --git a/Tool/Sources/SharedUIComponents/DownvoteButton.swift b/Tool/Sources/SharedUIComponents/DownvoteButton.swift
index 33d6ec9..952aadb 100644
--- a/Tool/Sources/SharedUIComponents/DownvoteButton.swift
+++ b/Tool/Sources/SharedUIComponents/DownvoteButton.swift
@@ -19,14 +19,15 @@ public struct DownvoteButton: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
- .frame(width: 20, height: 20, alignment: .center)
+// .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(.secondary)
- .background(
- .regularMaterial,
- in: RoundedRectangle(cornerRadius: 4, style: .circular)
- )
+// .background(
+// .regularMaterial,
+// in: RoundedRectangle(cornerRadius: 4, style: .circular)
+// )
.padding(4)
+ .help("Unhelpful")
}
- .buttonStyle(.borderless)
+ .buttonStyle(HoverButtonStyle(padding: 0))
}
}
diff --git a/Tool/Sources/SharedUIComponents/InsertButton.swift b/Tool/Sources/SharedUIComponents/InsertButton.swift
new file mode 100644
index 0000000..7f10cd9
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/InsertButton.swift
@@ -0,0 +1,35 @@
+import SwiftUI
+
+public struct InsertButton: View {
+ public var insert: () -> Void
+
+ @Environment(\.colorScheme) var colorScheme
+
+ private var icon: Image {
+ return colorScheme == .dark ? Image("CodeBlockInsertIconDark") : Image("CodeBlockInsertIconLight")
+ }
+
+ public init(insert: @escaping () -> Void) {
+ self.insert = insert
+ }
+
+ public var body: some View {
+ Button(action: {
+ insert()
+ }) {
+ self.icon
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 14, height: 14)
+// .frame(width: 20, height: 20, alignment: .center)
+ .foregroundColor(.secondary)
+// .background(
+// .regularMaterial,
+// in: RoundedRectangle(cornerRadius: 4, style: .circular)
+// )
+ .padding(4)
+ }
+ .buttonStyle(HoverButtonStyle(padding: 0))
+ .help("Insert at Cursor")
+ }
+}
diff --git a/Tool/Sources/SharedUIComponents/InstructionView.swift b/Tool/Sources/SharedUIComponents/InstructionView.swift
new file mode 100644
index 0000000..f3da854
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/InstructionView.swift
@@ -0,0 +1,39 @@
+import ComposableArchitecture
+import SwiftUI
+
+public struct Instruction: View {
+ public init() {}
+
+ public var body: some View {
+ WithPerceptionTracking {
+ VStack {
+ VStack(spacing: 24) {
+
+ VStack(spacing: 16) {
+ Image("CopilotLogo")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .frame(width: 60.0, height: 60.0)
+ .foregroundColor(.secondary)
+
+ Text("Copilot is powered by AI, so mistakes are possible. Review output carefully before use.")
+ .font(.system(size: 14, weight: .light))
+ .multilineTextAlignment(.center)
+ .lineSpacing(4)
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Label("to reference context", systemImage: "paperclip")
+ .foregroundColor(Color("DescriptionForegroundColor"))
+ .font(.system(size: 14))
+ Text("Type / to use commands")
+ .foregroundColor(Color("DescriptionForegroundColor"))
+ .font(.system(size: 14))
+ }
+ }
+ }
+ }
+ }
+}
+
diff --git a/Tool/Sources/SharedUIComponents/UpvoteButton.swift b/Tool/Sources/SharedUIComponents/UpvoteButton.swift
index 40c985c..b4e13e2 100644
--- a/Tool/Sources/SharedUIComponents/UpvoteButton.swift
+++ b/Tool/Sources/SharedUIComponents/UpvoteButton.swift
@@ -19,14 +19,15 @@ public struct UpvoteButton: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
- .frame(width: 20, height: 20, alignment: .center)
+// .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(.secondary)
- .background(
- .regularMaterial,
- in: RoundedRectangle(cornerRadius: 4, style: .circular)
- )
+// .background(
+// .regularMaterial,
+// in: RoundedRectangle(cornerRadius: 4, style: .circular)
+// )
.padding(4)
+ .help("Helpful")
}
- .buttonStyle(.borderless)
+ .buttonStyle(HoverButtonStyle(padding: 0))
}
}
diff --git a/Tool/Sources/Status/Status.swift b/Tool/Sources/Status/Status.swift
index 5cc7e9a..f91fe59 100644
--- a/Tool/Sources/Status/Status.swift
+++ b/Tool/Sources/Status/Status.swift
@@ -15,13 +15,8 @@ public enum ExtensionPermissionStatus {
public struct CLSStatus: Equatable {
public enum Status {
- case unknown
- case normal
- case inProgress
- case error
- case warning
- case inactive
- }
+ case unknown, normal, inProgress, error, warning, inactive
+ }
public let status: Status
public let message: String
@@ -37,16 +32,31 @@ public struct CLSStatus: Equatable {
public struct AuthStatus: Equatable {
public enum Status {
- case unknown
- case loggedIn
- case notLoggedIn
- }
+ case unknown, loggedIn, notLoggedIn, notAuthorized
+ }
public let status: Status
public let username: String?
public let message: String?
}
+private struct AuthStatusInfo {
+ let authIcon: StatusResponse.Icon?
+ let authStatus: AuthStatus.Status
+ let userName: String?
+}
+
+private struct CLSStatusInfo {
+ let icon: StatusResponse.Icon?
+ let message: String
+}
+
+private struct ExtensionStatusInfo {
+ let icon: StatusResponse.Icon?
+ let message: String?
+ let url: String?
+}
+
public extension Notification.Name {
static let authStatusDidChange = Notification.Name("com.github.CopilotForXcode.authStatusDidChange")
static let serviceStatusDidChange = Notification.Name("com.github.CopilotForXcode.serviceStatusDidChange")
@@ -55,21 +65,28 @@ public extension Notification.Name {
public struct StatusResponse {
public struct Icon {
public let name: String
+ // isTemplate = true, monochrome icon; isTemplate = false, colored icon
+ public let isTemplate: Bool
- public init(name: String) {
+ public init(name: String, isTemplate: Bool = true) {
self.name = name
+ self.isTemplate = isTemplate
}
public var nsImage: NSImage? {
- NSImage(named: name)
+ let image = NSImage(named: name)
+ image?.isTemplate = isTemplate
+ return image
}
}
public let icon: Icon
public let inProgress: Bool
+ public let clsMessage: String
public let message: String?
public let url: String?
- public let authMessage: String
+ public let authStatus: AuthStatus.Status
+ public let userName: String?
}
public final actor Status {
@@ -80,7 +97,7 @@ public final actor Status {
private var clsStatus = CLSStatus(status: .unknown, message: "")
private var authStatus = AuthStatus(status: .unknown, username: nil, message: nil)
- private let okIcon = StatusResponse.Icon(name: "MenuBarIcon")
+ private let okIcon = StatusResponse.Icon(name: "MenuBarIcon", isTemplate: false)
private let errorIcon = StatusResponse.Icon(name: "MenuBarWarningIcon")
private let inactiveIcon = StatusResponse.Icon(name: "MenuBarInactiveIcon")
@@ -113,101 +130,113 @@ public final actor Status {
}
public func getAXStatus() -> ObservedAXStatus {
- // if Xcode is running, return the observed status
if isXcodeRunning() {
return axStatus
} else if AXIsProcessTrusted() {
- // if Xcode is not running but AXIsProcessTrusted() is true, return granted
return .granted
} else {
- // otherwise, return the last observed status, which may be unknown
return axStatus
}
}
private func isXcodeRunning() -> Bool {
- let xcode = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dt.Xcode")
- return !xcode.isEmpty
+ !NSRunningApplication.runningApplications(
+ withBundleIdentifier: "com.apple.dt.Xcode"
+ ).isEmpty
}
public func getAuthStatus() -> AuthStatus.Status {
- return authStatus.status
+ authStatus.status
+ }
+
+ public func getCLSStatus() -> CLSStatus {
+ clsStatus
}
public func getStatus() -> StatusResponse {
- let (authIcon, authMessage) = getAuthStatusInfo()
- let (icon, message, url) = getExtensionStatusInfo()
+ let authStatusInfo: AuthStatusInfo = getAuthStatusInfo()
+ let clsStatusInfo: CLSStatusInfo = getCLSStatusInfo()
+ let extensionStatusInfo: ExtensionStatusInfo = getExtensionStatusInfo()
return .init(
- icon: authIcon ?? icon ?? okIcon,
+ icon: authStatusInfo.authIcon ?? clsStatusInfo.icon ?? extensionStatusInfo.icon ?? okIcon,
inProgress: clsStatus.status == .inProgress,
- message: message,
- url: url,
- authMessage: authMessage
+ clsMessage: clsStatus.message,
+ message: extensionStatusInfo.message,
+ url: extensionStatusInfo.url,
+ authStatus: authStatusInfo.authStatus,
+ userName: authStatusInfo.userName
)
}
- private func getAuthStatusInfo() -> (authIcon: StatusResponse.Icon?, authMessage: String) {
+ private func getAuthStatusInfo() -> AuthStatusInfo {
switch authStatus.status {
- case .unknown,
- .loggedIn:
- (authIcon: nil, authMessage: "Logged in as \(authStatus.username ?? "")")
+ case .unknown, .loggedIn:
+ return AuthStatusInfo(
+ authIcon: nil,
+ authStatus: authStatus.status,
+ userName: authStatus.username
+ )
case .notLoggedIn:
- (authIcon: errorIcon, authMessage: authStatus.message ?? "Not logged in")
+ return AuthStatusInfo(
+ authIcon: errorIcon,
+ authStatus: authStatus.status,
+ userName: nil
+ )
+ case .notAuthorized:
+ return AuthStatusInfo(
+ authIcon: inactiveIcon,
+ authStatus: authStatus.status,
+ userName: authStatus.username
+ )
}
}
-
- private func getExtensionStatusInfo() -> (icon: StatusResponse.Icon?, message: String?, url: String?) {
+
+ private func getCLSStatusInfo() -> CLSStatusInfo {
if clsStatus.isInactiveStatus {
- return (icon: inactiveIcon, message: clsStatus.message, url: nil)
- } else if clsStatus.isErrorStatus {
- return (icon: errorIcon, message: clsStatus.message, url: nil)
+ return CLSStatusInfo(icon: inactiveIcon, message: clsStatus.message)
+ }
+ if clsStatus.isErrorStatus {
+ return CLSStatusInfo(icon: errorIcon, message: clsStatus.message)
}
+ return CLSStatusInfo(icon: nil, message: "")
+ }
+ private func getExtensionStatusInfo() -> ExtensionStatusInfo {
if extensionStatus == .failed {
- // TODO differentiate between the permission not being granted and the
- // extension just getting disabled by Xcode.
- return (
+ return ExtensionStatusInfo(
icon: errorIcon,
message: """
- Extension is not enabled. Enable GitHub Copilot under Xcode
- and then restart Xcode.
- """,
+ Enable Copilot in Xcode & restart
+ """,
url: "x-apple.systempreferences:com.apple.ExtensionsPreferences"
)
}
switch getAXStatus() {
case .granted:
- return (icon: nil, message: nil, url: nil)
+ return ExtensionStatusInfo(icon: nil, message: nil, url: nil)
case .notGranted:
- return (
+ return ExtensionStatusInfo(
icon: errorIcon,
message: """
- Accessibility permission not granted. \
- Click to open System Preferences.
- """,
+ Enable accessibility in system preferences
+ """,
url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
)
case .unknown:
- return (
+ return ExtensionStatusInfo(
icon: errorIcon,
message: """
- Accessibility permission not granted or Copilot restart needed.
- """,
+ Enable accessibility or restart Copilot
+ """,
url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
)
}
}
private func broadcast() {
- NotificationCenter.default.post(
- name: .serviceStatusDidChange,
- object: nil
- )
+ NotificationCenter.default.post(name: .serviceStatusDidChange, object: nil)
// Can remove DistributedNotificationCenter if the settings UI moves in-process
- DistributedNotificationCenter.default().post(
- name: .serviceStatusDidChange,
- object: nil
- )
+ DistributedNotificationCenter.default().post(name: .serviceStatusDidChange, object: nil)
}
}
diff --git a/Tool/Sources/Status/StatusObserver.swift b/Tool/Sources/Status/StatusObserver.swift
new file mode 100644
index 0000000..074b305
--- /dev/null
+++ b/Tool/Sources/Status/StatusObserver.swift
@@ -0,0 +1,82 @@
+import SwiftUI
+import Cache
+
+@MainActor
+public class StatusObserver: ObservableObject {
+ @Published public private(set) var authStatus = AuthStatus(status: .unknown, username: nil, message: nil)
+ @Published public private(set) var clsStatus = CLSStatus(status: .unknown, message: "")
+
+ public static let shared = StatusObserver()
+
+ private init() {
+ Task { @MainActor in
+ await observeAuthStatus()
+ await observeCLSStatus()
+ }
+ }
+
+ private func observeAuthStatus() async {
+ await updateAuthStatus()
+ setupAuthStatusNotificationObserver()
+ }
+
+ private func observeCLSStatus() async {
+ await updateCLSStatus()
+ setupCLSStatusNotificationObserver()
+ }
+
+ private func updateAuthStatus() async {
+ let authStatus = await Status.shared.getAuthStatus()
+ let statusInfo = await Status.shared.getStatus()
+
+ self.authStatus = AuthStatus(
+ status: authStatus,
+ username: statusInfo.userName,
+ message: nil
+ )
+
+ // load avatar when auth status changed
+ AvatarViewModel.shared.loadAvatar(forUser: self.authStatus.username)
+ }
+
+ private func updateCLSStatus() async {
+ self.clsStatus = await Status.shared.getCLSStatus()
+ }
+
+ private func setupAuthStatusNotificationObserver() {
+ NotificationCenter.default.addObserver(
+ forName: .serviceStatusDidChange,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ guard let self = self else { return }
+ Task { @MainActor [self] in
+ await self.updateAuthStatus()
+ }
+ }
+
+ DistributedNotificationCenter.default().addObserver(
+ forName: .authStatusDidChange,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ guard let self = self else { return }
+ Task { @MainActor [self] in
+ await self.updateAuthStatus()
+ }
+ }
+ }
+
+ private func setupCLSStatusNotificationObserver() {
+ NotificationCenter.default.addObserver(
+ forName: .serviceStatusDidChange,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ guard let self = self else { return }
+ Task { @MainActor [self] in
+ await self.updateCLSStatus()
+ }
+ }
+ }
+}
diff --git a/Tool/Sources/StatusBarItemView/AccountItemView.swift b/Tool/Sources/StatusBarItemView/AccountItemView.swift
new file mode 100644
index 0000000..3d6fda8
--- /dev/null
+++ b/Tool/Sources/StatusBarItemView/AccountItemView.swift
@@ -0,0 +1,193 @@
+import SwiftUI
+import Cache
+
+public class AccountItemView: NSView {
+ private var target: AnyObject?
+ private var action: Selector?
+ private var isHovered = false
+ private var visualEffect: NSVisualEffectView
+ private let menuItemPadding: CGFloat = 3
+
+ private var userName: String
+ private var nameLabel: NSTextField!
+ let avatarSize = 36
+ let horizontalPadding = 14
+ let verticalPadding = 8
+
+ public override func setFrameSize(_ newSize: NSSize) {
+ super.setFrameSize(newSize)
+ updateVisualEffectFrame()
+ }
+
+ public init(
+ target: AnyObject? = nil,
+ action: Selector? = nil,
+ userName: String = ""
+ ) {
+ self.target = target
+ self.action = action
+ self.userName = userName
+
+ // Initialize visualEffect with zero frame - it will be updated in layout
+ self.visualEffect = NSVisualEffectView(frame: .zero)
+ self.visualEffect.material = .selection
+ self.visualEffect.state = .active
+ self.visualEffect.blendingMode = .withinWindow
+ self.visualEffect.isHidden = true
+ self.visualEffect.wantsLayer = true
+ self.visualEffect.layer?.cornerRadius = 4
+ self.visualEffect.layer?.backgroundColor = NSColor.systemBlue.cgColor
+ self.visualEffect.isEmphasized = true
+
+ // Initialize with a reasonable starting size
+ super.init(frame: NSRect(x: 0, y: 0, width: 240, height: 52))
+
+ // Set up autoresizing mask to allow the view to resize with its superview
+ self.autoresizingMask = [.width]
+ self.visualEffect.autoresizingMask = [.width, .height]
+
+ wantsLayer = true
+ addSubview(visualEffect)
+
+ // Create and configure subviews
+ setupSubviews()
+ }
+
+ private func setupSubviews() {
+ // Create avatar view with hover state
+ let avatarView = NSHostingView(rootView: AvatarView(userName: userName, isHovered: isHovered))
+ avatarView.frame = NSRect(
+ x: horizontalPadding,
+ y: 8,
+ width: avatarSize,
+ height: avatarSize
+ )
+ addSubview(avatarView)
+
+ // Store nameLabel as property and configure it
+ nameLabel = NSTextField(
+ labelWithString: userName.isEmpty ? "Sign In to GitHub Account" : userName
+ )
+ nameLabel.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
+ nameLabel.frame = NSRect(
+ x: horizontalPadding + horizontalPadding/2 + avatarSize,
+ y: verticalPadding,
+ width: 180,
+ height: 28
+ )
+ nameLabel.cell?.truncatesLastVisibleLine = true
+ nameLabel.cell?.lineBreakMode = .byTruncatingTail
+ nameLabel.textColor = .labelColor
+ addSubview(nameLabel)
+
+ // Make sure nameLabel resizes with the view
+ nameLabel.autoresizingMask = [.width]
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ public override func mouseUp(with event: NSEvent) {
+ if let target = target, let action = action {
+ NSApp.sendAction(action, to: target, from: self)
+ }
+ }
+
+ public override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ trackingAreas.forEach { removeTrackingArea($0) }
+ let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways]
+ let trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
+ addTrackingArea(trackingArea)
+ }
+
+ public override func mouseEntered(with event: NSEvent) {
+ super.mouseEntered(with: event)
+ isHovered = true
+ visualEffect.isHidden = false
+ nameLabel.textColor = .white
+ if let avatarView = subviews.first(where: { $0 is NSHostingView }) as? NSHostingView {
+ avatarView.rootView = AvatarView(userName: userName, isHovered: true)
+ }
+ }
+
+ public override func mouseExited(with event: NSEvent) {
+ super.mouseExited(with: event)
+ isHovered = false
+ visualEffect.isHidden = true
+ nameLabel.textColor = .labelColor
+ if let avatarView = subviews.first(where: { $0 is NSHostingView }) as? NSHostingView {
+ avatarView.rootView = AvatarView(userName: userName, isHovered: false)
+ }
+ }
+
+ public override func resetCursorRects() {
+ addCursorRect(bounds, cursor: .pointingHand)
+ }
+
+ public override func layout() {
+ super.layout()
+ updateVisualEffectFrame()
+ }
+
+ private func updateVisualEffectFrame() {
+ let paddedFrame = bounds.insetBy(
+ dx: menuItemPadding*2,
+ dy: menuItemPadding
+ )
+ visualEffect.frame = paddedFrame
+ }
+}
+
+struct AvatarView: View {
+ let userName: String
+ let isHovered: Bool
+ @ObservedObject private var viewModel = AvatarViewModel.shared
+
+ init(userName: String, isHovered: Bool = false) {
+ self.userName = userName
+ self.isHovered = isHovered
+ }
+
+ var body: some View {
+ Group {
+ if let avatarImage = viewModel.avatarImage {
+ avatarImage
+ .resizable()
+ .scaledToFit()
+ .clipShape(Circle())
+ } else if userName.isEmpty {
+ Image(systemName: "person.circle")
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(isHovered ? .white : .primary)
+ } else {
+ ProgressView()
+ .clipShape(Circle())
+ }
+ }
+ }
+}
+
+struct NSViewPreview: NSViewRepresentable {
+ var userName: String = ""
+
+ func makeNSView(context: Context) -> NSView {
+ let NSView = AccountItemView(
+ userName: userName
+ )
+ return NSView
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ // Update as needed...
+ }
+}
+
+#Preview("Not Signed In") {
+ NSViewPreview().frame(width: 245, height: 52)
+}
+#Preview("Signed In, Active") {
+ NSViewPreview(userName: "xcode-test").frame(width: 245, height: 52)
+}
diff --git a/Tool/Sources/StatusBarItemView/ErrorMessageView.swift b/Tool/Sources/StatusBarItemView/ErrorMessageView.swift
new file mode 100644
index 0000000..8229c84
--- /dev/null
+++ b/Tool/Sources/StatusBarItemView/ErrorMessageView.swift
@@ -0,0 +1,49 @@
+import SwiftUI
+
+public class ErrorMessageView: NSView {
+ public init(errorMessage: String) {
+ // Create a custom view for the menu item
+ let maxWidth: CGFloat = 240
+ let padding = NSEdgeInsets(top: 8, left: 12, bottom: 8, right: 12)
+
+ // Initialize with temporary frame, will be adjusted
+ super.init(frame: NSRect(x: 0, y: 0, width: maxWidth, height: 0))
+
+ let textField = NSTextField(frame: .zero)
+ textField.stringValue = errorMessage
+ textField.isEditable = false
+ textField.isBordered = false
+ textField.drawsBackground = false
+ textField.lineBreakMode = .byWordWrapping
+ textField.usesSingleLineMode = false
+ textField.cell?.wraps = true
+ textField.cell?.isScrollable = false
+ textField.textColor = .secondaryLabelColor
+
+ // Calculate the required height
+ let fittingSize = textField.sizeThatFits(
+ NSSize(width: maxWidth - padding.left - padding.right,
+ height: CGFloat.greatestFiniteMagnitude)
+ )
+
+ // Set the final frames
+ self.frame = NSRect(
+ x: 0, y: 0,
+ width: maxWidth,
+ height: fittingSize.height + padding.top + padding.bottom
+ )
+
+ textField.frame = NSRect(
+ x: padding.left,
+ y: padding.bottom,
+ width: maxWidth - padding.left - padding.right,
+ height: fittingSize.height
+ )
+
+ addSubview(textField)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
diff --git a/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift
index f4345fd..0a008da 100644
--- a/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift
+++ b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift
@@ -64,6 +64,19 @@ public struct CursorRange: Codable, Hashable, Sendable, Equatable, CustomStringC
public var description: String {
return "\(start.readableText) - \(end.readableText)"
}
+
+ public var isValid: Bool {
+ let startLine = start.line
+ let startCharacter = start.character
+ let endLine = end.line
+ let endCharacter = end.character
+
+ guard startLine >= 0 && startCharacter >= 0 && endLine >= 0 && endCharacter >= 0 else {return false}
+
+ guard startLine < endLine || (startLine == endLine && startCharacter <= endCharacter) else {return false}
+
+ return true
+ }
}
public extension CursorRange {
diff --git a/Tool/Sources/SuggestionBasic/Modification.swift b/Tool/Sources/SuggestionBasic/Modification.swift
index c4547e8..5e35c96 100644
--- a/Tool/Sources/SuggestionBasic/Modification.swift
+++ b/Tool/Sources/SuggestionBasic/Modification.swift
@@ -3,6 +3,7 @@ import Foundation
public enum Modification: Codable, Equatable {
case deleted(ClosedRange)
case inserted(Int, [String])
+ case deletedSelection(CursorRange)
}
public extension [String] {
@@ -15,6 +16,36 @@ public extension [String] {
removeSubrange(removingRange.clamped(to: 0.. String {
+ // Original getMachineId implementation
+ let matchingDict = IOServiceMatching("IOEthernetInterface") as NSMutableDictionary
+ var iterator: io_iterator_t = 0
+ let result = IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator)
+
+ if result != KERN_SUCCESS {
+ return UUID().uuidString
+ }
+
+ var macAddress: String = ""
+ var service = IOIteratorNext(iterator)
+
+ while service != 0 {
+ var parentService: io_object_t = 0
+ let kernResult = IORegistryEntryGetParentEntry(service, "IOService", &parentService)
+
+ if kernResult == KERN_SUCCESS {
+ let propertyPtr = UnsafeMutablePointer?>.allocate(capacity: 1)
+ _ = IORegistryEntryCreateCFProperties(
+ parentService,
+ propertyPtr,
+ kCFAllocatorDefault,
+ 0
+ )
+
+ if let properties = propertyPtr.pointee?.takeUnretainedValue() as? [String: Any],
+ let data = properties["IOMACAddress"] as? Data {
+ macAddress = data.map { String(format: "%02x", $0) }.joined()
+ IOObjectRelease(parentService)
+ break
+ }
+
+ IOObjectRelease(parentService)
+ }
+
+ IOObjectRelease(service)
+ service = IOIteratorNext(iterator)
+ }
+
+ IOObjectRelease(iterator)
+
+ // Hash the MAC address using SHA256
+ if !macAddress.isEmpty, let macData = macAddress.data(using: .utf8) {
+ let hashedData = SHA256.hash(data: macData)
+ return hashedData.compactMap { String(format: "%02x", $0) }.joined()
+ }
+
+ return "unknown"
+ }
+
+ public func getXcodeBinaryPath() -> String {
+ var systemInfo = utsname()
+ uname(&systemInfo)
+
+ let machineMirror = Mirror(reflecting: systemInfo.machine)
+ let identifier = machineMirror.children.reduce("") { identifier, element in
+ guard let value = element.value as? Int8, value != 0 else { return identifier }
+ return identifier + String(UnicodeScalar(UInt8(value)))
+ }
+
+ let path: String
+ if identifier == "x86_64" {
+ path = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/copilot-language-server").path
+ } else if identifier == "arm64" {
+ path = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/copilot-language-server-arm64").path
+ } else {
+ fatalError("Unsupported architecture")
+ }
+
+ return path
+ }
+
+ private func computeXcodeVersion() -> String? {
+ let process = Process()
+ let pipe = Pipe()
+
+ defer {
+ pipe.fileHandleForReading.closeFile()
+ if process.isRunning {
+ process.terminate()
+ }
+ }
+
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
+ process.arguments = ["xcodebuild", "-version"]
+ process.standardOutput = pipe
+
+ do {
+ try process.run()
+ } catch {
+ print("Error running xcrun xcodebuild: \(error)")
+ return nil
+ }
+
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ guard let output = String(data: data, encoding: .utf8) else {
+ return nil
+ }
+
+ let lines = output.split(separator: "\n")
+ return lines.first?.split(separator: " ").last.map(String.init)
+ }
+
+ public func getEditorVersionString() -> String {
+ return "Xcode/\(computeXcodeVersion() ?? "0.0.0")"
+ }
+
+ public func getEditorPluginVersion() -> String? {
+ return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
+ }
+
+ public func getEditorPluginVersionString() -> String {
+ return "copilot-xcode/\(getEditorPluginVersion() ?? "0.0.0")"
+ }
+
+ public func getBuild() -> String {
+ return isDeveloperMode() ? "dev" : ""
+ }
+
+ public func getBuildType() -> String {
+ return isDeveloperMode() ? "true" : "false"
+ }
+
+ func isDeveloperMode() -> Bool {
+ #if DEBUG
+ return true
+ #else
+ return false
+ #endif
+ }
+}
diff --git a/Tool/Sources/TelemetryService/GithubPanicErrorReporter.swift b/Tool/Sources/TelemetryService/GithubPanicErrorReporter.swift
new file mode 100644
index 0000000..33733f8
--- /dev/null
+++ b/Tool/Sources/TelemetryService/GithubPanicErrorReporter.swift
@@ -0,0 +1,95 @@
+import Foundation
+import TelemetryServiceProvider
+
+public class GitHubPanicErrorReporter {
+ private static let panicEndpoint = URL(string: "https://copilot-telemetry.githubusercontent.com/telemetry")!
+ private static let sessionId = UUID().uuidString
+ private static let standardChannelKey = Bundle.main
+ .object(forInfoDictionaryKey: "STANDARD_TELEMETRY_CHANNEL_KEY") as! String
+
+ // Helper: Format current time in ISO8601 style
+ private static func currentTime() -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSX"
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ return formatter.string(from: Date())
+ }
+
+ // Helper: Create failbot payload JSON string and update properties
+ private static func createFailbotPayload(
+ for request: TelemetryExceptionRequest,
+ properties: inout [String: Any]
+ ) -> String? {
+ let payload: [String: Any] = [
+ "context": [:],
+ "app": "copilot-xcode",
+ "catalog_service": "CopilotXcode",
+ "release": "copilot-xcode@\(properties["common_extversion"] ?? "0.0.0")",
+ "rollup_id": "auto",
+ "platform": "macOS",
+ "exception_detail": request.exceptionDetail?.toDictionary() ?? []
+ ]
+ guard let data = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
+ return nil
+ }
+ return String(data: data, encoding: .utf8)
+ }
+
+ // Helper: Create payload with a channel input, but always using standard telemetry key.
+ private static func createPayload(
+ for request: TelemetryExceptionRequest,
+ properties: inout [String: Any]
+ ) -> [String: Any] {
+ // Build and add failbot payload to properties
+ if let payloadString = createFailbotPayload(for: request, properties: &properties) {
+ properties["failbot_payload"] = payloadString
+ }
+ properties["common_vscodesessionid"] = sessionId
+ properties["client_sessionid"] = sessionId
+
+ let baseData: [String: Any] = [
+ "ver": 2,
+ "severityLevel": "Error",
+ "name": "agent/error.exception",
+ "properties": properties,
+ "exceptions": [],
+ "measurements": [:]
+ ]
+
+ return [
+ "ver": 1,
+ "time": currentTime(),
+ "severityLevel": "Error",
+ "name": "Microsoft.ApplicationInsights.standard.Event",
+ "iKey": standardChannelKey,
+ "data": [
+ "baseData": baseData,
+ "baseType": "ExceptionData"
+ ]
+ ]
+ }
+
+ public static func report(_ request: TelemetryExceptionRequest) async {
+ do {
+ var properties: [String : Any] = request.properties ?? [:]
+ let payload = createPayload(
+ for: request,
+ properties: &properties
+ )
+
+ let jsonData = try JSONSerialization.data(withJSONObject: [payload], options: [])
+ var httpRequest = URLRequest(url: panicEndpoint)
+ httpRequest.httpMethod = "POST"
+ httpRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
+ httpRequest.httpBody = jsonData
+
+ let (_, response) = try await URLSession.shared.data(for: httpRequest)
+ guard let httpResp = response as? HTTPURLResponse, httpResp.statusCode == 200 else {
+ throw URLError(.badServerResponse)
+ }
+ } catch {
+ print("Fails to send to Panic Endpoint: \(error)")
+ }
+ }
+}
diff --git a/Tool/Sources/TelemetryService/TelemetryCleaner.swift b/Tool/Sources/TelemetryService/TelemetryCleaner.swift
new file mode 100644
index 0000000..069ad84
--- /dev/null
+++ b/Tool/Sources/TelemetryService/TelemetryCleaner.swift
@@ -0,0 +1,86 @@
+import Foundation
+
+// reference the redact algorithm from https://github.com/microsoft/vscode/blame/main/src/vs/platform/telemetry/common/telemetryUtils.ts
+public struct TelemetryCleaner {
+ private let cleanupPatterns: [NSRegularExpression]
+
+ public init(cleanupPatterns: [NSRegularExpression]) {
+ self.cleanupPatterns = cleanupPatterns
+ }
+
+ public func redactMap(_ data: [String: Any]?) -> [String: Any]? {
+ guard let data = data else {
+ return nil
+ }
+ return data.mapValues { value in
+ if let stringValue = value as? String {
+ return redact(stringValue) ?? ""
+ }
+
+ return value
+ }
+ }
+
+ public func redact(_ value: String?) -> String? {
+ guard let value = value else {
+ return nil
+ }
+ var cleanedValue = value.replacingOccurrences(of: "%20", with: " ")
+ cleanedValue = anonymizeFilePaths(cleanedValue)
+ cleanedValue = removeUserInfo(cleanedValue)
+ return cleanedValue
+ }
+
+ private func anonymizeFilePaths(_ stack: String) -> String {
+ guard stack.contains("/") || stack.contains("\\") else {
+ return stack
+ }
+
+ var updatedStack = stack
+ for pattern in cleanupPatterns {
+ updatedStack = pattern.stringByReplacingMatches(
+ in: updatedStack,
+ range: NSRange(updatedStack.startIndex..., in: updatedStack),
+ withTemplate: ""
+ )
+ }
+
+ // Replace file paths with redacted marker
+ let filePattern = try! NSRegularExpression(
+ pattern: "(file:\\/\\/)?([a-zA-Z]:(\\\\|\\/)|(\\\\\\\\/|\\\\|\\/))?([\\w-\\._]+(\\\\|\\/))+"
+ )
+ updatedStack = filePattern.stringByReplacingMatches(
+ in: updatedStack,
+ range: NSRange(updatedStack.startIndex..., in: updatedStack),
+ withTemplate: ""
+ )
+
+ return updatedStack
+ }
+
+ private func removeUserInfo(_ value: String) -> String {
+ let patterns: [(label: String, pattern: String)] = [
+ ("Google API Key", "AIza[A-Za-z0-9_\\\\\\-]{35}"),
+ ("Slack Token", "xox[pbar]\\-[A-Za-z0-9]"),
+ ("GitHub Token", "(gh[psuro]_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})"),
+ ("Generic Secret", "(key|token|sig|secret|signature|password|passwd|pwd|android:value)[^a-zA-Z0-9]"),
+ ("CLI Credentials", "((login|psexec|(certutil|psexec)\\.exe).{1,50}(\\s-u(ser(name)?)?\\s+.{3,100})?\\s-(admin|user|vm|root)?p(ass(word)?)?\\s+[\"']?[^$\\-\\/\\s]|(^|[\\s\\r\\n\\])net(\\.exe)?.{1,5}(user\\s+|share\\s+\\/user:| user -? secrets ? set) \\s + [^ $\\s \\/])"),
+ ("Microsoft Entra ID", "eyJ(?:0eXAiOiJKV1Qi|hbGci|[a-zA-Z0-9\\-_]+\\.[a-zA-Z0-9\\-_]+\\.)"),
+ ("Email", "@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+")
+ ]
+
+ var cleanedValue = value
+ for (label, pattern) in patterns {
+ if let regex = try? NSRegularExpression(pattern: pattern) {
+ if regex.firstMatch(
+ in: cleanedValue,
+ range: NSRange(cleanedValue.startIndex..., in: cleanedValue)
+ ) != nil {
+ return ""
+ }
+ }
+ }
+
+ return cleanedValue
+ }
+}
diff --git a/Tool/Sources/TelemetryService/TelemetryService.swift b/Tool/Sources/TelemetryService/TelemetryService.swift
new file mode 100644
index 0000000..79cda70
--- /dev/null
+++ b/Tool/Sources/TelemetryService/TelemetryService.swift
@@ -0,0 +1,337 @@
+import Foundation
+import SystemUtils
+import TelemetryServiceProvider
+import BuiltinExtension
+import GitHubCopilotService
+
+public protocol WrappedTelemetryServiceType {
+ func sendError(
+ _ error: Error?,
+ transaction: String?,
+ additionalProperties: [String: String]?,
+ category: String,
+ file: StaticString,
+ line: UInt,
+ function: StaticString,
+ from symbols: [String]
+ )
+
+ func sendError(
+ _ message: String,
+ transaction: String?,
+ additionalProperties: [String: String]?,
+ category: String,
+ file: StaticString,
+ line: UInt,
+ function: StaticString,
+ from symbols: [String]
+ )
+}
+
+public actor TelemetryService: WrappedTelemetryServiceType {
+ private let telemetryProvider: TelemetryServiceProvider?
+ private var commonProperties: [String: String] = [:]
+ private let telemetryCleaner: TelemetryCleaner = TelemetryCleaner(cleanupPatterns: [])
+
+ public static var shared: TelemetryService = TelemetryService.service()
+
+ init(
+ provider: any TelemetryServiceProvider
+ ) {
+ telemetryProvider = provider
+ self.commonProperties = [
+ "common_extname": "copilot-xcode",
+ "common_extversion": SystemUtils.editorPluginVersionString,
+ "common_os": "darwin",
+ "common_platformversion": SystemUtils.osVersion,
+ "common_uikind": "desktop",
+ "common_vscodemachineid": SystemUtils.machineId,
+ "client_machineid": SystemUtils.machineId,
+ "editor_version": SystemUtils.editorVersionString,
+ "editor_plugin_version": "copilot-xcode/\(SystemUtils.editorPluginVersionString)",
+ "copilot_build": SystemUtils.build,
+ "copilot_buildType": SystemUtils.buildType
+ ]
+ }
+
+ public static func service() -> TelemetryService {
+ let provider = BuiltinExtensionTelemetryServiceProvider(
+ extension: GitHubCopilotExtension.self
+ )
+ return TelemetryService(provider: provider)
+ }
+
+ enum TelemetryServiceError: Error {
+ case providerNotFound
+ }
+
+ private enum ErrorSource {
+ case message(String)
+ case error(Error?)
+ }
+
+ /// Sends an error with the given parameters
+ public nonisolated func sendError(
+ _ error: Error?,
+ transaction: String? = nil,
+ additionalProperties: [String: String]? = nil,
+ category: String = "",
+ file: StaticString,
+ line: UInt,
+ function: StaticString,
+ from symbols: [String]
+ ) {
+ Task.detached(priority: .background) {
+ await self.sendErrorInternal(
+ .error(error),
+ transaction: transaction,
+ additionalProperties: additionalProperties,
+ category: category,
+ file: file,
+ line: line,
+ function: function,
+ from: symbols
+ )
+ }
+ }
+
+ /// Sends an error message with the given parameters
+ public nonisolated func sendError(
+ _ message: String,
+ transaction: String? = nil,
+ additionalProperties: [String: String]? = nil,
+ category: String = "",
+ file: StaticString,
+ line: UInt,
+ function: StaticString,
+ from symbols: [String]
+ ) {
+ Task.detached(priority: .background) {
+ await self.sendErrorInternal(
+ .message(message),
+ transaction: transaction,
+ additionalProperties: additionalProperties,
+ category: category,
+ file: file,
+ line: line,
+ function: function,
+ from: symbols
+ )
+ }
+ }
+
+ /// Internal implementation for sending errors
+ private func sendErrorInternal(
+ _ source: ErrorSource,
+ transaction: String? = nil,
+ additionalProperties: [String: String]? = nil,
+ category: String = "",
+ file: StaticString,
+ line: UInt,
+ function: StaticString,
+ from symbols: [String]
+ ) async {
+ var props = commonProperties
+ additionalProperties?.forEach { props[$0.key] = $0.value }
+ let fileName: String = telemetryCleaner.redact(String(describing: file)) ?? ""
+ let request = createTelemetryExceptionRequest(
+ errorSource: source,
+ transaction: transaction,
+ additionalProperties: props,
+ category: category,
+ file: fileName,
+ line: line,
+ function: function,
+ symbols: symbols
+ )
+
+ do {
+ if let provider = telemetryProvider {
+ try await provider.sendError(request)
+ } else {
+ throw TelemetryServiceError.providerNotFound
+ }
+ } catch {
+ await GitHubPanicErrorReporter.report(request)
+ }
+ }
+
+ /// Creates a telemetry exception request from the given parameters
+ private func createTelemetryExceptionRequest(
+ errorSource: ErrorSource,
+ transaction: String?,
+ additionalProperties: [String: String],
+ category: String,
+ file: String,
+ line: UInt,
+ function: StaticString,
+ symbols: [String]
+ ) -> TelemetryExceptionRequest {
+ let stacktrace: String? = switch errorSource {
+ case .message(let message):
+ message
+ case .error(let error):
+ error?.localizedDescription
+ }
+
+ let exceptionDetails = convertErrorToExceptionDetails(
+ errorSource,
+ category: category,
+ file: file,
+ line: line,
+ function: function,
+ from: symbols
+ )
+
+ return TelemetryExceptionRequest(
+ transaction: transaction,
+ stacktrace: telemetryCleaner.redact(stacktrace),
+ properties: additionalProperties,
+ platform: "macOS",
+ exceptionDetail: exceptionDetails
+ )
+ }
+
+ /// Converts error source to exception details array
+ private func convertErrorToExceptionDetails(
+ _ errorSource: ErrorSource,
+ category: String,
+ file: String,
+ line: UInt,
+ function: StaticString,
+ from symbols: [String]
+ ) -> [ExceptionDetail] {
+ let (errorType, errorValue) = extractErrorInfo(from: errorSource, category: category)
+ let stackFrames = createStackFrames(
+ errorSource: errorSource,
+ file: file,
+ line: line,
+ function: function,
+ symbols: symbols
+ )
+
+ return [
+ ExceptionDetail(
+ type: errorType,
+ value: telemetryCleaner.redact(errorValue),
+ stacktrace: stackFrames
+ )
+ ]
+ }
+
+ /// Extracts error type and value from error source
+ private func extractErrorInfo(from errorSource: ErrorSource, category: String) -> (type: String, value: String) {
+ switch errorSource {
+ case .message(let message):
+ let type = "ErrorMessage \(category)"
+ return (type, message)
+
+ case .error(let error):
+ guard let error = error else {
+ let type = "UnknownError \(category)"
+ return (type, "Unknown error occurred")
+ }
+
+ var typePrefix = String(describing: type(of: error))
+ if typePrefix == "NSError" {
+ let nsError = error as NSError
+ typePrefix += ":\(nsError.domain):\(nsError.code)"
+ }
+
+ let type = typePrefix + " \(category)"
+ return (type, error.localizedDescription)
+ }
+ }
+
+ /// Creates stack trace frames from error information
+ private func createStackFrames(
+ errorSource: ErrorSource,
+ file: String,
+ line: UInt,
+ function: StaticString,
+ symbols: [String]
+ ) -> [StackTraceFrame] {
+ let callSiteFrame = StackTraceFrame(
+ filename: file,
+ lineno: .integer(Int(line)),
+ colno: nil,
+ function: String(describing: function),
+ inApp: true
+ )
+
+ switch errorSource {
+ case .message:
+ return [callSiteFrame]
+
+ case .error:
+ var frames = parseStackFrames(from: symbols)
+ frames.insert(callSiteFrame, at: 0)
+ return frames
+ }
+ }
+
+ /// Parses call stack symbols into stack trace frames
+ private func parseStackFrames(from symbols: [String]) -> [StackTraceFrame] {
+ symbols.map { symbol -> StackTraceFrame? in
+ let pattern = #"^(\d+)\s+(.+?)\s+(0x[0-9a-fA-F]+)\s+(.+?)\s+\+\s+(\d+)$"#
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil }
+ guard let match = regex.firstMatch(in: symbol, range: NSRange(symbol.startIndex..., in: symbol)) else { return nil }
+
+ let components = (1.. String in
+ if let range = Range(match.range(at: i), in: symbol) {
+ return String(symbol[range])
+ }
+ return ""
+ }
+
+ guard components.count == 5,
+ let offset = Int(components[4]) else { return nil }
+
+ let module = components[1]
+ let parsedSymbol = parseDemangledSymbol(swift_demangle(components[3]))
+
+ return StackTraceFrame(
+ filename: parsedSymbol?.module ?? module,
+ lineno: .integer(offset),
+ colno: nil,
+ function: parsedSymbol?.function ?? components[3],
+ inApp: module.contains("GitHub Copilot for Xcode Extension")
+ )
+ }.compactMap { $0 }
+ }
+
+ /// Demangles Swift symbol names using the Swift runtime
+ typealias Swift_Demangle = @convention(c) (_ mangledName: UnsafePointer?,
+ _ mangledNameLength: Int,
+ _ outputBuffer: UnsafeMutablePointer?,
+ _ outputBufferSize: UnsafeMutablePointer?,
+ _ flags: UInt32) -> UnsafeMutablePointer?
+
+ func swift_demangle(_ mangled: String) -> String {
+ let RTLD_DEFAULT = dlopen(nil, RTLD_NOW)
+ if let sym = dlsym(RTLD_DEFAULT, "swift_demangle") {
+ let f = unsafeBitCast(sym, to: Swift_Demangle.self)
+ if let cString = f(mangled, mangled.count, nil, nil, 0) {
+ defer { cString.deallocate() }
+ return String(cString: cString)
+ }
+ }
+ return ""
+ }
+
+ /// Parses demangled symbol into module and function components
+ func parseDemangledSymbol(_ demangled: String) -> (module: String, function: String)? {
+ let regex = try! NSRegularExpression(
+ pattern: #"^\((\d+)\)\s*(.*?)\s*for\s*([^\s]+(?: [^\s]+)*?)\s*((?:async)?)\s*((?:throws)?)\s*(?:->\s*(.*))?$"#,
+ options: [.anchorsMatchLines]
+ )
+ guard let match = regex.firstMatch(
+ in: demangled, options: [],
+ range: NSRange(location: 0, length: demangled.utf16.count)
+ ) else {
+ return nil
+ }
+ let functionName = (demangled as NSString).substring(with: match.range(at: 3))
+ return (module: functionName, function: demangled)
+ }
+}
diff --git a/Tool/Sources/TelemetryServiceProvider/TelemetryServiceProvider.swift b/Tool/Sources/TelemetryServiceProvider/TelemetryServiceProvider.swift
new file mode 100644
index 0000000..b82df33
--- /dev/null
+++ b/Tool/Sources/TelemetryServiceProvider/TelemetryServiceProvider.swift
@@ -0,0 +1,167 @@
+import CopilotForXcodeKit
+import Foundation
+import CodableWrappers
+
+public protocol TelemetryServiceType {
+ func sendError(
+ _ request: TelemetryExceptionRequest,
+ workspace: WorkspaceInfo
+ ) async throws
+}
+
+public protocol TelemetryServiceProvider {
+ func sendError(_ request: TelemetryExceptionRequest) async throws
+}
+
+/// Represents a telemetry exception request, containing error details and additional properties.
+public struct TelemetryExceptionRequest {
+ /// An identifier to group or track the transaction.
+ public let transaction: String?
+ /// The error stacktrace as a string.
+ public let stacktrace: String?
+ /// Additional telemetry properties as key-value pairs.
+ public let properties: [String: String]?
+ /// The target platform information (default to macOS).
+ public let platform: String?
+ /// A list of detailed exceptions, each with its own context.
+ public let exceptionDetail: [ExceptionDetail]?
+
+ public init(
+ transaction: String? = nil,
+ stacktrace: String? = nil,
+ properties: [String: String]? = nil,
+ platform: String? = nil,
+ exceptionDetail: [ExceptionDetail]? = nil
+ ) {
+ self.transaction = transaction
+ self.stacktrace = stacktrace
+ self.properties = properties
+ self.platform = platform
+ self.exceptionDetail = exceptionDetail
+ }
+}
+
+public struct ExceptionDetail: Codable {
+ public let type: String?
+ public let value: String?
+ public let stacktrace: [StackTraceFrame]?
+
+ public init(type: String? = nil, value: String? = nil, stacktrace: [StackTraceFrame]? = nil) {
+ self.type = type
+ self.value = value
+ self.stacktrace = stacktrace
+ }
+
+ func toDictionary() -> [String: Any] {
+ var dict: [String: Any] = [:]
+ if let type = type {
+ dict["type"] = type
+ }
+ if let value = value {
+ dict["value"] = value
+ }
+ if let stacktrace = stacktrace {
+ dict["stacktrace"] = stacktrace.map { $0.toDictionary() }
+ }
+ return dict
+ }
+}
+
+public struct StackTraceFrame: Codable {
+ public let filename: String?
+ public let lineno: PositionNumberType?
+ public let colno: PositionNumberType?
+ public let function: String?
+ public let inApp: Bool?
+
+ public init(
+ filename: String? = nil,
+ lineno: PositionNumberType? = nil,
+ colno: PositionNumberType? = nil,
+ function: String? = nil,
+ inApp: Bool? = nil
+ ) {
+ self.filename = filename
+ self.lineno = lineno
+ self.colno = colno
+ self.function = function
+ self.inApp = inApp
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case filename
+ case lineno
+ case colno
+ case function
+ case inApp = "in_app"
+ }
+
+ func toDictionary() -> [String: Any] {
+ var dict: [String: Any] = [:]
+ if let filename = filename {
+ dict["filename"] = filename
+ }
+ if let lineno = lineno {
+ dict["lineno"] = lineno.toAny()
+ }
+ if let colno = colno {
+ dict["colno"] = colno.toAny()
+ }
+ if let function = function {
+ dict["function"] = function
+ }
+ if let inApp = inApp {
+ dict["in_app"] = inApp
+ }
+ return dict
+ }
+}
+
+public enum PositionNumberType: Codable {
+ case string(String)
+ case integer(Int)
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if let stringValue = try? container.decode(String.self) {
+ self = .string(stringValue)
+ } else if let intValue = try? container.decode(Int.self) {
+ self = .integer(intValue)
+ } else {
+ self = .string("")
+ }
+ }
+
+ public init(fromInt intValue: Int) {
+ self = .integer(intValue)
+ }
+
+ public init(fromString stringValue: String) {
+ self = .string(stringValue)
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .string(let value):
+ try container.encode(value)
+ case .integer(let value):
+ try container.encode(value)
+ }
+ }
+
+ func toAny() -> Any {
+ switch self {
+ case .string(let value):
+ return value
+ case .integer(let value):
+ return value
+ }
+ }
+}
+
+extension Array where Element == ExceptionDetail {
+ public func toDictionary() -> [[String: Any]] {
+ return self.map { $0.toDictionary() }
+ }
+}
diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift
index 2b31c4a..8da014a 100644
--- a/Tool/Sources/Workspace/Filespace.swift
+++ b/Tool/Sources/Workspace/Filespace.swift
@@ -84,6 +84,10 @@ public final class Filespace {
return suggestions[suggestionIndex]
}
+ public private(set) var errorMessage: String = "" {
+ didSet { refreshUpdateTime() }
+ }
+
// MARK: Life Cycle
public var isExpired: Bool {
@@ -168,5 +172,15 @@ public final class Filespace {
public func bumpVersion() {
version += 1
}
+
+ @WorkspaceActor
+ public func setError(_ message: String) {
+ errorMessage = message
+ }
+
+ @WorkspaceActor
+ public func dismissError() {
+ errorMessage = ""
+ }
}
diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
index e1173ad..877e950 100644
--- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
+++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
@@ -3,6 +3,7 @@ import GitHubCopilotService
import SuggestionBasic
import SuggestionProvider
import Workspace
+import Status
import XPCShared
public extension Workspace {
@@ -76,7 +77,13 @@ public extension Workspace {
workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL)
)
- filespace.setSuggestions(completions)
+ let clsStatus = await Status.shared.getCLSStatus()
+ if clsStatus.isErrorStatus {
+ filespace.setError(clsStatus.message)
+ } else {
+ filespace.setError("")
+ filespace.setSuggestions(completions)
+ }
return completions
}
diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift
index 953cee3..6082a32 100644
--- a/Tool/Sources/XcodeInspector/SourceEditor.swift
+++ b/Tool/Sources/XcodeInspector/SourceEditor.swift
@@ -262,7 +262,8 @@ public extension SourceEditor {
var cursorRange = CursorRange(start: .zero, end: .outOfScope)
for (i, line) in lines.enumerated() {
if countS <= range.lowerBound,
- range.lowerBound < countS + line.utf16.count
+ // when equal, means the cursor is located at the lowerBound
+ range.lowerBound <= countS + line.utf16.count
{
cursorRange.start = .init(line: i, character: range.lowerBound - countS)
}
diff --git a/Tool/Tests/GitHubCopilotServiceTests/SystemInfoTests.swift b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift
similarity index 80%
rename from Tool/Tests/GitHubCopilotServiceTests/SystemInfoTests.swift
rename to Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift
index 442b25a..a01a5a3 100644
--- a/Tool/Tests/GitHubCopilotServiceTests/SystemInfoTests.swift
+++ b/Tool/Tests/SystemUtilsTests/SystemUtilsTests.swift
@@ -3,11 +3,11 @@ import LanguageServerProtocol
import XCTest
@testable import Workspace
-@testable import GitHubCopilotService
+@testable import SystemUtils
-final class SystemInfoTests: XCTestCase {
+final class SystemUtilsTests: XCTestCase {
func test_get_xcode_version() async throws {
- guard let version = SystemInfo().xcodeVersion() else {
+ guard let version = SystemUtils.xcodeVersion else {
XCTFail("The Xcode version should not be nil.")
return
}