Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5b14370
feat: add GitHub Copilot support with rewritten inline suggestion arc…
datlechin Apr 24, 2026
229f3aa
fix: resolve Copilot service, binary manager, and auth issues
datlechin Apr 24, 2026
155144d
fix: resolve critical bugs in LSP transport and inline suggestion system
datlechin Apr 24, 2026
fca0951
fix: treat query timeout as best-effort so Aurora DSQL can connect (#…
datlechin Apr 24, 2026
f2ce8ec
fix: correct Copilot binary tar extraction path, stop restart on perm…
datlechin Apr 24, 2026
941fed9
fix: separate auth status from server status in Copilot notifications
datlechin Apr 24, 2026
bd24d80
fix: use typed Codable structs for LSP params instead of fragile AnyC…
datlechin Apr 24, 2026
e3f3ae7
fix: send empty object params for checkStatus request
datlechin Apr 24, 2026
47bbe59
fix: handle null and non-object JSON-RPC result values without crashing
datlechin Apr 24, 2026
bbc0fd9
refactor: unify inline suggestion settings into single EditorSettings…
datlechin Apr 24, 2026
6645375
fix: sync document immediately when creating CopilotInlineSource
datlechin Apr 24, 2026
49f8cfc
fix: start Copilot service on app launch when enabled
datlechin Apr 24, 2026
3bf2a8b
fix: use file:// URI with .sql extension for Copilot document sync
datlechin Apr 24, 2026
97fe8e5
fix: use correct triggerKind=2 (Automatic) for inline completions
datlechin Apr 24, 2026
fb5b31b
fix: add workspaceFolders and capabilities to LSP initialize params
datlechin Apr 24, 2026
e50815a
debug: add logging for Copilot inline suggestion pipeline
datlechin Apr 24, 2026
0062de2
refactor: production-ready inline suggestion system with proper ghost…
datlechin Apr 24, 2026
51898aa
feat: provide database schema context to Copilot via companion document
datlechin Apr 24, 2026
40390e1
fix: preserve connectionInfo on coalesced schema load, add schema syn…
datlechin Apr 24, 2026
5bd8d30
fix: get database name from DatabaseManager session instead of schema…
datlechin Apr 24, 2026
6df70c9
fix: prepend schema preamble to document text instead of companion file
datlechin Apr 24, 2026
e5c7662
fix: build schema preamble before opening document to avoid duplicate…
datlechin Apr 24, 2026
0d531b1
fix: count newlines in preamble for correct line offset
datlechin Apr 24, 2026
e27ca61
chore: clean up schema preamble logging
datlechin Apr 24, 2026
21f2925
refactor: use real file on disk + workspace folder for schema context…
datlechin Apr 24, 2026
527a8ba
fix: block completion requests until document is synced to server
datlechin Apr 24, 2026
dc4c094
fix: prevent didChange race before server sync, add dialect hint to s…
datlechin Apr 24, 2026
574c8f7
refactor: final cleanup for merge — fix duplicate didOpen, URI encodi…
datlechin Apr 24, 2026
aa284ca
Merge branch 'main' into feat/copilot-support
datlechin Apr 24, 2026
85902c6
refactor: clean preamble architecture for Copilot schema context
datlechin Apr 24, 2026
37e7021
fix: pass Escape event through so autocomplete popup also closes
datlechin Apr 24, 2026
c57c77f
fix: address PR review findings — server restart sync, download guard…
datlechin Apr 24, 2026
4a4b0bd
feat: add AI provider registry pattern and Copilot Chat support
datlechin Apr 24, 2026
112ad2f
feat: wire registry to factory, implement Copilot Chat conversation p…
datlechin Apr 24, 2026
406dc59
feat: wire registry to factory, implement Copilot Chat conversation p…
datlechin Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- AI provider registry for extensible provider management
- GitHub Copilot as an AI chat provider option (LSP transport pending)
- In-app feedback form for bug reports and feature requests via Help > Report an Issue
- Per-connection "Local only" option to exclude individual connections from iCloud sync
- Filter operator picker shows SQL symbols alongside names for quick visual recognition
Expand Down
42 changes: 36 additions & 6 deletions TablePro/Core/AI/AIProviderFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ enum AIProviderFactory {
apiKey: apiKey ?? "",
maxOutputTokens: config.maxOutputTokens ?? 8_192
)
case .copilot:
provider = CopilotChatProvider()
case .openAI, .openRouter, .ollama, .custom:
provider = OpenAICompatibleProvider(
endpoint: config.endpoint,
Expand All @@ -70,18 +72,46 @@ enum AIProviderFactory {
for feature: AIFeature,
settings: AISettings
) -> (AIProviderConfig, String?)? {
if let route = settings.featureRouting[feature.rawValue],
let config = settings.providers.first(where: { $0.id == route.providerID && $0.isEnabled }) {
// Check feature routing: explicit provider or Copilot
if let route = settings.featureRouting[feature.rawValue] {
// Routed to Copilot
if route.providerID == AIProviderConfig.copilotProviderID, settings.copilotChatEnabled {
let config = AIProviderConfig(
id: AIProviderConfig.copilotProviderID,
name: "GitHub Copilot",
type: .copilot,
model: route.model,
endpoint: ""
)
return (config, nil)
}

// Routed to a regular provider
if let config = settings.providers.first(where: { $0.id == route.providerID && $0.isEnabled }) {
let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id)
return (config, apiKey)
}
}

// Fallback: first enabled provider
if let config = settings.providers.first(where: { $0.isEnabled }) {
let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id)
return (config, apiKey)
}

guard let config = settings.providers.first(where: { $0.isEnabled }) else {
return nil
// Last resort: if copilotChatEnabled and no other providers, use Copilot
if settings.copilotChatEnabled {
let config = AIProviderConfig(
id: AIProviderConfig.copilotProviderID,
name: "GitHub Copilot",
type: .copilot,
model: "",
endpoint: ""
)
return (config, nil)
}

let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id)
return (config, apiKey)
return nil
}

static func resolveModel(
Expand Down
82 changes: 82 additions & 0 deletions TablePro/Core/AI/Copilot/CopilotAuthManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//
// CopilotAuthManager.swift
// TablePro
//

import AppKit
import Foundation
import os

@MainActor
final class CopilotAuthManager {
private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotAuth")

struct SignInResult {
let userCode: String
let verificationURI: String
}

private struct SignInInitiateResponse: Decodable {
let status: String
let userCode: String
let verificationUri: String
}

private struct SignInConfirmResponse: Decodable {
let status: String
let user: String
}

func initiateSignIn(transport: LSPTransport) async throws -> SignInResult {
let data: Data = try await transport.sendRequest(
method: "signInInitiate",
params: EmptyLSPParams()
)
let response = try JSONDecoder().decode(SignInInitiateResponse.self, from: data)

NSPasteboard.general.clearContents()
NSPasteboard.general.setString(response.userCode, forType: .string)

if let url = URL(string: response.verificationUri) {
NSWorkspace.shared.open(url)
}

Self.logger.info("Sign-in initiated, user code copied to clipboard")
return SignInResult(userCode: response.userCode, verificationURI: response.verificationUri)
}

func completeSignIn(transport: LSPTransport) async throws -> String {
let maxAttempts = 60
let pollInterval: Duration = .seconds(2)

for _ in 0..<maxAttempts {
try Task.checkCancellation()
let data: Data = try await transport.sendRequest(
method: "signInConfirm",
params: EmptyLSPParams()
)
let response = try JSONDecoder().decode(SignInConfirmResponse.self, from: data)

if response.status == "OK" || response.status == "AlreadySignedIn" {
Self.logger.info("Sign-in completed for user: \(response.user)")
return response.user
}

try await Task.sleep(for: pollInterval)
}

throw CopilotError.authenticationFailed(String(localized: "Sign-in timed out"))
}

func signOut(transport: LSPTransport) async {
do {
let _: Data = try await transport.sendRequest(
method: "signOut",
params: EmptyLSPParams()
)
Self.logger.info("Signed out of GitHub Copilot")
} catch {
Self.logger.error("Sign-out failed: \(error.localizedDescription)")
}
}
}
155 changes: 155 additions & 0 deletions TablePro/Core/AI/Copilot/CopilotBinaryManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//
// CopilotBinaryManager.swift
// TablePro
//

import CommonCrypto
import Foundation
import os

actor CopilotBinaryManager {
private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotBinary")
static let shared = CopilotBinaryManager()

private let baseDirectory: URL
private var downloadTask: Task<Void, Error>?

private init() {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
baseDirectory = appSupport.appendingPathComponent("TablePro/copilot-language-server", isDirectory: true)
}

func ensureBinary() async throws -> String {
let path = binaryExecutablePath
if FileManager.default.isExecutableFile(atPath: path) {
return path
}

if let existing = downloadTask {
try await existing.value
} else {
let task = Task { try await downloadBinary() }
downloadTask = task
do {
try await task.value
downloadTask = nil
} catch {
downloadTask = nil
throw error
}
}

guard FileManager.default.isExecutableFile(atPath: path) else {
throw CopilotError.binaryNotFound
}
return path
}

private func downloadBinary() async throws {
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true)

let platform = self.platform
let optionalDep = "@github/copilot-language-server-\(platform)"

guard let registryURL = URL(string: "https://registry.npmjs.org/\(optionalDep)/latest") else {
throw CopilotError.binaryNotFound
}
let (data, _) = try await URLSession.shared.data(from: registryURL)

guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let dist = json["dist"] as? [String: Any],
let tarballURLString = dist["tarball"] as? String,
let tarballURL = URL(string: tarballURLString) else {
throw CopilotError.binaryNotFound
}

let (tarballData, _) = try await URLSession.shared.data(from: tarballURL)

if let expectedShasum = dist["shasum"] as? String {
let actualHash = tarballData.sha1HexString()
if actualHash != expectedShasum {
Self.logger.error("Binary checksum mismatch: expected \(expectedShasum), got \(actualHash)")
throw CopilotError.binaryNotFound
}
}

let tempTar = baseDirectory.appendingPathComponent("download.tar.gz")
try tarballData.write(to: tempTar)

let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
process.arguments = ["xzf", tempTar.path, "-C", baseDirectory.path, "--strip-components=1", "package/copilot-language-server"]

try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
process.terminationHandler = { proc in
if proc.terminationStatus == 0 {
continuation.resume()
} else {
continuation.resume(throwing: CopilotError.binaryNotFound)
}
}
do {
try process.run()
} catch {
continuation.resume(throwing: error)
}
}

try? FileManager.default.removeItem(at: tempTar)

// Verify extraction; try to find binary if not at expected path
if !FileManager.default.fileExists(atPath: binaryExecutablePath) {
let enumerator = FileManager.default.enumerator(at: baseDirectory, includingPropertiesForKeys: nil)
while let fileURL = enumerator?.nextObject() as? URL {
if fileURL.lastPathComponent == "copilot-language-server" {
let foundPath = fileURL.path
if foundPath != binaryExecutablePath {
try FileManager.default.moveItem(atPath: foundPath, toPath: binaryExecutablePath)
Self.logger.info("Moved binary from \(foundPath) to expected location")
}
break
}
}
}

try FileManager.default.setAttributes(
[.posixPermissions: 0o755],
ofItemAtPath: binaryExecutablePath
)

// Store version for future reference
if let version = json["version"] as? String {
let versionFile = baseDirectory.appendingPathComponent("version.txt")
try? version.write(to: versionFile, atomically: true, encoding: .utf8)
Self.logger.info("Installed Copilot language server version \(version)")
}

Self.logger.info("Downloaded Copilot language server binary")
}

func installedVersion() -> String? {
let versionFile = baseDirectory.appendingPathComponent("version.txt")
return try? String(contentsOf: versionFile, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
}

private var binaryExecutablePath: String {
baseDirectory.appendingPathComponent("copilot-language-server").path
}

private var platform: String {
#if arch(arm64)
return "darwin-arm64"
#else
return "darwin-x64"
#endif
}
}

private extension Data {
func sha1HexString() -> String {
var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
withUnsafeBytes { CC_SHA1($0.baseAddress, CC_LONG(count), &digest) }
return digest.map { String(format: "%02x", $0) }.joined()
}
}
63 changes: 63 additions & 0 deletions TablePro/Core/AI/Copilot/CopilotChatProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// CopilotChatProvider.swift
// TablePro
//
// AIProvider implementation for GitHub Copilot Chat.
// Uses the Copilot LSP conversation protocol for streaming chat completions.
// Requires an authenticated CopilotService with a running LSP server.
//

import Foundation
import os

/// AI provider that routes chat requests through the GitHub Copilot LSP server
final class CopilotChatProvider: AIProvider {
private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotChatProvider")

// MARK: - AIProvider

func streamChat(
messages: [AIChatMessage],
model: String,
systemPrompt: String?
) -> AsyncThrowingStream<AIStreamEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task { @MainActor in
do {
guard CopilotService.shared.isAuthenticated else {
throw CopilotError.serverNotRunning
}

// LSP transport not yet available. Once the Copilot LSP client ships,
// this will use conversation/create + conversation/turn to stream responses.
throw CopilotError.serverNotRunning
} catch {
continuation.finish(throwing: error)
}
}

continuation.onTermination = { _ in
task.cancel()
}
}
}

func fetchAvailableModels() async throws -> [String] {
guard await CopilotService.shared.isAuthenticated else {
throw CopilotError.serverNotRunning
}

// Default models available through Copilot Chat.
// Once LSP transport ships, this will query copilot/models.
return [
"gpt-4o",
"gpt-4o-mini",
"claude-3.5-sonnet",
"o3-mini"
]
}

func testConnection() async throws -> Bool {
await CopilotService.shared.isAuthenticated
}
}
Loading
Loading