diff --git a/CHANGELOG.md b/CHANGELOG.md index 414d09241..3be753532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Cursor as an AI provider: paste a Cursor API key, or sign in with the Cursor CLI (no key), to run AI chat and inline suggestions on your Cursor subscription. +- Cursor as an AI provider: paste a Cursor API key, or sign in with the Cursor CLI (no key), to run AI chat and inline suggestions on your Cursor subscription. (#1624) - Sign in with ChatGPT to power AI chat and inline suggestions from your ChatGPT subscription (Plus, Pro, Business, or Enterprise) without an API key. Existing Codex CLI logins can be imported. (#1617) - libSQL / Turso connections can open a local database file: pick Local File mode in the connection form, browse to the file, and work with it offline, transactions included. (#1607) diff --git a/TablePro/Core/AI/Cursor/CursorAgentCLI.swift b/TablePro/Core/AI/Cursor/CursorAgentCLI.swift index d6ad1e718..382429a62 100644 --- a/TablePro/Core/AI/Cursor/CursorAgentCLI.swift +++ b/TablePro/Core/AI/Cursor/CursorAgentCLI.swift @@ -45,17 +45,22 @@ struct CursorAgentCLI: Sendable { process.standardOutput = stdout process.standardError = stdout Self.logger.debug("Running agent \(arguments.first ?? "", privacy: .public)") - do { - try process.run() - } catch { - throw CursorAgentError.launchFailed(error.localizedDescription) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { proc in + let data = (try? stdout.fileHandleForReading.readToEnd() ?? Data()) ?? Data() + continuation.resume(returning: (proc.terminationStatus, String(data: data, encoding: .utf8) ?? "")) + } + do { + try process.run() + } catch { + process.terminationHandler = nil + continuation.resume(throwing: CursorAgentError.launchFailed(error.localizedDescription)) + } + } + } onCancel: { + if process.isRunning { process.terminate() } } - let data = try await Task.detached { - try stdout.fileHandleForReading.readToEnd() ?? Data() - }.value - process.waitUntilExit() - let output = String(data: data, encoding: .utf8) ?? "" - return (process.terminationStatus, output) } func stream(_ arguments: [String]) -> AsyncThrowingStream { diff --git a/TablePro/Core/AI/Cursor/CursorAgentProvider.swift b/TablePro/Core/AI/Cursor/CursorAgentProvider.swift index 57c3c8d93..c00e3cdc3 100644 --- a/TablePro/Core/AI/Cursor/CursorAgentProvider.swift +++ b/TablePro/Core/AI/Cursor/CursorAgentProvider.swift @@ -31,9 +31,7 @@ final class CursorAgentProvider: ChatTransport { for try await line in lines { if Task.isCancelled { break } guard let json = Self.decodeJSON(line), - json["type"] as? String == "assistant", - json["timestamp_ms"] != nil, - let text = Self.assistantText(json), !text.isEmpty else { continue } + let text = Self.incrementalText(json) else { continue } continuation.yield(.textDelta(text)) } continuation.finish() @@ -71,6 +69,15 @@ final class CursorAgentProvider: ChatTransport { return arguments } + static func incrementalText(_ json: [String: Any]) -> String? { + guard json["type"] as? String == "assistant", + json["timestamp_ms"] != nil, + let text = assistantText(json), !text.isEmpty else { + return nil + } + return text + } + static func assistantText(_ json: [String: Any]) -> String? { guard let message = json["message"] as? [String: Any], let content = message["content"] as? [[String: Any]], diff --git a/TablePro/Core/AI/Cursor/CursorAgentService.swift b/TablePro/Core/AI/Cursor/CursorAgentService.swift index 8b87bbea8..066a60a1e 100644 --- a/TablePro/Core/AI/Cursor/CursorAgentService.swift +++ b/TablePro/Core/AI/Cursor/CursorAgentService.swift @@ -28,6 +28,7 @@ final class CursorAgentService { private(set) var errorMessage: String? @ObservationIgnored private let cli: CursorAgentCLI + @ObservationIgnored private var signInTask: Task? init(cli: CursorAgentCLI = CursorAgentCLI()) { self.cli = cli @@ -48,25 +49,36 @@ final class CursorAgentService { } } - func signIn() async { + func signIn() { guard cli.isInstalled else { authState = .notInstalled return } + guard signInTask == nil else { return } errorMessage = nil authState = .signingIn - do { - let result = try await cli.run(["login"]) - if result.code != 0 { - errorMessage = result.output.isEmpty - ? String(localized: "Cursor sign-in failed.") - : result.output + signInTask = Task { + do { + let result = try await cli.run(["login"]) + if !Task.isCancelled, result.code != 0 { + errorMessage = result.output.isEmpty + ? String(localized: "Cursor sign-in failed.") + : result.output + } + } catch { + Self.logger.error("Cursor CLI sign-in failed: \(error.localizedDescription, privacy: .public)") + if !Task.isCancelled { + errorMessage = error.localizedDescription + } } - } catch { - Self.logger.error("Cursor CLI sign-in failed: \(error.localizedDescription, privacy: .public)") - errorMessage = error.localizedDescription + signInTask = nil + await refreshStatus() } - await refreshStatus() + } + + func cancelSignIn() { + signInTask?.cancel() + signInTask = nil } func signOut() async { diff --git a/TablePro/Core/AI/Cursor/CursorProvider.swift b/TablePro/Core/AI/Cursor/CursorProvider.swift index f48d35c20..7de6a1ced 100644 --- a/TablePro/Core/AI/Cursor/CursorProvider.swift +++ b/TablePro/Core/AI/Cursor/CursorProvider.swift @@ -37,27 +37,15 @@ final class CursorProvider: ChatTransport { throw AIProviderError.mapHTTPError(statusCode: httpResponse.statusCode, body: body) } - var event = "" - var emittedText = false + var parser = StreamParser() events: for try await line in bytes.lines { if Task.isCancelled { break } - if line.isEmpty { event = ""; continue } - if let name = Self.sseField(line, "event") { event = name; continue } - guard let payload = Self.sseField(line, "data"), - let json = Self.decodeJSON(payload) else { continue } - switch event { - case "assistant": - if let text = json["text"] as? String, !text.isEmpty { - emittedText = true - continuation.yield(.textDelta(text)) - } - case "result": - if !emittedText, let text = json["text"] as? String, !text.isEmpty { - continuation.yield(.textDelta(text)) - } - case "done": + switch parser.consume(line) { + case .text(let text): + continuation.yield(.textDelta(text)) + case .done: break events - default: + case nil: continue } } @@ -92,7 +80,9 @@ final class CursorProvider: ChatTransport { else { throw AIProviderError.networkError("Failed to fetch models") } - return items.compactMap { $0["id"] as? String }.sorted() + let fetched = items.compactMap { $0["id"] as? String } + let curated = Set(CursorAI.curatedModelIDs) + return CursorAI.curatedModelIDs + fetched.filter { !curated.contains($0) }.sorted() } func testConnection() async throws -> Bool { @@ -209,4 +199,42 @@ final class CursorProvider: ChatTransport { guard let data = payload.data(using: .utf8) else { return nil } return try? JSONSerialization.jsonObject(with: data) as? [String: Any] } + + struct StreamParser { + enum Output: Equatable { + case text(String) + case done + } + + private var event = "" + private var emittedText = false + + mutating func consume(_ line: String) -> Output? { + if line.isEmpty { + event = "" + return nil + } + if let name = CursorProvider.sseField(line, "event") { + event = name + return nil + } + guard let payload = CursorProvider.sseField(line, "data"), + let json = CursorProvider.decodeJSON(payload) else { + return nil + } + switch event { + case "assistant": + guard let text = json["text"] as? String, !text.isEmpty else { return nil } + emittedText = true + return .text(text) + case "result": + guard !emittedText, let text = json["text"] as? String, !text.isEmpty else { return nil } + return .text(text) + case "done": + return .done + default: + return nil + } + } + } } diff --git a/TablePro/Views/Settings/AIProviderDetailSheet.swift b/TablePro/Views/Settings/AIProviderDetailSheet.swift index 21f9a8eca..3cd97495f 100644 --- a/TablePro/Views/Settings/AIProviderDetailSheet.swift +++ b/TablePro/Views/Settings/AIProviderDetailSheet.swift @@ -298,7 +298,7 @@ struct AIProviderDetailSheet: View { .foregroundStyle(.secondary) Spacer() Button(String(localized: "Sign in with Cursor")) { - Task { await cursorAgentService.signIn() } + cursorAgentService.signIn() } .buttonStyle(.borderedProminent) } @@ -309,6 +309,11 @@ struct AIProviderDetailSheet: View { Text("Opening your browser to sign in…") .font(.caption) .foregroundStyle(.secondary) + Spacer() + Button(String(localized: "Cancel")) { + cursorAgentService.cancelSignIn() + } + .controlSize(.small) } case .signedIn(let account): diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index aad96a40f..19c85c7d0 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -15,6 +15,7 @@ struct AISettingsView: View { @State private var addingProviderType: AIProviderType? @State private var pendingDeleteID: UUID? @State private var chatGPTCodexService = ChatGPTCodexService.shared + @State private var cursorAgentService = CursorAgentService.shared @State private var providersWithKey: Set = [] var body: some View { @@ -32,6 +33,7 @@ struct AISettingsView: View { .formStyle(.grouped) .task { refreshKeyAvailability() } .task { await chatGPTCodexService.refreshAuthState() } + .task { await cursorAgentService.refreshStatus() } .onChange(of: settings.providers.map(\.id)) { refreshKeyAvailability() } @@ -356,7 +358,7 @@ struct AISettingsView: View { if providersWithKey.contains(provider.id) { return String(localized: "API key set") } - if CursorAgentService.shared.authState.isSignedIn { + if cursorAgentService.authState.isSignedIn { return String(localized: "Signed in with Cursor") } return String(localized: "Not configured") diff --git a/TableProTests/Core/AI/CursorAgentProviderTests.swift b/TableProTests/Core/AI/CursorAgentProviderTests.swift index 2e8a5503b..c5b882c23 100644 --- a/TableProTests/Core/AI/CursorAgentProviderTests.swift +++ b/TableProTests/Core/AI/CursorAgentProviderTests.swift @@ -57,4 +57,29 @@ struct CursorAgentProviderTests { let result: [String: Any] = ["type": "result", "duration_ms": 1_234] #expect(CursorAgentProvider.assistantText(result) == nil) } + + @Test("An assistant event with a timestamp yields incremental text") + func incrementalTextFromTimestampedDelta() { + let event: [String: Any] = [ + "type": "assistant", + "timestamp_ms": 1_234_567, + "message": ["content": [["text": "SELECT 1"]]] + ] + #expect(CursorAgentProvider.incrementalText(event) == "SELECT 1") + } + + @Test("The consolidated final assistant message (no timestamp) is skipped to avoid duplication") + func incrementalTextSkipsConsolidatedFinalMessage() { + let finalMessage: [String: Any] = [ + "type": "assistant", + "message": ["content": [["text": "SELECT 1"]]] + ] + #expect(CursorAgentProvider.incrementalText(finalMessage) == nil) + } + + @Test("Non-assistant events yield no incremental text") + func incrementalTextSkipsNonAssistant() { + let result: [String: Any] = ["type": "result", "timestamp_ms": 1, "duration_ms": 1_234] + #expect(CursorAgentProvider.incrementalText(result) == nil) + } } diff --git a/TableProTests/Core/AI/CursorProviderStreamParserTests.swift b/TableProTests/Core/AI/CursorProviderStreamParserTests.swift new file mode 100644 index 000000000..93b393012 --- /dev/null +++ b/TableProTests/Core/AI/CursorProviderStreamParserTests.swift @@ -0,0 +1,62 @@ +// +// CursorProviderStreamParserTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("CursorProvider SSE stream parsing") +struct CursorProviderStreamParserTests { + private func deltas(from lines: [String]) -> [CursorProvider.StreamParser.Output] { + var parser = CursorProvider.StreamParser() + return lines.compactMap { parser.consume($0) } + } + + @Test("Assistant deltas are emitted in order, the consolidated result is skipped") + func assistantDeltasEmittedResultSkipped() { + let outputs = deltas(from: [ + "event: assistant", + "data: {\"text\":\"SELECT \"}", + "", + "event: assistant", + "data: {\"text\":\"1\"}", + "", + "event: result", + "data: {\"text\":\"SELECT 1\"}", + "", + "event: done", + "data: {}" + ]) + #expect(outputs == [.text("SELECT "), .text("1"), .done]) + } + + @Test("When no assistant delta arrives, the result text is used as a fallback") + func resultTextUsedWhenNoAssistantDelta() { + let outputs = deltas(from: [ + "event: result", + "data: {\"text\":\"SELECT 1\"}", + "", + "event: done", + "data: {}" + ]) + #expect(outputs == [.text("SELECT 1"), .done]) + } + + @Test("Event and blank lines produce no output") + func eventAndBlankLinesProduceNothing() { + var parser = CursorProvider.StreamParser() + #expect(parser.consume("event: assistant") == nil) + #expect(parser.consume("") == nil) + } + + @Test("Empty assistant text is not emitted") + func emptyAssistantTextSkipped() { + let outputs = deltas(from: [ + "event: assistant", + "data: {\"text\":\"\"}" + ]) + #expect(outputs.isEmpty) + } +}