From cda0e72b7ce5984082da0971e80411c62be36efa Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:04:45 +0530 Subject: [PATCH] feat: add GitHub Copilot inline completions Integrate the GitHub Copilot language server to provide AI inline completions (ghost text) in the editor, building on the inline-suggestion primitive in CodeEditTextView and the InlineCompletionDelegate seam in CodeEditSourceEditor. New Features/Copilot group: - CopilotService: @MainActor ObservableObject singleton that owns the language-server process and lifecycle, publishes auth state (signedOut / awaitingDeviceCode / signedIn / error) and a busy flag, and brokers document sync and inline-completion requests. Resolves the binary from settings or PATH. - CopilotClient: raw JSON-RPC transport over a framed JSONRPCSession that drives Copilot's custom methods (initialize, signIn, checkStatus, textDocument/inlineCompletion, telemetry) and answers server-to-client requests (workspace/configuration, workDoneProgress/create, showDocument) plus didChangeStatus/v2. - CopilotProtocol: Codable param/result types for the custom methods. - CopilotInlineCompletionProvider: implements InlineCompletionDelegate, syncs the document, maps the cursor to an LSP position, and adapts server items to ghost text (prefix-stripping, newline normalization). - CopilotDocumentObjects: per-document bundle that owns the provider so the controller's weak delegate reference survives. Wire-up: - Register CopilotService alongside LSPService in CodeEditApp. - Add a Copilot settings page (enable toggle, language-server path, device-flow sign-in/out with live status) and SettingsData/SettingsPage entries. - Add a status bar item reflecting Copilot state. - Pass the inline completion delegate from CodeFileView when enabled. --- CodeEdit/CodeEditApp.swift | 3 + .../Copilot/CopilotBinaryResolver.swift | 72 ++++ CodeEdit/Features/Copilot/CopilotClient.swift | 249 +++++++++++++ .../Copilot/CopilotDocumentObjects.swift | 23 ++ .../CopilotInlineCompletionProvider.swift | 153 ++++++++ .../Features/Copilot/CopilotService.swift | 346 ++++++++++++++++++ .../Copilot/Models/CopilotProtocol.swift | 261 +++++++++++++ .../CodeFileDocument/CodeFileDocument.swift | 3 + .../Features/Editor/Views/CodeFileView.swift | 12 +- .../Settings/Models/SettingsData.swift | 10 + .../Settings/Models/SettingsPage.swift | 1 + .../CopilotSettings/CopilotSettingsView.swift | 135 +++++++ .../Models/CopilotSettings.swift | 47 +++ CodeEdit/Features/Settings/SettingsView.swift | 9 + .../StatusBarItems/StatusBarCopilotIcon.swift | 75 ++++ .../StatusBar/Views/StatusBarView.swift | 1 + 16 files changed, 1399 insertions(+), 1 deletion(-) create mode 100644 CodeEdit/Features/Copilot/CopilotBinaryResolver.swift create mode 100644 CodeEdit/Features/Copilot/CopilotClient.swift create mode 100644 CodeEdit/Features/Copilot/CopilotDocumentObjects.swift create mode 100644 CodeEdit/Features/Copilot/CopilotInlineCompletionProvider.swift create mode 100644 CodeEdit/Features/Copilot/CopilotService.swift create mode 100644 CodeEdit/Features/Copilot/Models/CopilotProtocol.swift create mode 100644 CodeEdit/Features/Settings/Pages/CopilotSettings/CopilotSettingsView.swift create mode 100644 CodeEdit/Features/Settings/Pages/CopilotSettings/Models/CopilotSettings.swift create mode 100644 CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCopilotIcon.swift diff --git a/CodeEdit/CodeEditApp.swift b/CodeEdit/CodeEditApp.swift index 17de696d90..da72405bba 100644 --- a/CodeEdit/CodeEditApp.swift +++ b/CodeEdit/CodeEditApp.swift @@ -21,6 +21,9 @@ struct CodeEditApp: App { ServiceContainer.register( LSPService() ) + ServiceContainer.register( + CopilotService.shared + ) _ = CodeEditDocumentController.shared NSMenuItem.swizzle() diff --git a/CodeEdit/Features/Copilot/CopilotBinaryResolver.swift b/CodeEdit/Features/Copilot/CopilotBinaryResolver.swift new file mode 100644 index 0000000000..5c7779a292 --- /dev/null +++ b/CodeEdit/Features/Copilot/CopilotBinaryResolver.swift @@ -0,0 +1,72 @@ +// +// CopilotBinaryResolver.swift +// CodeEdit +// +// Created by Anas Khan on 6/30/26. +// + +import Foundation + +/// Locates the `copilot-language-server` binary and builds a launch environment for it. +enum CopilotBinaryResolver { + private static let binaryName = "copilot-language-server" + + /// Resolves the path to the `copilot-language-server` binary. + /// + /// Prefers an explicit `configuredPath`, then searches `PATH` and common installation directories. + static func resolveBinaryPath(configuredPath: String) -> String? { + let fileManager = FileManager.default + let trimmed = configuredPath.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, fileManager.isExecutableFile(atPath: trimmed) { + return trimmed + } + + let home = fileManager.homeDirectoryForCurrentUser.path + var searchDirectories: [String] = [] + if let path = ProcessInfo.processInfo.environment["PATH"] { + searchDirectories.append(contentsOf: path.split(separator: ":").map(String.init)) + } + searchDirectories.append(contentsOf: [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "\(home)/.npm-global/bin", + "\(home)/.local/bin" + ]) + + for directory in searchDirectories { + let candidate = (directory as NSString).appendingPathComponent(binaryName) + if fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + /// Builds an environment for the language-server process, ensuring `PATH` includes Node and the binary directory. + /// + /// The server's `#!/usr/bin/env node` shebang requires `node` to be discoverable on `PATH`, which is not + /// guaranteed inside the sandboxed app, so nvm-managed and common Node directories are appended. + static func augmentedEnvironment(forBinaryAt binaryPath: String) -> [String: String] { + var environment = ProcessInfo.processInfo.environment + let home = FileManager.default.homeDirectoryForCurrentUser.path + let binaryDirectory = (binaryPath as NSString).deletingLastPathComponent + var extraPaths = [ + binaryDirectory, + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin" + ] + + let nvmNode = "\(home)/.nvm/versions/node" + if let versions = try? FileManager.default.contentsOfDirectory(atPath: nvmNode) { + extraPaths.append(contentsOf: versions.map { "\(nvmNode)/\($0)/bin" }) + } + + let currentPath = environment["PATH"] ?? "" + let combined = ([currentPath] + extraPaths).filter { !$0.isEmpty }.joined(separator: ":") + environment["PATH"] = combined + return environment + } +} diff --git a/CodeEdit/Features/Copilot/CopilotClient.swift b/CodeEdit/Features/Copilot/CopilotClient.swift new file mode 100644 index 0000000000..9dbd9753b8 --- /dev/null +++ b/CodeEdit/Features/Copilot/CopilotClient.swift @@ -0,0 +1,249 @@ +// +// CopilotClient.swift +// CodeEdit +// +// Created by Anas Khan on 6/30/26. +// + +import Foundation +import JSONRPC +import LanguageClient +import LanguageServerProtocol +import OSLog + +/// A raw JSON-RPC client for the GitHub Copilot language server. +/// +/// `LanguageServerProtocol`'s typed enums do not model Copilot's custom requests (`signIn`, `checkStatus`, +/// `textDocument/inlineCompletion`, ...), so this client drives the server with hand-rolled `Codable` types over +/// a ``JSONRPCSession``. The session is created on top of `DataChannel.localProcessChannel(parameters:)` +/// wrapped with LSP header framing via `withMessageFraming()`. +/// +/// The client also answers the server-to-client requests that would otherwise stall the server +/// (`workspace/configuration`, `window/workDoneProgress/create`, `window/showDocument`) and forwards the +/// `didChangeStatus/v2` notification to its owner. +final class CopilotClient { + /// A `{ "success": true }` reply for `window/showDocument`. + private struct ShowDocumentResult: Codable, Sendable { + let success: Bool + } + + /// `{ "settings": {} }` payload for `workspace/didChangeConfiguration`. + private struct DidChangeConfigurationParams: Codable, Sendable { + let settings: CopilotEmptyObject + } + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "CopilotClient") + + /// The underlying JSON-RPC session, framed for LSP transport. + let session: JSONRPCSession + /// The language-server process. + let process: Process + + private var eventTask: Task? + + /// Forwards `didChangeStatus/v2` notifications to the owner (the ``CopilotService``). + var onStatusChange: (@Sendable (CopilotDidChangeStatusParams) -> Void)? + + init(session: JSONRPCSession, process: Process) { + self.session = session + self.process = process + } + + deinit { + eventTask?.cancel() + } + + // MARK: - Process Launch + + /// Spawns the Copilot language server as a local subprocess and wires up a framed JSON-RPC session. + /// - Parameters: + /// - binaryPath: Absolute path to the `copilot-language-server` executable. + /// - arguments: Process arguments. Defaults to `["--stdio"]`. + /// - environment: Environment variables for the process. + /// - terminationHandler: Invoked when the process terminates unexpectedly. + /// - Returns: A connected, but not yet initialized, client. + static func launch( + binaryPath: String, + arguments: [String] = ["--stdio"], + environment: [String: String], + terminationHandler: @escaping @Sendable () -> Void + ) throws -> CopilotClient { + let params = Process.ExecutionParameters( + path: binaryPath, + arguments: arguments, + environment: environment + ) + let (channel, process) = try DataChannel.localProcessChannel( + parameters: params, + terminationHandler: terminationHandler + ) + let session = JSONRPCSession(channel: channel.withMessageFraming()) + let client = CopilotClient(session: session, process: process) + client.startListening() + return client + } + + // MARK: - Server To Client Traffic + + /// Begins consuming server-to-client requests and notifications so the server does not stall. + private func startListening() { + eventTask = Task { [weak self] in + guard let session = self?.session else { return } + let sequence = await session.eventSequence + for await event in sequence { + guard let self else { return } + switch event { + case let .request(request, handler, _): + await self.handleServerRequest(request, handler: handler) + case let .notification(notification, data): + self.handleServerNotification(notification, data: data) + case let .error(error): + self.logger.warning("JSON-RPC error: \(error)") + } + } + } + } + + private func handleServerRequest( + _ request: AnyJSONRPCRequest, + handler: JSONRPCEvent.RequestHandler + ) async { + switch request.method { + case CopilotMethod.configuration: + // Reply with one empty configuration object per requested item. + let count = configurationItemCount(request.params) + let reply = Array(repeating: CopilotEmptyObject(), count: max(count, 1)) + await handler(.success(reply)) + case CopilotMethod.workDoneProgressCreate: + await handler(.success(CopilotEmptyObject())) + case CopilotMethod.showDocument: + await handler(.success(ShowDocumentResult(success: true))) + default: + // Reply with an empty object so unmodeled requests never block the server. + await handler(.success(CopilotEmptyObject())) + } + } + + private func configurationItemCount(_ params: JSONValue?) -> Int { + guard let params, case let .hash(hash) = params, + let itemsValue = hash["items"], case let .array(items) = itemsValue else { + return 1 + } + return items.count + } + + private func handleServerNotification(_ notification: AnyJSONRPCNotification, data: Data) { + guard notification.method == CopilotMethod.didChangeStatusV2 else { return } + guard let params: CopilotDidChangeStatusParams = decodeParams(notification.params) else { return } + onStatusChange?(params) + } + + private func decodeParams(_ params: JSONValue?) -> T? { + guard let params else { return nil } + do { + let data = try JSONEncoder().encode(params) + return try JSONDecoder().decode(T.self, from: data) + } catch { + logger.warning("Failed to decode notification params: \(error)") + return nil + } + } + + // MARK: - Lifecycle Requests + + @discardableResult + func initialize(_ params: CopilotInitializeParams) async throws -> CopilotInitializeResult { + try await session.response(to: CopilotMethod.initialize, params: params) + } + + func sendInitialized() async throws { + try await session.sendNotification(CopilotEmptyObject(), method: CopilotMethod.initialized) + try await session.sendNotification( + DidChangeConfigurationParams(settings: CopilotEmptyObject()), + method: CopilotMethod.didChangeConfiguration + ) + } + + // MARK: - Authentication Requests + + func checkStatus(localChecksOnly: Bool) async throws -> CopilotStatusResult { + try await session.response( + to: CopilotMethod.checkStatus, + params: CopilotCheckStatusParams(options: .init(localChecksOnly: localChecksOnly)) + ) + } + + func signIn() async throws -> CopilotSignInResult { + try await session.response(to: CopilotMethod.signIn, params: CopilotEmptyObject()) + } + + func signInWithGithubToken(_ token: String, user: String?) async throws -> CopilotStatusResult { + try await session.response( + to: CopilotMethod.signInWithGithubToken, + params: CopilotSignInWithTokenParams(githubToken: token, user: user) + ) + } + + func signOut() async throws -> CopilotStatusResult { + try await session.response(to: CopilotMethod.signOut, params: CopilotEmptyObject()) + } + + @discardableResult + func executeCommand(_ command: CopilotCommand) async throws -> JSONValue { + try await session.response( + to: CopilotMethod.executeCommand, + params: CopilotExecuteCommandParams(command: command.command, arguments: command.arguments) + ) + } + + // MARK: - Document Synchronization Notifications + + func didOpen(uri: String, languageId: String, version: Int, text: String) async throws { + let params = CopilotDidOpenParams( + textDocument: .init(uri: uri, languageId: languageId, version: version, text: text) + ) + try await session.sendNotification(params, method: CopilotMethod.didOpen) + } + + func didChange(uri: String, version: Int, text: String) async throws { + let params = CopilotDidChangeParams( + textDocument: .init(uri: uri, version: version), + contentChanges: [.init(text: text)] + ) + try await session.sendNotification(params, method: CopilotMethod.didChange) + } + + func didClose(uri: String) async throws { + try await session.sendNotification( + CopilotDidCloseParams(textDocument: .init(uri: uri)), + method: CopilotMethod.didClose + ) + } + + func didFocus(uri: String) async throws { + try await session.sendNotification( + CopilotDidFocusParams(textDocument: .init(uri: uri)), + method: CopilotMethod.didFocus + ) + } + + // MARK: - Inline Completion + + func inlineCompletion(_ params: CopilotInlineCompletionParams) async throws -> CopilotInlineCompletionResult { + try await session.response(to: CopilotMethod.inlineCompletion, params: params) + } + + func didShowCompletion(item: CopilotCompletionItem) async throws { + try await session.sendNotification( + CopilotDidShowCompletionParams(item: item), + method: CopilotMethod.didShowCompletion + ) + } + + func didPartiallyAcceptCompletion(item: CopilotCompletionItem, acceptedLength: Int) async throws { + try await session.sendNotification( + CopilotDidPartiallyAcceptParams(item: item, acceptedLength: acceptedLength), + method: CopilotMethod.didPartiallyAcceptCompletion + ) + } +} diff --git a/CodeEdit/Features/Copilot/CopilotDocumentObjects.swift b/CodeEdit/Features/Copilot/CopilotDocumentObjects.swift new file mode 100644 index 0000000000..b67e6b6b12 --- /dev/null +++ b/CodeEdit/Features/Copilot/CopilotDocumentObjects.swift @@ -0,0 +1,23 @@ +// +// CopilotDocumentObjects.swift +// CodeEdit +// +// Created by Anas Khan on 6/30/26. +// + +import Foundation + +/// A per-document bundle of GitHub Copilot objects, mirroring ``LanguageServerDocumentObjects``. +/// +/// Stored on ``CodeFileDocument`` so it lives for the document's lifetime and keeps a strong reference to the +/// ``CopilotInlineCompletionProvider`` (the editor controller only holds the provider weakly). +struct CopilotDocumentObjects { + /// The inline completion provider passed to the editor as its `inlineCompletionDelegate`. + let provider = CopilotInlineCompletionProvider() + + /// Associates the provider with its document. + @MainActor + func setUp(document: CodeFileDocument) { + provider.setUp(document: document) + } +} diff --git a/CodeEdit/Features/Copilot/CopilotInlineCompletionProvider.swift b/CodeEdit/Features/Copilot/CopilotInlineCompletionProvider.swift new file mode 100644 index 0000000000..12511bfe2d --- /dev/null +++ b/CodeEdit/Features/Copilot/CopilotInlineCompletionProvider.swift @@ -0,0 +1,153 @@ +// +// CopilotInlineCompletionProvider.swift +// CodeEdit +// +// Created by Anas Khan on 6/30/26. +// + +import AppKit +import CodeEditSourceEditor +import CodeEditTextView +import Foundation + +/// Bridges the editor's ``InlineCompletionDelegate`` to the GitHub Copilot language server. +/// +/// One provider is created per open document (held strongly by ``CopilotDocumentObjects`` so the controller's +/// weak `inlineCompletionDelegate` reference survives). On each request it synchronizes the document with the +/// server, maps the cursor to an LSP position, requests inline completions, and adapts the results into the +/// editor's ``InlineCompletionItem`` model (rendered as ghost text anchored at the caret). +@MainActor +final class CopilotInlineCompletionProvider: InlineCompletionDelegate { + private var service: CopilotService { .shared } + + /// The document this provider serves. Held weakly; the document owns this provider. + private weak var document: CodeFileDocument? + + /// Maps an emitted item id to the original server item so accept/show telemetry can reference its command. + private var serverItems: [UUID: CopilotCompletionItem] = [:] + + /// Creates a provider. Marked `nonisolated` so it can be constructed as a stored property of a document. + nonisolated init() {} + + /// Associates this provider with a document. + func setUp(document: CodeFileDocument) { + self.document = document + } + + // MARK: - InlineCompletionDelegate + + func inlineCompletionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition + ) async -> [InlineCompletionItem] { + guard service.isEnabled, + let document, + let uri = document.languageServerURI else { + return [] + } + + await service.initializeIfNeeded() + guard service.isSignedIn else { return [] } + + let text = textView.textView.string + let languageId = document.getLanguage().lspLanguage?.rawValue ?? "plaintext" + await service.syncDocument(uri: uri, languageId: languageId, text: text) + + let cursorOffset = cursorPosition.range.location + guard cursorOffset != NSNotFound, + let lspPosition = textView.textView.lspRangeFrom( + nsRange: NSRange(location: cursorOffset, length: 0) + )?.start else { + return [] + } + + let items = await service.requestInlineCompletion( + uri: uri, + position: CopilotPosition(line: lspPosition.line, character: lspPosition.character), + tabWidth: textView.tabWidth + ) + + serverItems.removeAll() + return items.compactMap { serverItem in + mapItem(serverItem, cursorOffset: cursorOffset, text: text) + } + } + + func inlineCompletionDidShow(item: InlineCompletionItem) { + guard let serverItem = serverItems[item.id] else { return } + service.reportShown(item: serverItem) + } + + func inlineCompletionDidAccept(item: InlineCompletionItem) { + guard let serverItem = serverItems[item.id] else { return } + service.reportAccepted(item: serverItem) + } + + func inlineCompletionDidDismiss(item: InlineCompletionItem) { + serverItems.removeValue(forKey: item.id) + } + + // MARK: - Mapping + + /// Adapts a server completion item to the editor's ghost-text model. + /// + /// The editor renders `insertText` at the caret and replaces `range` on accept. Copilot may return an + /// `insertText` that includes text the user has already typed, so the already-typed prefix is stripped and + /// the accept range is anchored at the caret (extending to the end of the server range when it reaches past + /// the cursor). + private func mapItem( + _ serverItem: CopilotCompletionItem, + cursorOffset: Int, + text: String + ) -> InlineCompletionItem? { + let normalized = normalizeNewlines(serverItem.insertText) + let nsText = text as NSString + + var ghostText = normalized + var replaceLength = 0 + + if let range = serverItem.range { + let startOffset = utf16Offset(line: range.start.line, character: range.start.character, in: nsText) + let endOffset = utf16Offset(line: range.end.line, character: range.end.character, in: nsText) + + if startOffset <= cursorOffset, cursorOffset <= nsText.length { + let prefixLength = cursorOffset - startOffset + let prefix = nsText.substring(with: NSRange(location: startOffset, length: prefixLength)) + let normalizedNS = normalized as NSString + if prefixLength > 0, normalizedNS.length >= prefixLength, normalizedNS.hasPrefix(prefix) { + ghostText = normalizedNS.substring(from: prefixLength) + } + } + if endOffset > cursorOffset, endOffset <= nsText.length { + replaceLength = endOffset - cursorOffset + } + } + + guard !ghostText.isEmpty else { return nil } + + let item = InlineCompletionItem( + insertText: ghostText, + range: NSRange(location: cursorOffset, length: replaceLength) + ) + serverItems[item.id] = serverItem + return item + } + + /// Normalizes CRLF and CR line endings to LF. + private func normalizeNewlines(_ string: String) -> String { + string.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") + } + + /// Converts a zero-based LSP `(line, character)` position to a UTF-16 offset in `text`. + private func utf16Offset(line: Int, character: Int, in text: NSString) -> Int { + guard line >= 0 else { return 0 } + var index = 0 + var currentLine = 0 + while currentLine < line, index < text.length { + let lineRange = text.lineRange(for: NSRange(location: index, length: 0)) + index = NSMaxRange(lineRange) + currentLine += 1 + } + return min(index + max(character, 0), text.length) + } +} diff --git a/CodeEdit/Features/Copilot/CopilotService.swift b/CodeEdit/Features/Copilot/CopilotService.swift new file mode 100644 index 0000000000..80c84c06f5 --- /dev/null +++ b/CodeEdit/Features/Copilot/CopilotService.swift @@ -0,0 +1,346 @@ +// +// CopilotService.swift +// CodeEdit +// +// Created by Anas Khan on 6/30/26. +// + +import Foundation +import JSONRPC +import OSLog +import SwiftUI + +/// The authentication state of the GitHub Copilot language server, surfaced to the UI. +enum CopilotAuthState: Equatable { + /// No user is signed in. + case signedOut + /// A device-flow sign-in is in progress; the user must enter `userCode` at `verificationUri`. + case awaitingDeviceCode(userCode: String, verificationUri: String) + /// A user is signed in. + case signedIn(user: String) + /// An error occurred. The associated value is a human-readable description. + case error(String) +} + +/// Manages the lifecycle of the GitHub Copilot language server and exposes its authentication state. +/// +/// `CopilotService` owns the ``CopilotClient`` (and therefore the language-server process and JSON-RPC session), +/// drives the device-flow sign-in, tracks document synchronization versions, and brokers inline-completion +/// requests on behalf of ``CopilotInlineCompletionProvider``. +/// +/// It is a singleton registered with ``ServiceContainer`` alongside ``LSPService`` and is observed by the UI +/// (settings page, status bar) as an `ObservableObject`. +@MainActor +final class CopilotService: ObservableObject { + /// The shared instance. The same instance is registered with ``ServiceContainer``. + static let shared = CopilotService() + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "CopilotService") + + /// The current authentication state. + @Published private(set) var authState: CopilotAuthState = .signedOut + /// Whether a request (sign-in, status check, completion) is currently in flight. + @Published private(set) var isBusy: Bool = false + /// Whether the language-server process is running and initialized. + @Published private(set) var isRunning: Bool = false + + private var client: CopilotClient? + private var isInitialized = false + + /// The `command` returned by the latest `signIn` response, used to finish the device flow. + private var pendingDeviceFlowCommand: CopilotCommand? + + /// Tracks the synced version and last text for each open document URI. + private var syncedDocuments: [String: (version: Int, text: String)] = [:] + + private init() { + NotificationCenter.default.addObserver( + forName: CodeFileDocument.didCloseNotification, + object: nil, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + guard let url = notification.object as? URL else { return } + Task { await self?.closeDocument(uri: url.lspURI) } + } + } + } + + /// Whether the user has enabled Copilot in settings. + var isEnabled: Bool { + Settings.shared.preferences.copilot.enabled + } + + /// Whether a user is currently signed in. + var isSignedIn: Bool { + if case .signedIn = authState { return true } + return false + } + + private var settings: SettingsData.CopilotSettings { + Settings.shared.preferences.copilot + } + + // MARK: - Lifecycle + + /// Starts and initializes the language server if it is not already running. + /// + /// Resolves the binary, launches the process, sends `initialize`/`initialized`, then performs a status check + /// (priming with an environment token if one is available and the user is signed out). + func initializeIfNeeded() async { + guard !isInitialized else { return } + isBusy = true + defer { isBusy = false } + + guard let binaryPath = CopilotBinaryResolver.resolveBinaryPath( + configuredPath: settings.languageServerPath + ) else { + authState = .error("Could not find the copilot-language-server binary. Set its path in settings.") + return + } + + do { + let client = try CopilotClient.launch( + binaryPath: binaryPath, + environment: CopilotBinaryResolver.augmentedEnvironment(forBinaryAt: binaryPath), + terminationHandler: { [weak self] in + Task { @MainActor in self?.handleTermination() } + } + ) + client.onStatusChange = { [weak self] params in + Task { @MainActor in self?.handleStatusChange(params) } + } + self.client = client + + let result = try await client.initialize(makeInitializeParams()) + guard result.capabilities.inlineCompletionProvider != nil else { + authState = .error("Server does not advertise inline completion support.") + return + } + try await client.sendInitialized() + isInitialized = true + isRunning = true + + await refreshStatus(localChecksOnly: false) + await primeWithEnvironmentTokenIfNeeded() + } catch { + logger.error("Failed to initialize Copilot: \(error)") + authState = .error(error.localizedDescription) + isRunning = false + } + } + + /// Terminates the language server and resets all state. + func shutdown() { + client?.process.terminate() + client = nil + isInitialized = false + isRunning = false + syncedDocuments.removeAll() + } + + private func handleTermination() { + logger.warning("Copilot language server terminated") + client = nil + isInitialized = false + isRunning = false + syncedDocuments.removeAll() + } + + // MARK: - Authentication + + /// Re-checks the authentication status with the server. + func checkStatus() async { + await initializeIfNeeded() + await refreshStatus(localChecksOnly: false) + } + + private func refreshStatus(localChecksOnly: Bool) async { + guard let client else { return } + do { + let status = try await client.checkStatus(localChecksOnly: localChecksOnly) + applyStatus(status.status, user: status.user) + } catch { + logger.error("checkStatus failed: \(error)") + } + } + + /// Begins the GitHub device-flow sign-in, moving to the `awaitingDeviceCode` state with the user code. + func signIn() async { + await initializeIfNeeded() + guard let client else { return } + isBusy = true + defer { isBusy = false } + + do { + let result = try await client.signIn() + if result.status == "AlreadySignedIn" { + applyStatus("OK", user: result.user) + return + } + guard let userCode = result.userCode, let verificationUri = result.verificationUri else { + authState = .error("Sign-in did not return a device code.") + return + } + pendingDeviceFlowCommand = result.command + authState = .awaitingDeviceCode(userCode: userCode, verificationUri: verificationUri) + } catch { + logger.error("signIn failed: \(error)") + authState = .error(error.localizedDescription) + } + } + + /// Completes the device flow by executing the `finishDeviceFlow` command returned by ``signIn()``. + /// + /// This call blocks server-side until the user authorizes the code in their browser. + func confirmDeviceFlow() async { + guard let client, let command = pendingDeviceFlowCommand else { return } + isBusy = true + defer { isBusy = false } + + do { + _ = try await client.executeCommand(command) + pendingDeviceFlowCommand = nil + await refreshStatus(localChecksOnly: false) + } catch { + logger.error("finishDeviceFlow failed: \(error)") + authState = .error(error.localizedDescription) + } + } + + /// Signs the current user out of Copilot. + func signOut() async { + guard let client else { + authState = .signedOut + return + } + isBusy = true + defer { isBusy = false } + + do { + _ = try await client.signOut() + authState = .signedOut + } catch { + logger.error("signOut failed: \(error)") + authState = .error(error.localizedDescription) + } + } + + private func primeWithEnvironmentTokenIfNeeded() async { + guard case .signedOut = authState, let client else { return } + let environment = ProcessInfo.processInfo.environment + guard let token = environment["GH_COPILOT_TOKEN"] ?? environment["GITHUB_COPILOT_TOKEN"] else { return } + do { + let status = try await client.signInWithGithubToken(token, user: nil) + applyStatus(status.status, user: status.user) + } catch { + logger.warning("Environment token sign-in failed: \(error)") + } + } + + private func applyStatus(_ status: String, user: String?) { + switch status { + case "OK", "MaybeOk", "AlreadySignedIn": + authState = .signedIn(user: user ?? "GitHub user") + case "NotSignedIn", "NotAuthorized": + if case .awaitingDeviceCode = authState { return } // keep prompting during the flow + authState = .signedOut + default: + authState = .signedOut + } + } + + private func handleStatusChange(_ params: CopilotDidChangeStatusParams) { + for status in params.statuses where status.category == "auth" { + if let resultStatus = status.result?.status { + applyStatus(resultStatus, user: nil) + } + } + } + + // MARK: - Document Synchronization + + /// Sends `didOpen` the first time a URI is seen, otherwise a full-text `didChange` with a bumped version. + func syncDocument(uri: String, languageId: String, text: String) async { + guard let client else { return } + if let existing = syncedDocuments[uri] { + guard existing.text != text else { return } + let version = existing.version + 1 + syncedDocuments[uri] = (version, text) + try? await client.didChange(uri: uri, version: version, text: text) + } else { + syncedDocuments[uri] = (1, text) + try? await client.didOpen(uri: uri, languageId: languageId, version: 1, text: text) + } + } + + /// Notifies the server a document was closed and forgets its sync state. + func closeDocument(uri: String) async { + guard let client, syncedDocuments[uri] != nil else { return } + syncedDocuments.removeValue(forKey: uri) + try? await client.didClose(uri: uri) + } + + // MARK: - Inline Completion + + /// Requests inline completions for a synced document. + /// + /// Returns an empty array (and transitions to ``CopilotAuthState/signedOut``) if the server reports the + /// not-authenticated error (code 1000). + func requestInlineCompletion( + uri: String, + position: CopilotPosition, + tabWidth: Int + ) async -> [CopilotCompletionItem] { + guard let client else { return [] } + let version = syncedDocuments[uri]?.version ?? 1 + let params = CopilotInlineCompletionParams( + textDocument: .init(uri: uri, version: version), + position: position, + context: .init(triggerKind: CopilotInlineTriggerKind.automatic.rawValue), + formattingOptions: .init(tabSize: tabWidth, insertSpaces: true) + ) + do { + return try await client.inlineCompletion(params).items + } catch let error as JSONRPCResponseError where error.code == copilotNotAuthenticatedErrorCode { + authState = .signedOut + return [] + } catch { + logger.warning("inlineCompletion failed: \(error)") + return [] + } + } + + /// Reports that an item was shown (telemetry). + func reportShown(item: CopilotCompletionItem) { + guard let client else { return } + Task { try? await client.didShowCompletion(item: item) } + } + + /// Reports that an item was fully accepted by executing its `command` (telemetry). + func reportAccepted(item: CopilotCompletionItem) { + guard let client, let command = item.command else { return } + Task { _ = try? await client.executeCommand(command) } + } + + // MARK: - Initialize Params + + private func makeInitializeParams() -> CopilotInitializeParams { + let version = Bundle.versionString ?? "1.0" + let rootUri = FileManager.default.homeDirectoryForCurrentUser.lspURI + return CopilotInitializeParams( + processId: Int(ProcessInfo.processInfo.processIdentifier), + clientInfo: .init(name: "CodeEdit", version: version), + rootUri: rootUri, + workspaceFolders: [.init(uri: rootUri, name: "CodeEdit")], + capabilities: .init( + workspace: .init(workspaceFolders: true, configuration: true), + textDocument: .init(inlineCompletion: CopilotEmptyObject()) + ), + initializationOptions: .init( + editorInfo: .init(name: "CodeEdit", version: version), + editorPluginInfo: .init(name: "CodeEdit-Copilot", version: version) + ) + ) + } +} diff --git a/CodeEdit/Features/Copilot/Models/CopilotProtocol.swift b/CodeEdit/Features/Copilot/Models/CopilotProtocol.swift new file mode 100644 index 0000000000..ae1f38094e --- /dev/null +++ b/CodeEdit/Features/Copilot/Models/CopilotProtocol.swift @@ -0,0 +1,261 @@ +// +// CopilotProtocol.swift +// CodeEdit +// +// Created by Anas Khan on 6/30/26. +// + +import Foundation +import JSONRPC + +/// An empty JSON object (`{}`) used for request params and replies that carry no data. +struct CopilotEmptyObject: Codable, Sendable {} + +/// A `name`/`version` pair, used for `clientInfo`, `editorInfo`, and `editorPluginInfo`. +struct CopilotNameVersion: Codable, Sendable { + let name: String + let version: String +} + +// MARK: - Initialize + +/// Parameters for the `initialize` request. +/// +/// The Copilot language server requires both `editorInfo` and `editorPluginInfo` inside +/// `initializationOptions`, and advertises `textDocument.inlineCompletion` support. +struct CopilotInitializeParams: Codable, Sendable { + struct Capabilities: Codable, Sendable { + struct Workspace: Codable, Sendable { + let workspaceFolders: Bool + let configuration: Bool + } + struct TextDocument: Codable, Sendable { + let inlineCompletion: CopilotEmptyObject + } + let workspace: Workspace + let textDocument: TextDocument + } + + struct InitializationOptions: Codable, Sendable { + let editorInfo: CopilotNameVersion + let editorPluginInfo: CopilotNameVersion + } + + struct WorkspaceFolder: Codable, Sendable { + let uri: String + let name: String + } + + let processId: Int? + let clientInfo: CopilotNameVersion + let rootUri: String? + let workspaceFolders: [WorkspaceFolder]? + let capabilities: Capabilities + let initializationOptions: InitializationOptions +} + +/// The subset of the `initialize` response capabilities CodeEdit inspects. +struct CopilotInitializeResult: Codable, Sendable { + struct Capabilities: Codable, Sendable { + struct ExecuteCommandProvider: Codable, Sendable { + let commands: [String]? + } + /// Present (as an empty object) when the server supports inline completions. + let inlineCompletionProvider: JSONValue? + let executeCommandProvider: ExecuteCommandProvider? + } + let capabilities: Capabilities +} + +// MARK: - Authentication + +/// Parameters for the `checkStatus` request. +struct CopilotCheckStatusParams: Codable, Sendable { + struct Options: Codable, Sendable { + let localChecksOnly: Bool + } + let options: Options +} + +/// Result of `checkStatus`, `signOut`, and `signInWithGithubToken`. +struct CopilotStatusResult: Codable, Sendable { + let status: String + let user: String? +} + +/// Result of the `signIn` request describing the GitHub device flow. +struct CopilotSignInResult: Codable, Sendable { + let status: String + let userCode: String? + let verificationUri: String? + let expiresIn: Int? + let interval: Int? + let user: String? + let command: CopilotCommand? +} + +/// Parameters for `signInWithGithubToken`. +struct CopilotSignInWithTokenParams: Codable, Sendable { + let githubToken: String + let user: String? +} + +// MARK: - Commands + +/// An LSP `Command` as returned by the server (for device-flow completion and accept telemetry). +struct CopilotCommand: Codable, Sendable { + let command: String + let title: String? + let arguments: [JSONValue]? +} + +/// Parameters for `workspace/executeCommand`. +struct CopilotExecuteCommandParams: Codable, Sendable { + let command: String + let arguments: [JSONValue]? +} + +// MARK: - Document Synchronization + +struct CopilotTextDocumentItem: Codable, Sendable { + let uri: String + let languageId: String + let version: Int + let text: String +} + +struct CopilotVersionedDocumentIdentifier: Codable, Sendable { + let uri: String + let version: Int +} + +struct CopilotDocumentIdentifier: Codable, Sendable { + let uri: String +} + +struct CopilotContentChange: Codable, Sendable { + let text: String +} + +struct CopilotDidOpenParams: Codable, Sendable { + let textDocument: CopilotTextDocumentItem +} + +struct CopilotDidChangeParams: Codable, Sendable { + let textDocument: CopilotVersionedDocumentIdentifier + let contentChanges: [CopilotContentChange] +} + +struct CopilotDidCloseParams: Codable, Sendable { + let textDocument: CopilotDocumentIdentifier +} + +struct CopilotDidFocusParams: Codable, Sendable { + let textDocument: CopilotDocumentIdentifier +} + +// MARK: - Inline Completion + +struct CopilotPosition: Codable, Sendable { + let line: Int + let character: Int +} + +struct CopilotRange: Codable, Sendable { + let start: CopilotPosition + let end: CopilotPosition +} + +/// The trigger reason for an inline completion request. CodeEdit always requests automatically. +enum CopilotInlineTriggerKind: Int, Codable, Sendable { + case invoked = 1 + case automatic = 2 +} + +struct CopilotInlineCompletionContext: Codable, Sendable { + let triggerKind: Int +} + +struct CopilotFormattingOptions: Codable, Sendable { + let tabSize: Int + let insertSpaces: Bool +} + +struct CopilotInlineCompletionParams: Codable, Sendable { + let textDocument: CopilotVersionedDocumentIdentifier + let position: CopilotPosition + let context: CopilotInlineCompletionContext + let formattingOptions: CopilotFormattingOptions? +} + +/// A single inline completion suggestion returned by the server. +struct CopilotCompletionItem: Codable, Sendable { + let insertText: String + let range: CopilotRange? + let command: CopilotCommand? +} + +struct CopilotInlineCompletionResult: Codable, Sendable { + let items: [CopilotCompletionItem] +} + +// MARK: - Telemetry + +struct CopilotDidShowCompletionParams: Codable, Sendable { + let item: CopilotCompletionItem +} + +struct CopilotDidPartiallyAcceptParams: Codable, Sendable { + let item: CopilotCompletionItem + let acceptedLength: Int +} + +// MARK: - Server Notifications + +/// Payload of the `didChangeStatus/v2` notification. +struct CopilotDidChangeStatusParams: Codable, Sendable { + struct Status: Codable, Sendable { + struct Result: Codable, Sendable { + let status: String? + } + let category: String + let kind: String? + let message: String? + let result: Result? + } + let statuses: [Status] +} + +// MARK: - Method Names + +/// String constants for the custom Copilot JSON-RPC methods not modeled by `LanguageServerProtocol`. +enum CopilotMethod { + static let initialize = "initialize" + static let initialized = "initialized" + static let didChangeConfiguration = "workspace/didChangeConfiguration" + static let checkStatus = "checkStatus" + static let signIn = "signIn" + static let signInWithGithubToken = "signInWithGithubToken" + static let signOut = "signOut" + static let executeCommand = "workspace/executeCommand" + static let didOpen = "textDocument/didOpen" + static let didChange = "textDocument/didChange" + static let didClose = "textDocument/didClose" + static let didFocus = "textDocument/didFocus" + static let inlineCompletion = "textDocument/inlineCompletion" + static let didShowCompletion = "textDocument/didShowCompletion" + static let didPartiallyAcceptCompletion = "textDocument/didPartiallyAcceptCompletion" + + // Server to client + static let configuration = "workspace/configuration" + static let workDoneProgressCreate = "window/workDoneProgress/create" + static let showDocument = "window/showDocument" + static let didChangeStatusV2 = "didChangeStatus/v2" + + // Commands + static let finishDeviceFlow = "github.copilot.finishDeviceFlow" + static let didAcceptCompletionItem = "github.copilot.didAcceptCompletionItem" +} + +/// The JSON-RPC error code the server returns when an inline completion is requested while signed out. +let copilotNotAuthenticatedErrorCode = 1000 diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 5a6a3b3b46..41716239c4 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -66,6 +66,9 @@ final class CodeFileDocument: NSDocument, ObservableObject { /// Set up by ``LanguageServer``, conforms this type to ``LanguageServerDocument``. @Published var languageServerObjects: LanguageServerDocumentObjects = .init() + /// Per-document GitHub Copilot objects (inline completion provider). See ``CopilotDocumentObjects``. + @Published var copilotObjects: CopilotDocumentObjects = .init() + /// The type of data this file document contains. /// /// If its text content is not nil, a `text` UTType is returned. diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index f22f6cce3d..d01a9dd59c 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -59,6 +59,8 @@ struct CodeFileView: View { var invisibleCharactersConfiguration @AppSettings(\.textEditing.warningCharacters) var warningCharacters + @AppSettings(\.copilot.enabled) + var copilotEnabled @Environment(\.colorScheme) private var colorScheme @@ -93,6 +95,8 @@ struct CodeFileView: View { editorInstance.cursorPositions = openOptions.cursorPositions } + codeFile.copilotObjects.setUp(document: codeFile) + highlightProviders = [codeFile.languageServerObjects.highlightProvider] + [treeSitterClient] codeFile @@ -168,7 +172,8 @@ struct CodeFileView: View { ), highlightProviders: highlightProviders, undoManager: undoRegistration.manager(forFile: editorInstance.file), - coordinators: textViewCoordinators + coordinators: textViewCoordinators, + inlineCompletionDelegate: copilotEnabled ? codeFile.copilotObjects.provider : nil ) // This view needs to refresh when the codefile changes. The file URL is too stable. .id(ObjectIdentifier(codeFile)) @@ -185,6 +190,11 @@ struct CodeFileView: View { .onChange(of: settingsFont) { _, newFontSetting in font = newFontSetting.current } + .task(id: copilotEnabled) { + if copilotEnabled { + await CopilotService.shared.initializeIfNeeded() + } + } } /// Determines the style of bracket emphasis based on the `bracketEmphasis` setting and the current theme. diff --git a/CodeEdit/Features/Settings/Models/SettingsData.swift b/CodeEdit/Features/Settings/Models/SettingsData.swift index cd860c7e43..bbd314f619 100644 --- a/CodeEdit/Features/Settings/Models/SettingsData.swift +++ b/CodeEdit/Features/Settings/Models/SettingsData.swift @@ -53,6 +53,9 @@ struct SettingsData: Codable, Hashable { /// Language Server Settings var languageServers: LanguageServerSettings = .init() + /// GitHub Copilot settings + var copilot: CopilotSettings = .init() + /// Developer settings for CodeEdit developers var developerSettings: DeveloperSettings = .init() @@ -80,6 +83,9 @@ struct SettingsData: Codable, Hashable { self.languageServers = try container.decodeIfPresent( LanguageServerSettings.self, forKey: .languageServers ) ?? .init() + self.copilot = try container.decodeIfPresent( + CopilotSettings.self, forKey: .copilot + ) ?? .init() self.developerSettings = try container.decodeIfPresent( DeveloperSettings.self, forKey: .developerSettings ) ?? .init() @@ -112,6 +118,10 @@ struct SettingsData: Codable, Hashable { LanguageServerSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .copilot: + CopilotSettings().searchKeys.forEach { + settings.append(.init(name, isSetting: true, settingName: $0)) + } case .developer: developerSettings.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } case .behavior: return [.init(name, settingName: "Error")] diff --git a/CodeEdit/Features/Settings/Models/SettingsPage.swift b/CodeEdit/Features/Settings/Models/SettingsPage.swift index 597532b556..028e416f46 100644 --- a/CodeEdit/Features/Settings/Models/SettingsPage.swift +++ b/CodeEdit/Features/Settings/Models/SettingsPage.swift @@ -33,6 +33,7 @@ struct SettingsPage: Hashable, Equatable, Identifiable { case location = "Locations" case advanced = "Advanced" case languageServers = "Language Servers" + case copilot = "GitHub Copilot" case developer = "Developer" } diff --git a/CodeEdit/Features/Settings/Pages/CopilotSettings/CopilotSettingsView.swift b/CodeEdit/Features/Settings/Pages/CopilotSettings/CopilotSettingsView.swift new file mode 100644 index 0000000000..cf3e4bf43d --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/CopilotSettings/CopilotSettingsView.swift @@ -0,0 +1,135 @@ +// +// CopilotSettingsView.swift +// CodeEdit +// +// Created by Anas Khan on 6/30/26. +// + +import SwiftUI + +/// The settings page for the GitHub Copilot integration: enable toggle, binary path, and device-flow sign-in. +struct CopilotSettingsView: View { + @AppSettings(\.copilot.enabled) + var enabled + @AppSettings(\.copilot.languageServerPath) + var languageServerPath + + @ObservedObject private var service: CopilotService = .shared + + var body: some View { + SettingsForm { + Section { + Toggle("Enable GitHub Copilot", isOn: $enabled) + } header: { + Text("GitHub Copilot") + Text("Show AI inline completions as you type. Requires a GitHub Copilot subscription.") + } + + Section { + accountRow + } header: { + Text("Account") + } + + Section { + LabeledContent("Language Server Path") { + TextField("Auto-discover", text: $languageServerPath) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 260) + } + } header: { + Text("Advanced") + Text( + "Optional absolute path to the copilot-language-server binary. " + + "Leave empty to discover it on your PATH or npm global install." + ) + } + } + } + + @ViewBuilder private var accountRow: some View { + switch service.authState { + case .signedOut: + LabeledContent("Status") { + HStack { + Text("Not signed in") + .foregroundStyle(.secondary) + signInButton + } + } + case let .awaitingDeviceCode(userCode, verificationUri): + deviceCodeView(userCode: userCode, verificationUri: verificationUri) + case let .signedIn(user): + LabeledContent("Status") { + HStack { + Label("Signed in as \(user)", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Button("Sign Out") { + Task { await service.signOut() } + } + .disabled(service.isBusy) + } + } + case let .error(message): + LabeledContent("Status") { + VStack(alignment: .trailing, spacing: 4) { + Label(message, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .multilineTextAlignment(.trailing) + signInButton + } + } + } + } + + private var signInButton: some View { + Button("Sign In") { + startSignIn() + } + .disabled(service.isBusy) + } + + @ViewBuilder + private func deviceCodeView(userCode: String, verificationUri: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Enter this code at GitHub to finish signing in:") + .foregroundStyle(.secondary) + HStack { + Text(userCode) + .font(.system(.title2, design: .monospaced, weight: .semibold)) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 6).fill(Color(.textBackgroundColor))) + Button { + copyCodeAndOpen(userCode: userCode, verificationUri: verificationUri) + } label: { + Label("Copy & Open Browser", systemImage: "arrow.up.forward.square") + } + } + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Waiting for authorization...") + .foregroundStyle(.secondary) + } + } + } + + private func startSignIn() { + Task { + await service.signIn() + if case let .awaitingDeviceCode(userCode, verificationUri) = service.authState { + copyCodeAndOpen(userCode: userCode, verificationUri: verificationUri) + await service.confirmDeviceFlow() + } + } + } + + private func copyCodeAndOpen(userCode: String, verificationUri: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(userCode, forType: .string) + if let url = URL(string: verificationUri) { + NSWorkspace.shared.open(url) + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/CopilotSettings/Models/CopilotSettings.swift b/CodeEdit/Features/Settings/Pages/CopilotSettings/Models/CopilotSettings.swift new file mode 100644 index 0000000000..93a240160a --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/CopilotSettings/Models/CopilotSettings.swift @@ -0,0 +1,47 @@ +// +// CopilotSettings.swift +// CodeEdit +// +// Created by Anas Khan on 6/30/26. +// + +import Foundation + +extension SettingsData { + /// Settings for the GitHub Copilot inline completion integration. + struct CopilotSettings: Codable, Hashable, SearchableSettingsPage { + + /// The search keys + var searchKeys: [String] { + [ + "GitHub Copilot", + "Copilot", + "Inline Completion", + "Code Completion", + "AI", + "Language Server" + ] + .map { NSLocalizedString($0, comment: "") } + } + + /// Whether Copilot inline completions are enabled. + var enabled: Bool = false + + /// An optional explicit path to the `copilot-language-server` binary. When empty, the binary is + /// discovered automatically from `PATH` and common install locations. + var languageServerPath: String = "" + + /// Default initializer + init() {} + + /// Explicit decoder init for setting default values when key is not present in `JSON` + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false + self.languageServerPath = try container.decodeIfPresent( + String.self, + forKey: .languageServerPath + ) ?? "" + } + } +} diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/Features/Settings/SettingsView.swift index 9caf15464c..3905d345a7 100644 --- a/CodeEdit/Features/Settings/SettingsView.swift +++ b/CodeEdit/Features/Settings/SettingsView.swift @@ -92,6 +92,13 @@ struct SettingsView: View { icon: .system("cube.box.fill") ) ), + .init( + SettingsPage( + .copilot, + baseColor: Color(hex: "#24292F"), // GitHub dark + icon: .system("sparkles") + ) + ), .init( SettingsPage( .developer, @@ -200,6 +207,8 @@ struct SettingsView: View { LocationsSettingsView() case .languageServers: LanguageServersView() + case .copilot: + CopilotSettingsView() case .developer: DeveloperSettingsView() default: diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCopilotIcon.swift b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCopilotIcon.swift new file mode 100644 index 0000000000..d84c7dc5c8 --- /dev/null +++ b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCopilotIcon.swift @@ -0,0 +1,75 @@ +// +// StatusBarCopilotIcon.swift +// CodeEdit +// +// Created by Anas Khan on 6/30/26. +// + +import SwiftUI + +/// A status bar item reflecting the GitHub Copilot state. +/// +/// Shows `sparkles` when signed in and idle, a progress indicator while busy, and `sparkles.slash` when signed +/// out or in an error state. Clicking opens the Settings window. Hidden entirely when Copilot is disabled. +struct StatusBarCopilotIcon: View { + @ObservedObject private var service: CopilotService = .shared + + @Environment(\.openWindow) + private var openWindow + + var body: some View { + if service.isEnabled { + Button { + openWindow(id: SceneID.settings.rawValue) + } label: { + iconContent + } + .buttonStyle(.icon) + .help(helpText) + } + } + + @ViewBuilder private var iconContent: some View { + if service.isBusy { + ProgressView() + .controlSize(.small) + .frame(width: 14, height: 14) + } else { + Image(systemName: symbolName) + .foregroundStyle(tint) + } + } + + private var symbolName: String { + switch service.authState { + case .signedIn: + return "sparkles" + case .signedOut, .awaitingDeviceCode, .error: + return "sparkles.slash" + } + } + + private var tint: Color { + switch service.authState { + case .signedIn: + return .primary + case .error: + return .red + case .signedOut, .awaitingDeviceCode: + return .secondary + } + } + + private var helpText: String { + switch service.authState { + case .signedIn(let user): + return "GitHub Copilot: Signed in as \(user)" + case .awaitingDeviceCode: + return "GitHub Copilot: Waiting for authorization" + case .signedOut: + return "GitHub Copilot: Not signed in" + case .error(let message): + return "GitHub Copilot: \(message)" + } + } +} diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarView.swift b/CodeEdit/Features/StatusBar/Views/StatusBarView.swift index cb73012d8a..0d881d906c 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarView.swift +++ b/CodeEdit/Features/StatusBar/Views/StatusBarView.swift @@ -40,6 +40,7 @@ struct StatusBarView: View { StatusBarFileInfoView() StatusBarCursorPositionLabel() StatusBarDivider() + StatusBarCopilotIcon() StatusBarToggleUtilityAreaButton() } .padding(.horizontal, 10)