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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
25 changes: 15 additions & 10 deletions TablePro/Core/AI/Cursor/CursorAgentCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Drain CLI output before waiting for termination

When agent status or agent login writes more than the OS pipe buffer to stdout/stderr, this waits to read the merged pipe until terminationHandler runs. The child can block while writing to the full pipe and therefore never terminate, so the handler never fires and status/sign-in await forever; the previous implementation drained the pipe concurrently while the process was still running.

Useful? React with 👍 / 👎.

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<String, Error> {
Expand Down
13 changes: 10 additions & 3 deletions TablePro/Core/AI/Cursor/CursorAgentProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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]],
Expand Down
34 changes: 23 additions & 11 deletions TablePro/Core/AI/Cursor/CursorAgentService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ final class CursorAgentService {
private(set) var errorMessage: String?

@ObservationIgnored private let cli: CursorAgentCLI
@ObservationIgnored private var signInTask: Task<Void, Never>?

init(cli: CursorAgentCLI = CursorAgentCLI()) {
self.cli = cli
Expand All @@ -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 {
Expand Down
66 changes: 47 additions & 19 deletions TablePro/Core/AI/Cursor/CursorProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
}
}
7 changes: 6 additions & 1 deletion TablePro/Views/Settings/AIProviderDetailSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion TablePro/Views/Settings/AISettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID> = []

var body: some View {
Expand All @@ -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()
}
Expand Down Expand Up @@ -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")
Expand Down
25 changes: 25 additions & 0 deletions TableProTests/Core/AI/CursorAgentProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
62 changes: 62 additions & 0 deletions TableProTests/Core/AI/CursorProviderStreamParserTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading