Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- AI provider registry for extensible provider management
- GitHub Copilot integration: inline suggestions, chat via LSP conversation protocol, OAuth sign-in, schema context
- Inline suggestion architecture rewrite with protocol-based sources and ghost text rendering
- Unified inline suggestion provider picker (Off/Copilot/AI) in Editor settings
- Connection sharing: Share submenu with Copy Connection String, Copy TablePro Link, Copy as JSON, and rich deep links carrying all connection fields
- Edit > Find menu item (Cmd+F)
- MCP server: Bearer token authentication with multi-token management
Expand Down
77 changes: 57 additions & 20 deletions TablePro/Core/AI/AIProviderFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,9 @@ enum AIProviderFactory {
}

let provider: AIProvider
switch config.type {
case .claude:
provider = AnthropicProvider(
endpoint: config.endpoint,
apiKey: apiKey ?? "",
maxOutputTokens: config.maxOutputTokens ?? 4_096
)
case .gemini:
provider = GeminiProvider(
endpoint: config.endpoint,
apiKey: apiKey ?? "",
maxOutputTokens: config.maxOutputTokens ?? 8_192
)
case .openAI, .openRouter, .ollama, .custom:
if let descriptor = AIProviderRegistry.shared.descriptor(for: config.type.rawValue) {
provider = descriptor.makeProvider(config, apiKey)
} else {
provider = OpenAICompatibleProvider(
endpoint: config.endpoint,
apiKey: apiKey,
Expand All @@ -66,22 +55,70 @@ enum AIProviderFactory {
cacheLock.withLock { $0.removeValue(forKey: configID) }
}

static func resetCopilotConversation() {
cacheLock.withLock { cache in
for (_, entry) in cache {
if let copilot = entry.provider as? CopilotChatProvider {
copilot.resetConversation()
}
}
}
}

static func copilotDeleteLastTurn() {
cacheLock.withLock { cache in
for (_, entry) in cache {
if let copilot = entry.provider as? CopilotChatProvider {
copilot.deleteLastTurn()
}
}
}
}

static func resolveProvider(
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
5 changes: 3 additions & 2 deletions TablePro/Core/AI/AISchemaContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ struct AISchemaContext {
!query.isEmpty {
let lang = editorLanguage.codeBlockTag
let maxQueryLength = 2_000
let truncated = query.count > maxQueryLength
? String(query.prefix(maxQueryLength)) + "\n-- ... truncated"
let nsQuery = query as NSString
let truncated = nsQuery.length > maxQueryLength
? nsQuery.substring(to: maxQueryLength) + "\n-- ... truncated"
: query
parts.append("\n## Current Query\n```\(lang)\n\(truncated)\n```")
}
Expand Down
88 changes: 88 additions & 0 deletions TablePro/Core/AI/Copilot/CopilotAuthManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// 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 {
guard !Task.isCancelled else {
throw CopilotError.authenticationFailed(String(localized: "Sign-in cancelled"))
}
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
}

do {
try await Task.sleep(for: pollInterval)
} catch is CancellationError {
throw CopilotError.authenticationFailed(String(localized: "Sign-in cancelled"))
}
}

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)")
}
}
}
158 changes: 158 additions & 0 deletions TablePro/Core/AI/Copilot/CopilotBinaryManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
//
// CopilotBinaryManager.swift
// TablePro
//

import CryptoKit
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
downloadTask = nil
} 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)

guard let integrityValue = dist["integrity"] as? String else {
Self.logger.error("No integrity hash in npm registry response")
throw CopilotError.binaryNotFound
}

let actualHash = "sha512-" + tarballData.sha512Base64String()
if actualHash != integrityValue {
Self.logger.error("Binary integrity mismatch")
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 sha512Base64String() -> String {
let digest = SHA512.hash(data: self)
return Data(digest).base64EncodedString()
}
}
Loading
Loading