From 652a9866fc8ac82603934c34fd5b99d1e0267796 Mon Sep 17 00:00:00 2001 From: yan Date: Mon, 12 Oct 2026 16:35:10 +0300 Subject: [PATCH] feat(ai-completion): add OpenAI-compatible code autocompletions and model chooser - Add OpenAIClient in CodeEditorCore supporting standard /v1 and Ollama models discovery and chat completions - Add completion engine mode selector (Language Server vs AI Copilot-style) in Settings - Implement dynamic Fetch Models parser and searchable model picker in Settings - Integrate Copilot-style suggestions with sparkles indicator, model badge, and Tab/Enter insertion - Expand code suggestion trigger characters in AI mode to include delimiters and operators - Provide unit tests for OpenAIClient URL resolution, schema parsing, and text cleaning --- .../Sources/AI/OpenAIClient.swift | 262 ++++++++++++++++++ Sources/CodeEditorUI/AppState.swift | 28 ++ .../Sources/Chrome/SettingsView.swift | 130 +++++++++ .../Sources/Editor/CodeEditorView.swift | 4 +- .../Sources/Editor/EditorState.swift | 62 +++++ .../Editor/LSPTextViewCoordinator.swift | 128 ++++++++- Tests/CodeEditorTests/OpenAIClientTests.swift | 124 +++++++++ docs/CURRENT_STATE.md | 1 + 8 files changed, 737 insertions(+), 2 deletions(-) create mode 100644 Sources/CodeEditorCore/Sources/AI/OpenAIClient.swift create mode 100644 Tests/CodeEditorTests/OpenAIClientTests.swift diff --git a/Sources/CodeEditorCore/Sources/AI/OpenAIClient.swift b/Sources/CodeEditorCore/Sources/AI/OpenAIClient.swift new file mode 100644 index 0000000..b80eb6c --- /dev/null +++ b/Sources/CodeEditorCore/Sources/AI/OpenAIClient.swift @@ -0,0 +1,262 @@ +import Foundation + +/// Lightweight client for OpenAI-compatible APIs (OpenAI, OpenRouter, Ollama, LM Studio, etc.). +/// Handles model discovery and code completions. +public final class OpenAIClient: Sendable { + public static let shared = OpenAIClient() + + private let urlSession: URLSession + + public init(session: URLSession = .shared) { + self.urlSession = session + } + + // MARK: - URL Helpers + + /// Cleans and normalizes an API base URL (e.g. `https://api.openai.com/v1`). + public static func normalizeBaseURL(_ urlString: String) -> URL? { + var trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + trimmed = "https://api.openai.com/v1" + } + if !trimmed.hasPrefix("http://") && !trimmed.hasPrefix("https://") { + trimmed = "https://" + trimmed + } + while trimmed.hasSuffix("/") { + trimmed.removeLast() + } + return URL(string: trimmed) + } + + /// Constructs the models URL from a base URL. + public static func modelsURL(from baseURLString: String) -> URL? { + guard let base = normalizeBaseURL(baseURLString) else { return nil } + if base.path.hasSuffix("/v1") { + return base.appendingPathComponent("models") + } else if base.path.isEmpty || base.path == "/" { + return base.appendingPathComponent("v1/models") + } else { + return base.appendingPathComponent("models") + } + } + + /// Constructs the chat completions URL from a base URL. + public static func chatCompletionsURL(from baseURLString: String) -> URL? { + guard let base = normalizeBaseURL(baseURLString) else { return nil } + if base.path.hasSuffix("/v1") { + return base.appendingPathComponent("chat/completions") + } else if base.path.isEmpty || base.path == "/" { + return base.appendingPathComponent("v1/chat/completions") + } else { + return base.appendingPathComponent("chat/completions") + } + } + + // MARK: - Models Discovery + + /// Fetches the list of available model identifiers from an OpenAI-compatible endpoint. + public func fetchModels(baseURL: String, apiKey: String) async throws -> [String] { + guard let url = Self.modelsURL(from: baseURL) else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 15 + if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + request.setValue( + "Bearer \(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))", forHTTPHeaderField: "Authorization") + } + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await urlSession.data(for: request) + if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { + let errorText = String(data: data, encoding: .utf8) ?? "HTTP \(httpResponse.statusCode)" + throw NSError( + domain: "OpenAIClient", + code: httpResponse.statusCode, + userInfo: [NSLocalizedDescriptionKey: "Failed to fetch models: \(errorText)"] + ) + } + + return try Self.parseModelsJSON(data) + } + + /// Parses models JSON payload supporting both standard OpenAI schema and Ollama native schema. + public static func parseModelsJSON(_ data: Data) throws -> [String] { + // 1. Try standard OpenAI format: { "data": [ { "id": "..." } ] } + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let dataArray = obj["data"] as? [[String: Any]] { + var models: [String] = [] + for item in dataArray { + if let id = item["id"] as? String, !id.isEmpty { + models.append(id) + } + } + if !models.isEmpty { + return models.sorted() + } + } + + // 2. Try Ollama native format: { "models": [ { "name": "..." } ] } + if let modelsArray = obj["models"] as? [[String: Any]] { + var models: [String] = [] + for item in modelsArray { + if let name = item["name"] as? String, !name.isEmpty { + models.append(name) + } else if let model = item["model"] as? String, !model.isEmpty { + models.append(model) + } + } + if !models.isEmpty { + return models.sorted() + } + } + } + + return [] + } + + // MARK: - Code Completion + + /// Requests an autocompletion from the OpenAI-compatible API. + public func complete( + prefix: String, + suffix: String, + language: String, + baseURL: String, + apiKey: String, + model: String + ) async throws -> String { + guard let url = Self.chatCompletionsURL(from: baseURL) else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 10 + if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + request.setValue( + "Bearer \(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))", forHTTPHeaderField: "Authorization") + } + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let prompt = Self.buildPrompt(prefix: prefix, suffix: suffix, language: language) + + let body: [String: Any] = [ + "model": model, + "messages": [ + [ + "role": "system", + "content": + "You are a fast, accurate code completion engine like GitHub Copilot. Continue the code directly from the cursor position. Output ONLY the raw completion code to insert. Do NOT use markdown code blocks, do NOT write explanations, and do NOT repeat the code already before the cursor.", + ], + [ + "role": "user", + "content": prompt, + ], + ], + "temperature": 0.1, + "max_tokens": 128, + "stop": ["\n\n\n", "```"], + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await urlSession.data(for: request) + if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { + let errorText = String(data: data, encoding: .utf8) ?? "HTTP \(httpResponse.statusCode)" + throw NSError( + domain: "OpenAIClient", + code: httpResponse.statusCode, + userInfo: [NSLocalizedDescriptionKey: "AI completion error: \(errorText)"] + ) + } + + let rawCompletion = try Self.parseCompletionResponse(data) + return Self.cleanCompletionText(rawCompletion, prefix: prefix) + } + + public static func buildPrompt(prefix: String, suffix: String, language: String) -> String { + var result = "File language: \(language)\n\n" + result += "Existing code before cursor:\n\(prefix)\n" + if !suffix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + result += "\nExisting code after cursor:\n\(suffix)\n" + } + result += "\nPredict and provide ONLY the immediate next code to insert at cursor:" + return result + } + + public static func parseCompletionResponse(_ data: Data) throws -> String { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return "" + } + + if let choices = json["choices"] as? [[String: Any]], let first = choices.first { + if let message = first["message"] as? [String: Any], + let content = message["content"] as? String + { + return content + } + if let text = first["text"] as? String { + return text + } + } + + if let error = json["error"] as? [String: Any], let message = error["message"] as? String { + throw NSError( + domain: "OpenAIClient", + code: 1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } + + return "" + } + + /// Cleans any markdown artifacts or duplicate prefix overlaps from the completion string. + public static func cleanCompletionText(_ raw: String, prefix: String) -> String { + var text = raw + + // Strip markdown fences ```lang ... ``` if present + if text.hasPrefix("```") { + if let firstNewline = text.firstIndex(of: "\n") { + text = String(text[text.index(after: firstNewline)...]) + } else { + text = text.replacingOccurrences(of: "```", with: "") + } + } + if text.hasSuffix("```") { + text = String(text.dropLast(3)) + } + + // Check exact prefix suffix match first + for len in stride(from: min(50, prefix.count), through: 2, by: -1) { + let tail = String(prefix.suffix(len)) + if text.hasPrefix(tail) { + text = String(text.dropFirst(len)) + return text + } + } + + // Check trimmed prefix suffix match + let trimmedPrefix = prefix.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedPrefix.isEmpty { + for len in stride(from: min(40, trimmedPrefix.count), through: 2, by: -1) { + let tail = String(trimmedPrefix.suffix(len)) + if text.hasPrefix(tail) { + text = String(text.dropFirst(len)) + if prefix.hasSuffix(" ") || prefix.hasSuffix("\t") { + while text.hasPrefix(" ") || text.hasPrefix("\t") { + text.removeFirst() + } + } + break + } + } + } + + return text + } +} diff --git a/Sources/CodeEditorUI/AppState.swift b/Sources/CodeEditorUI/AppState.swift index 1bb6aed..8729177 100644 --- a/Sources/CodeEditorUI/AppState.swift +++ b/Sources/CodeEditorUI/AppState.swift @@ -529,6 +529,34 @@ public final class AppState { } } + // MARK: - AI Completion + + /// Fetches available models from the configured OpenAI-compatible API base URL. + public func fetchAIModels() async { + editorState.aiIsFetchingModels = true + editorState.aiFetchError = nil + do { + let models = try await OpenAIClient.shared.fetchModels( + baseURL: editorState.aiBaseURL, + apiKey: editorState.aiApiKey + ) + editorState.setAIAvailableModels(models) + if !models.isEmpty && !models.contains(editorState.aiModel) { + if let preferred = models.first(where: { + $0.contains("gpt-4o") || $0.contains("coder") || $0.contains("claude") + }) { + editorState.setAIModel(preferred) + } else if let first = models.first { + editorState.setAIModel(first) + } + } + editorState.aiIsFetchingModels = false + } catch { + editorState.aiIsFetchingModels = false + editorState.aiFetchError = (error as NSError).localizedDescription + } + } + // MARK: - Diagnostic navigation /// Navigates the editor to the specified diagnostic, focusing the document if needed. diff --git a/Sources/CodeEditorUI/Sources/Chrome/SettingsView.swift b/Sources/CodeEditorUI/Sources/Chrome/SettingsView.swift index 077b1c4..a4e402f 100644 --- a/Sources/CodeEditorUI/Sources/Chrome/SettingsView.swift +++ b/Sources/CodeEditorUI/Sources/Chrome/SettingsView.swift @@ -9,6 +9,7 @@ import SwiftUI /// this view only presents and binds. public struct SettingsView: View { @Bindable private var appState: AppState + @State private var modelSearchQuery = "" public init(appState: AppState) { self.appState = appState @@ -65,6 +66,97 @@ public struct SettingsView: View { } } + // MARK: Autocompletion + Section("Autocompletion") { + Picker("Engine", selection: completionModeBinding) { + Text("Language Server (LSP)").tag(EditorState.CompletionMode.lsp) + Text("AI (Copilot-style)").tag(EditorState.CompletionMode.ai) + } + .pickerStyle(.segmented) + + if appState.editorState.completionMode == .ai { + VStack(alignment: .leading, spacing: 10) { + Text("OpenAI-Compatible API") + .font(.caption) + .foregroundStyle(.secondary) + + TextField("Base URL (e.g. https://api.openai.com/v1)", text: aiBaseURLBinding) + .textFieldStyle(.roundedBorder) + + SecureField("API Key (leave blank for local models)", text: aiApiKeyBinding) + .textFieldStyle(.roundedBorder) + + HStack(spacing: 12) { + Button { + Task { await appState.fetchAIModels() } + } label: { + HStack(spacing: 4) { + if appState.editorState.aiIsFetchingModels { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + } + Text("Fetch Models") + } + } + .disabled(appState.editorState.aiIsFetchingModels) + + if let error = appState.editorState.aiFetchError { + Text(error) + .font(.caption) + .foregroundStyle(appState.theme.semantic.danger.color) + .lineLimit(2) + } else if !appState.editorState.aiAvailableModels.isEmpty { + Text("\(appState.editorState.aiAvailableModels.count) models available") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if !appState.editorState.aiAvailableModels.isEmpty { + Divider() + + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search model...", text: $modelSearchQuery) + .textFieldStyle(.plain) + } + .padding(4) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(6) + + Menu { + ForEach(filteredModels, id: \.self) { model in + Button { + appState.editorState.setAIModel(model) + } label: { + HStack { + Text(model) + if appState.editorState.aiModel == model { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } label: { + Label( + appState.editorState.aiModel.isEmpty + ? "Select Model" : "Model: \(appState.editorState.aiModel)", + systemImage: "cpu" + ) + } + } else { + TextField("Model Name (e.g. gpt-4o-mini)", text: aiModelBinding) + .textFieldStyle(.roundedBorder) + } + } + .padding(.vertical, 4) + } + } + // MARK: View Section("View") { Toggle("Word Wrap", isOn: wrapBinding) @@ -87,6 +179,44 @@ public struct SettingsView: View { // MARK: - Bindings + private var completionModeBinding: Binding { + Binding( + get: { appState.editorState.completionMode }, + set: { appState.editorState.setCompletionMode($0) } + ) + } + + private var aiBaseURLBinding: Binding { + Binding( + get: { appState.editorState.aiBaseURL }, + set: { appState.editorState.setAIBaseURL($0) } + ) + } + + private var aiApiKeyBinding: Binding { + Binding( + get: { appState.editorState.aiApiKey }, + set: { appState.editorState.setAIApiKey($0) } + ) + } + + private var aiModelBinding: Binding { + Binding( + get: { appState.editorState.aiModel }, + set: { appState.editorState.setAIModel($0) } + ) + } + + private var filteredModels: [String] { + let query = modelSearchQuery.trimmingCharacters(in: .whitespaces) + if query.isEmpty { + return appState.editorState.aiAvailableModels + } + return appState.editorState.aiAvailableModels.filter { + $0.localizedCaseInsensitiveContains(query) + } + } + private var wrapBinding: Binding { Binding( get: { appState.editorState.wrapLines }, diff --git a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift index cd177e9..0546962 100644 --- a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift +++ b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift @@ -116,7 +116,9 @@ public struct CodeEditorView: View { showFoldingRibbon: editorState.showFoldingRibbon, invisibleCharactersConfiguration: invisibles, warningCharacters: Self.warningCharacters, - codeSuggestionTriggerCharacters: [".", ":", "/", "@", "$", "_"] + codeSuggestionTriggerCharacters: editorState.completionMode == .ai + ? [".", ":", "/", "@", "$", "_", "(", "{", "[", " ", "=", ",", ";", ">", "<", "\n"] + : [".", ":", "/", "@", "$", "_"] ) ) } diff --git a/Sources/CodeEditorUI/Sources/Editor/EditorState.swift b/Sources/CodeEditorUI/Sources/Editor/EditorState.swift index f40b999..9c24490 100644 --- a/Sources/CodeEditorUI/Sources/Editor/EditorState.swift +++ b/Sources/CodeEditorUI/Sources/Editor/EditorState.swift @@ -19,6 +19,21 @@ public final class EditorState { public var showInvisibles = false public var formatOnSave = true + // MARK: - Autocompletion Preferences + + public enum CompletionMode: String, CaseIterable, Sendable { + case lsp = "LSP" + case ai = "AI" + } + + public var completionMode: CompletionMode = .lsp + public var aiBaseURL: String = "https://api.openai.com/v1" + public var aiApiKey: String = "" + public var aiModel: String = "gpt-4o-mini" + public var aiAvailableModels: [String] = [] + public var aiIsFetchingModels: Bool = false + public var aiFetchError: String? + // Transient state for the active document public private(set) var selectedLines: Set = [] public private(set) var caretLine = 0 @@ -35,6 +50,11 @@ public final class EditorState { private static let minimapKey = "CodeEditor.showMinimap" private static let foldingKey = "CodeEditor.showFoldingRibbon" private static let invisiblesKey = "CodeEditor.showInvisibles" + private static let completionModeKey = "CodeEditor.completionMode" + private static let aiBaseURLKey = "CodeEditor.aiBaseURL" + private static let aiApiKeyKey = "CodeEditor.aiApiKey" + private static let aiModelKey = "CodeEditor.aiModel" + private static let aiAvailableModelsKey = "CodeEditor.aiAvailableModels" public init() {} @@ -59,6 +79,23 @@ public final class EditorState { showInvisibles = defaults.bool(forKey: Self.invisiblesKey) } indentOption = .spaces(count: tabWidth) + if let modeRaw = defaults.string(forKey: Self.completionModeKey), + let mode = CompletionMode(rawValue: modeRaw) + { + completionMode = mode + } + if let base = defaults.string(forKey: Self.aiBaseURLKey), !base.isEmpty { + aiBaseURL = base + } + if let key = defaults.string(forKey: Self.aiApiKeyKey) { + aiApiKey = key + } + if let model = defaults.string(forKey: Self.aiModelKey), !model.isEmpty { + aiModel = model + } + if let models = defaults.stringArray(forKey: Self.aiAvailableModelsKey) { + aiAvailableModels = models + } } public func setFontSize(_ size: Double, defaults: UserDefaults = .standard) { @@ -92,6 +129,31 @@ public final class EditorState { defaults.set(show, forKey: Self.invisiblesKey) } + public func setCompletionMode(_ mode: CompletionMode, defaults: UserDefaults = .standard) { + completionMode = mode + defaults.set(mode.rawValue, forKey: Self.completionModeKey) + } + + public func setAIBaseURL(_ url: String, defaults: UserDefaults = .standard) { + aiBaseURL = url + defaults.set(url, forKey: Self.aiBaseURLKey) + } + + public func setAIApiKey(_ key: String, defaults: UserDefaults = .standard) { + aiApiKey = key + defaults.set(key, forKey: Self.aiApiKeyKey) + } + + public func setAIModel(_ model: String, defaults: UserDefaults = .standard) { + aiModel = model + defaults.set(model, forKey: Self.aiModelKey) + } + + public func setAIAvailableModels(_ models: [String], defaults: UserDefaults = .standard) { + aiAvailableModels = models + defaults.set(models, forKey: Self.aiAvailableModelsKey) + } + // MARK: - Caret tracking /// Recompute the status bar position from a document selection. diff --git a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift index 01f06b6..1199908 100644 --- a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift +++ b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift @@ -80,6 +80,55 @@ final class LSPCompletionItem: CodeSuggestionEntry { } } +@MainActor +final class AICompletionItem: CodeSuggestionEntry { + let completionText: String + let modelName: String + let isError: Bool + + init(text: String, model: String) { + self.completionText = text + self.modelName = model + self.isError = false + } + + init(error message: String) { + self.completionText = message + self.modelName = "AI" + self.isError = true + } + + // MARK: - CodeSuggestionEntry + + var label: String { + if isError { return completionText } + let firstLine = completionText.components(separatedBy: .newlines).first ?? completionText + return firstLine.trimmingCharacters(in: .whitespaces) + } + + var detail: String? { + isError ? "AI Error" : "AI (\(modelName))" + } + + var documentation: String? { + isError ? nil : completionText + } + + var pathComponents: [String]? { nil } + var targetPosition: CursorPosition? { nil } + var sourcePreview: String? { nil } + + var image: Image { + Image(systemName: isError ? "exclamationmark.triangle.fill" : "sparkles") + } + + var imageColor: Color { + isError ? .red : .purple + } + + var deprecated: Bool { false } +} + // MARK: - Coordinator /// Wires a language server into a `TextViewController` so completions and edits flow to it. @@ -238,7 +287,10 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, // MARK: - CodeSuggestionDelegate func completionTriggerCharacters() -> Set { - [".", ":", "/", "@", "$", "_"] + if appState?.editorState.completionMode == .ai { + return [".", ":", "/", "@", "$", "_", "(", "{", "[", " ", "=", ",", ";", ">", "<", "\n"] + } + return [".", ":", "/", "@", "$", "_"] } /// Fetch completions at the caret, rank them, and present. A stale in-flight request @@ -251,6 +303,14 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, let generation = completionGeneration didChangeTask?.cancel() + if appState?.editorState.completionMode == .ai { + return await requestAICompletion( + textView: textView, + cursorPosition: cursorPosition, + generation: generation + ) + } + guard LanguageServerRegistry.descriptor(for: document.language.identifier) != nil else { return nil } guard let uri = document.url?.absoluteString else { return nil } guard let client = await appState?.languageServer(for: document.language.identifier), @@ -307,6 +367,14 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, textView: TextViewController, cursorPosition: CursorPosition? ) { + if let aiItem = item as? AICompletionItem { + guard !aiItem.isError, let tv = controller?.textView else { return } + let ns = tv.string as NSString + let caret = offset(for: cursorPosition) ?? ns.length + tv.replaceCharacters(in: NSRange(location: caret, length: 0), with: aiItem.completionText) + return + } + guard let entry = item as? LSPCompletionItem, !entry.isError, let completionItem = entry.completionItem, let tv = controller?.textView @@ -331,6 +399,64 @@ final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, tv.replaceCharacters(in: range, with: completionItem.insertionText) } + private func requestAICompletion( + textView: TextViewController, + cursorPosition: CursorPosition, + generation: Int + ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { + guard let appState = appState else { return nil } + let editorState = appState.editorState + let baseURL = editorState.aiBaseURL + let apiKey = editorState.aiApiKey + let model = editorState.aiModel + + guard !model.trimmingCharacters(in: .whitespaces).isEmpty else { + return (cursorPosition, [AICompletionItem(error: "Configure an AI model in Settings.")]) + } + + guard let caret = offset(for: cursorPosition), + let tv = controller?.textView + else { return nil } + + let nsString = tv.string as NSString + let docLength = nsString.length + + // Debounce: allow fast continuous typing to cancel earlier in-flight completions + try? await Task.sleep(nanoseconds: 200_000_000) + guard generation == self.completionGeneration else { return nil } + + let prefixStart = max(0, caret - 3000) + let prefix = nsString.substring(with: NSRange(location: prefixStart, length: caret - prefixStart)) + + let suffixEnd = min(docLength, caret + 1000) + let suffix = nsString.substring(with: NSRange(location: caret, length: suffixEnd - caret)) + + let language = document.language.displayName + + do { + let completion = try await OpenAIClient.shared.complete( + prefix: prefix, + suffix: suffix, + language: language, + baseURL: baseURL, + apiKey: apiKey, + model: model + ) + + guard generation == self.completionGeneration else { return nil } + let trimmed = completion.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let item = AICompletionItem(text: completion, model: model) + let windowPos = CursorPosition(range: NSRange(location: caret, length: 0)) + return (windowPos, [item]) + } catch { + guard generation == self.completionGeneration else { return nil } + let reason = (error as NSError).localizedDescription + return (cursorPosition, [AICompletionItem(error: reason.isEmpty ? "AI completion error" : reason)]) + } + } + func completionWindowDidClose() { lastCompletionItems = nil crashShown = false diff --git a/Tests/CodeEditorTests/OpenAIClientTests.swift b/Tests/CodeEditorTests/OpenAIClientTests.swift new file mode 100644 index 0000000..1dc5760 --- /dev/null +++ b/Tests/CodeEditorTests/OpenAIClientTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing + +@testable import CodeEditorCore + +@Suite("OpenAI Client & Model Discovery") +struct OpenAIClientTests { + + @Test("normalizeBaseURL handles missing schemes, trailing slashes, and defaults") + func testNormalizeBaseURL() { + #expect( + OpenAIClient.normalizeBaseURL("api.openai.com/v1")?.absoluteString == "https://api.openai.com/v1" + ) + #expect( + OpenAIClient.normalizeBaseURL("https://api.openai.com/v1/")?.absoluteString + == "https://api.openai.com/v1" + ) + #expect( + OpenAIClient.normalizeBaseURL("http://localhost:11434")?.absoluteString + == "http://localhost:11434" + ) + #expect( + OpenAIClient.normalizeBaseURL("")?.absoluteString == "https://api.openai.com/v1" + ) + } + + @Test("modelsURL resolves correct path for /v1 and root endpoints") + func testModelsURL() { + let openAI = OpenAIClient.modelsURL(from: "https://api.openai.com/v1") + #expect(openAI?.absoluteString == "https://api.openai.com/v1/models") + + let localhost = OpenAIClient.modelsURL(from: "http://localhost:11434/v1") + #expect(localhost?.absoluteString == "http://localhost:11434/v1/models") + + let root = OpenAIClient.modelsURL(from: "https://api.openai.com") + #expect(root?.absoluteString == "https://api.openai.com/v1/models") + } + + @Test("chatCompletionsURL resolves chat/completions endpoint") + func testChatCompletionsURL() { + let url = OpenAIClient.chatCompletionsURL(from: "https://api.openai.com/v1") + #expect(url?.absoluteString == "https://api.openai.com/v1/chat/completions") + + let lmStudio = OpenAIClient.chatCompletionsURL(from: "http://localhost:1234/v1") + #expect(lmStudio?.absoluteString == "http://localhost:1234/v1/chat/completions") + } + + @Test("parseModelsJSON decodes standard OpenAI model schema") + func testParseStandardModelsJSON() throws { + let json = """ + { + "object": "list", + "data": [ + { "id": "gpt-4o", "object": "model" }, + { "id": "gpt-4o-mini", "object": "model" }, + { "id": "text-embedding-3-small", "object": "model" } + ] + } + """ + let data = try #require(json.data(using: .utf8)) + let models = try OpenAIClient.parseModelsJSON(data) + + #expect(models == ["gpt-4o", "gpt-4o-mini", "text-embedding-3-small"]) + } + + @Test("parseModelsJSON decodes Ollama native model schema") + func testParseOllamaModelsJSON() throws { + let json = """ + { + "models": [ + { "name": "llama3:latest", "model": "llama3:latest" }, + { "name": "codellama:latest", "model": "codellama:latest" } + ] + } + """ + let data = try #require(json.data(using: .utf8)) + let models = try OpenAIClient.parseModelsJSON(data) + + #expect(models == ["codellama:latest", "llama3:latest"]) + } + + @Test("cleanCompletionText removes markdown code blocks and repeated prefixes") + func testCleanCompletionText() { + let fenced = """ + ```swift + func hello() { + print("world") + } + ``` + """ + let cleaned = OpenAIClient.cleanCompletionText(fenced, prefix: "let x = 1\n") + #expect(cleaned.contains("func hello()")) + #expect(!cleaned.contains("```")) + + // Prefix overlap cleanup + let raw = "func greet() -> String { return \"hi\" }" + let prefix = " func " + let deduplicated = OpenAIClient.cleanCompletionText(raw, prefix: prefix) + #expect(deduplicated == "greet() -> String { return \"hi\" }") + } + + @Test("parseCompletionResponse decodes chat completions choice content") + func testParseCompletionResponse() throws { + let json = """ + { + "id": "chatcmpl-123", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "guard let self else { return }" + }, + "finish_reason": "stop" + } + ] + } + """ + let data = try #require(json.data(using: .utf8)) + let text = try OpenAIClient.parseCompletionResponse(data) + #expect(text == "guard let self else { return }") + } +} diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 6526b47..1852a38 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -41,6 +41,7 @@ The window was rebuilt around native macOS chrome. The design tokens live in | **Quick open** | ⌘P over a bounded filesystem index. Matched characters are emphasised. | | **Terminal** | Drag-resized 120–600pt, persisted. Semantic status dot, icon buttons, selectable output. | | **Session** | Workspace root, open tabs and active tab restore on launch. | +| **Autocompletion & AI** | User can choose between Language Server (LSP) and AI completions in Settings. Supports any OpenAI-compatible API (OpenAI, OpenRouter, Ollama, LM Studio), with dynamic model fetching/search chooser, Copilot-style suggestions with sparkles icon, and Tab insertion. | ## Working