diff --git a/CHANGELOG.md b/CHANGELOG.md index fb8722fd8..6fe2c5e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - AI Chat: attach a saved query as a chip via `@`. Type `@` and pick a saved SQL query to send its name and body to the AI alongside your message. - AI Chat: user-defined slash commands. Create your own commands in Settings -> AI -> Custom Slash Commands. Templates support `{{query}}`, `{{schema}}`, `{{database}}`, and `{{body}}` placeholders that get substituted at send time. - AI Chat: tool calling can now run write queries (`execute_query`) and destructive DDL (`confirm_destructive_operation` after the AI passes the verbatim phrase). The connection's safe mode policy still gates execution, so the user remains the final approver. -- AI Chat: inline assistant in the SQL editor. Select text and press Ctrl+Enter to ask the AI to rewrite it. Review the proposed diff and accept or reject. - AI Chat: per-connection rules. Add custom guidance (table conventions, PII columns, naming) in the connection's AI Rules tab; the AI sees it on every chat turn for that connection. ### Changed diff --git a/TablePro/Core/AI/InlineAssistant/AIPromptTemplates+Rewrite.swift b/TablePro/Core/AI/InlineAssistant/AIPromptTemplates+Rewrite.swift deleted file mode 100644 index 0d1c98ff1..000000000 --- a/TablePro/Core/AI/InlineAssistant/AIPromptTemplates+Rewrite.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// AIPromptTemplates+Rewrite.swift -// TablePro -// - -import Foundation - -extension AIPromptTemplates { - static func rewriteSelectionSystemPrompt(language: String, schemaContext: String? = nil) -> String { - var prompt = """ - You rewrite \(language) on demand. The user has selected a snippet and given an instruction. \ - Return ONLY the replacement text. Rules: \ - - Output raw \(language) only. No prose, no markdown fences, no explanation. \ - - The output replaces the selected snippet verbatim. Preserve indentation that matches the surrounding code. \ - - If the instruction asks to leave behavior unchanged but reformat or rename, do that. \ - - If the instruction is unclear, return the snippet unchanged. \ - - Match the dialect of the surrounding query. \ - - Do not wrap the result in quotes or backticks. - """ - if let schemaContext, !schemaContext.isEmpty { - prompt += "\n\n" + schemaContext - } - return prompt - } - - static func rewriteSelection(instruction: String, selection: String, fullQuery: String) -> String { - let cappedFull = capQueryContext(fullQuery) - return """ - Instruction: - \(instruction) - - Selected \(selection.isEmpty ? "(empty)" : "snippet"): - \(selection) - - Surrounding query: - \(cappedFull) - """ - } - - private static func capQueryContext(_ text: String) -> String { - let nsText = text as NSString - let cap = 4_000 - if nsText.length <= cap { return text } - return nsText.substring(from: nsText.length - cap) - } -} diff --git a/TablePro/Core/AI/InlineAssistant/InlineAssistantSession.swift b/TablePro/Core/AI/InlineAssistant/InlineAssistantSession.swift deleted file mode 100644 index 7da38fc38..000000000 --- a/TablePro/Core/AI/InlineAssistant/InlineAssistantSession.swift +++ /dev/null @@ -1,190 +0,0 @@ -// -// InlineAssistantSession.swift -// TablePro -// - -import Foundation -import os - -@Observable -@MainActor -final class InlineAssistantSession { - private static let logger = Logger(subsystem: "com.TablePro", category: "InlineAssistantSession") - - enum Phase: Equatable { - case idle - case streaming - case ready - case failed(message: String) - } - - let originalText: String - let fullQuery: String - let databaseType: DatabaseType? - - private(set) var prompt: String = "" - private(set) var proposedText: String = "" - private(set) var phase: Phase = .idle - - private weak var schemaProvider: SQLSchemaProvider? - private var task: Task? - - init( - originalText: String, - fullQuery: String, - databaseType: DatabaseType?, - schemaProvider: SQLSchemaProvider? - ) { - self.originalText = originalText - self.fullQuery = fullQuery - self.databaseType = databaseType - self.schemaProvider = schemaProvider - } - - var hasResponse: Bool { !proposedText.isEmpty } - - var isStreaming: Bool { - if case .streaming = phase { return true } - return false - } - - var canSubmit: Bool { - !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isStreaming - } - - func updatePrompt(_ value: String) { - prompt = value - } - - func start() { - let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - guard !isStreaming else { return } - - let settings = AppSettingsManager.shared.ai - guard let resolved = AIProviderFactory.resolve(settings: settings) else { - phase = .failed(message: String(localized: "Configure an AI provider in Settings to use the inline assistant.")) - return - } - - proposedText = "" - phase = .streaming - - let language = languageTag() - let original = originalText - let full = fullQuery - let schemaProvider = schemaProvider - - task?.cancel() - task = Task { @MainActor [weak self] in - let systemPrompt = await Self.buildSystemPrompt( - language: language, - settings: settings, - schemaProvider: schemaProvider - ) - let userMessage = AIPromptTemplates.rewriteSelection( - instruction: trimmed, - selection: original, - fullQuery: full - ) - let turns = [ChatTurn(role: .user, blocks: [.text(userMessage)])] - - var accumulated = "" - do { - let stream = resolved.provider.streamChat( - turns: turns, - options: ChatTransportOptions(model: resolved.model, systemPrompt: systemPrompt) - ) - for try await event in stream { - if Task.isCancelled { return } - if case .textDelta(let token) = event { - accumulated += token - guard let self else { return } - self.proposedText = Self.cleanResponse(accumulated) - } - } - } catch { - if Task.isCancelled { return } - guard let self else { return } - Self.logger.error("Inline assistant stream failed: \(error.localizedDescription, privacy: .public)") - self.phase = .failed(message: error.localizedDescription) - return - } - - guard let self, !Task.isCancelled else { return } - let final = Self.cleanResponse(accumulated) - self.proposedText = final - self.phase = final.isEmpty ? .failed(message: String(localized: "The model returned an empty response.")) : .ready - } - } - - func cancel() { - task?.cancel() - task = nil - if isStreaming { - phase = proposedText.isEmpty ? .idle : .ready - } - } - - func teardown() { - task?.cancel() - task = nil - } - - // MARK: - Helpers - - private func languageTag() -> String { - guard let databaseType else { return "SQL" } - return PluginManager.shared.queryLanguageName(for: databaseType) - } - - private static func buildSystemPrompt( - language: String, - settings: AISettings, - schemaProvider: SQLSchemaProvider? - ) async -> String { - guard settings.includeSchema, let provider = schemaProvider else { - return AIPromptTemplates.rewriteSelectionSystemPrompt(language: language) - } - let context = await provider.buildSchemaContextForAI(settings: settings) - if let context, !context.isEmpty { - return AIPromptTemplates.rewriteSelectionSystemPrompt(language: language, schemaContext: context) - } - return AIPromptTemplates.rewriteSelectionSystemPrompt(language: language) - } - - private static let fenceRegex: NSRegularExpression? = try? NSRegularExpression( - pattern: "^\\s*```[a-zA-Z0-9_+-]*\\s*\\n?|\\n?```\\s*$", - options: [] - ) - - private static let thinkingRegex: NSRegularExpression? = try? NSRegularExpression( - pattern: ".*?|.*$", - options: [.caseInsensitive, .dotMatchesLineSeparators] - ) - - private static func cleanResponse(_ raw: String) -> String { - var result = raw - if let regex = thinkingRegex { - result = regex.stringByReplacingMatches( - in: result, - range: NSRange(location: 0, length: (result as NSString).length), - withTemplate: "" - ) - } - if let regex = fenceRegex { - result = regex.stringByReplacingMatches( - in: result, - range: NSRange(location: 0, length: (result as NSString).length), - withTemplate: "" - ) - } - while result.first?.isNewline == true { - result.removeFirst() - } - while result.last?.isWhitespace == true { - result.removeLast() - } - return result - } -} diff --git a/TablePro/Views/Editor/AIEditorContextMenu.swift b/TablePro/Views/Editor/AIEditorContextMenu.swift index 2cc9a689e..c6b97cd86 100644 --- a/TablePro/Views/Editor/AIEditorContextMenu.swift +++ b/TablePro/Views/Editor/AIEditorContextMenu.swift @@ -15,7 +15,6 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { var fullText: (() -> String?)? var onExplainWithAI: ((String) -> Void)? var onOptimizeWithAI: ((String) -> Void)? - var onRewriteWithAI: (() -> Void)? var onSaveAsFavorite: ((String) -> Void)? var onFormatSQL: (() -> Void)? @@ -95,18 +94,6 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { optimizeItem.target = self optimizeItem.image = NSImage(systemSymbolName: "bolt", accessibilityDescription: nil) menu.addItem(optimizeItem) - - if onRewriteWithAI != nil { - let rewriteItem = NSMenuItem( - title: String(localized: "Rewrite with AI..."), - action: #selector(handleRewriteWithAI), - keyEquivalent: String(format: "%c", 0x0D) - ) - rewriteItem.keyEquivalentModifierMask = .control - rewriteItem.target = self - rewriteItem.image = NSImage(systemSymbolName: "wand.and.stars", accessibilityDescription: nil) - menu.addItem(rewriteItem) - } } // MARK: - AI Actions @@ -121,10 +108,6 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { onOptimizeWithAI?(text) } - @objc private func handleRewriteWithAI() { - onRewriteWithAI?() - } - @objc private func handleFormatSQL() { onFormatSQL?() } diff --git a/TablePro/Views/Editor/InlineAssistant/InlineAssistantOverlayController.swift b/TablePro/Views/Editor/InlineAssistant/InlineAssistantOverlayController.swift deleted file mode 100644 index b0c05d687..000000000 --- a/TablePro/Views/Editor/InlineAssistant/InlineAssistantOverlayController.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// InlineAssistantOverlayController.swift -// TablePro -// - -import AppKit -import CodeEditTextView -import SwiftUI - -@MainActor -final class InlineAssistantOverlayController { - private weak var textView: TextView? - private var panel: NSPanel? - private var hostingView: NSHostingView? - private var anchorRange = NSRange(location: 0, length: 0) - private var parentObservers: [NSObjectProtocol] = [] - - private static let topMargin: CGFloat = 6 - private static let panelMargin: CGFloat = 8 - - func present( - view: InlineAssistantPromptView, - anchorRange: NSRange, - in textView: TextView - ) { - dismiss() - self.textView = textView - self.anchorRange = anchorRange - - let hosting = NSHostingView(rootView: view) - hosting.translatesAutoresizingMaskIntoConstraints = false - - let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 60), - styleMask: [.borderless, .nonactivatingPanel, .fullSizeContentView], - backing: .buffered, - defer: false - ) - panel.isFloatingPanel = false - panel.hidesOnDeactivate = false - panel.becomesKeyOnlyIfNeeded = false - panel.isMovableByWindowBackground = false - panel.hasShadow = false - panel.backgroundColor = .clear - panel.isOpaque = false - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - panel.collectionBehavior.insert(.transient) - panel.collectionBehavior.insert(.moveToActiveSpace) - panel.contentView = hosting - - self.panel = panel - self.hostingView = hosting - - guard let parentWindow = textView.window else { return } - parentWindow.addChildWindow(panel, ordered: .above) - repositionPanel() - installParentObservers(parentWindow: parentWindow) - - panel.makeKeyAndOrderFront(nil) - } - - func updateRootView(_ view: InlineAssistantPromptView) { - hostingView?.rootView = view - repositionPanel() - } - - func dismiss() { - if let parent = panel?.parent { - parent.removeChildWindow(panel ?? NSPanel()) - } - panel?.orderOut(nil) - panel = nil - hostingView = nil - textView = nil - for observer in parentObservers { - NotificationCenter.default.removeObserver(observer) - } - parentObservers.removeAll() - } - - deinit { - for observer in parentObservers { - NotificationCenter.default.removeObserver(observer) - } - } - - private func installParentObservers(parentWindow: NSWindow) { - let center = NotificationCenter.default - let queue: OperationQueue = .main - - let resize = center.addObserver( - forName: NSWindow.didResizeNotification, - object: parentWindow, - queue: queue - ) { [weak self] _ in - MainActor.assumeIsolated { self?.repositionPanel() } - } - let move = center.addObserver( - forName: NSWindow.didMoveNotification, - object: parentWindow, - queue: queue - ) { [weak self] _ in - MainActor.assumeIsolated { self?.repositionPanel() } - } - parentObservers.append(contentsOf: [resize, move]) - } - - private func repositionPanel() { - guard let panel, let textView else { return } - guard let rect = anchorScreenRect(in: textView) else { return } - - var contentSize = panel.contentView?.fittingSize ?? NSSize(width: 480, height: 60) - if contentSize.width < 360 { contentSize.width = 360 } - if contentSize.height < 60 { contentSize.height = 60 } - - let parent = textView.window - let screenFrame = parent?.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero - - var origin = NSPoint( - x: rect.minX, - y: rect.maxY + Self.topMargin - ) - - if origin.x + contentSize.width + Self.panelMargin > screenFrame.maxX { - origin.x = max(screenFrame.minX + Self.panelMargin, screenFrame.maxX - contentSize.width - Self.panelMargin) - } - if origin.x < screenFrame.minX + Self.panelMargin { - origin.x = screenFrame.minX + Self.panelMargin - } - if origin.y + contentSize.height + Self.panelMargin > screenFrame.maxY { - origin.y = rect.minY - contentSize.height - Self.topMargin - } - if origin.y < screenFrame.minY + Self.panelMargin { - origin.y = screenFrame.minY + Self.panelMargin - } - - panel.setFrame(NSRect(origin: origin, size: contentSize), display: true) - } - - private func anchorScreenRect(in textView: TextView) -> NSRect? { - let location = anchorRange.location - guard let rectInView = textView.layoutManager.rectForOffset(location) else { return nil } - - let width = max(rectInView.width, 1) - let height = max(rectInView.height, 16) - let viewRect = NSRect(x: rectInView.origin.x, y: rectInView.origin.y, width: width, height: height) - let windowRect = textView.convert(viewRect, to: nil) - return textView.window?.convertToScreen(windowRect) - } -} diff --git a/TablePro/Views/Editor/InlineAssistant/InlineAssistantPresenter.swift b/TablePro/Views/Editor/InlineAssistant/InlineAssistantPresenter.swift deleted file mode 100644 index fa0d74ac7..000000000 --- a/TablePro/Views/Editor/InlineAssistant/InlineAssistantPresenter.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// InlineAssistantPresenter.swift -// TablePro -// - -import AppKit -import CodeEditSourceEditor -import CodeEditTextView -import os - -@MainActor -final class InlineAssistantPresenter { - private static let logger = Logger(subsystem: "com.TablePro", category: "InlineAssistantPresenter") - - private weak var controller: TextViewController? - private var session: InlineAssistantSession? - private var overlay: InlineAssistantOverlayController? - private var anchorRange = NSRange(location: 0, length: 0) - private var schemaProvider: (() -> SQLSchemaProvider?)? - private var databaseTypeResolver: (() -> DatabaseType?)? - - var isActive: Bool { overlay != nil } - - func install( - controller: TextViewController, - schemaProvider: @escaping () -> SQLSchemaProvider?, - databaseType: @escaping () -> DatabaseType? - ) { - self.controller = controller - self.schemaProvider = schemaProvider - self.databaseTypeResolver = databaseType - } - - func uninstall() { - dismiss() - controller = nil - schemaProvider = nil - databaseTypeResolver = nil - } - - func presentForSelection() { - guard !isActive else { return } - guard let controller, let textView = controller.textView else { return } - - let selectedRange = textView.selectedRange() - guard selectedRange.length > 0 else { - NSSound.beep() - return - } - - let nsString = textView.string as NSString - let selectedText = nsString.substring(with: selectedRange) - let fullQuery = textView.string - - let session = InlineAssistantSession( - originalText: selectedText, - fullQuery: fullQuery, - databaseType: databaseTypeResolver?(), - schemaProvider: schemaProvider?() - ) - self.session = session - self.anchorRange = selectedRange - - let overlay = InlineAssistantOverlayController() - self.overlay = overlay - - let view = makeView(for: session) - overlay.present(view: view, anchorRange: selectedRange, in: textView) - } - - func dismiss() { - session?.teardown() - session = nil - overlay?.dismiss() - overlay = nil - } - - private func makeView(for session: InlineAssistantSession) -> InlineAssistantPromptView { - InlineAssistantPromptView( - session: session, - onSubmit: { [weak self] in self?.handleSubmit() }, - onCancel: { [weak self] in self?.handleCancel() }, - onAccept: { [weak self] in self?.handleAccept() }, - onReject: { [weak self] in self?.handleReject() } - ) - } - - private func handleSubmit() { - session?.start() - } - - private func handleCancel() { - if let session, session.isStreaming { - session.cancel() - return - } - dismiss() - } - - private func handleAccept() { - guard let session, session.hasResponse else { return } - guard let textView = controller?.textView else { return } - - let storage = textView.string as NSString - guard anchorRange.upperBound <= storage.length else { - Self.logger.warning("Inline assistant: anchor range out of bounds, aborting accept") - dismiss() - return - } - let replacement = session.proposedText - textView.replaceCharacters(in: anchorRange, with: replacement) - let newRange = NSRange(location: anchorRange.location, length: (replacement as NSString).length) - textView.selectionManager.setSelectedRange(newRange) - dismiss() - } - - private func handleReject() { - dismiss() - } -} diff --git a/TablePro/Views/Editor/InlineAssistant/InlineAssistantPromptView.swift b/TablePro/Views/Editor/InlineAssistant/InlineAssistantPromptView.swift deleted file mode 100644 index c156cd88a..000000000 --- a/TablePro/Views/Editor/InlineAssistant/InlineAssistantPromptView.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// InlineAssistantPromptView.swift -// TablePro -// - -import AppKit -import SwiftUI - -struct InlineAssistantPromptView: View { - var session: InlineAssistantSession - var onSubmit: () -> Void - var onCancel: () -> Void - var onAccept: () -> Void - var onReject: () -> Void - - @FocusState private var promptFieldFocused: Bool - - private static let maxPanelWidth: CGFloat = 560 - private static let maxDiffHeight: CGFloat = 220 - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - promptRow - if session.hasResponse || session.isStreaming || session.phase == .idle { - diffPanel - } - if case .failed(let message) = session.phase { - errorRow(message: message) - } - } - .padding(10) - .frame(maxWidth: Self.maxPanelWidth) - .background(.regularMaterial) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5) - ) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .shadow(color: Color.black.opacity(0.18), radius: 12, x: 0, y: 4) - .onAppear { - promptFieldFocused = true - } - } - - private var promptRow: some View { - HStack(alignment: .center, spacing: 8) { - Image(systemName: "sparkles") - .foregroundStyle(.tint) - TextField( - String(localized: "Tell me how to change this..."), - text: Binding( - get: { session.prompt }, - set: { session.updatePrompt($0) } - ) - ) - .textFieldStyle(.plain) - .font(.system(size: 13)) - .focused($promptFieldFocused) - .disabled(session.isStreaming) - .onSubmit { - if session.canSubmit { onSubmit() } - } - - if session.isStreaming { - ProgressView() - .controlSize(.small) - } - - actionButtons - } - } - - @ViewBuilder - private var actionButtons: some View { - if session.hasResponse, case .ready = session.phase { - Button(String(localized: "Reject"), action: onReject) - .keyboardShortcut(.escape, modifiers: []) - .controlSize(.small) - Button(String(localized: "Accept"), action: onAccept) - .buttonStyle(.borderedProminent) - .keyboardShortcut(.return, modifiers: .command) - .controlSize(.small) - } else if session.isStreaming { - Button(String(localized: "Stop"), action: onCancel) - .controlSize(.small) - } else { - Button(String(localized: "Cancel"), action: onCancel) - .keyboardShortcut(.escape, modifiers: []) - .controlSize(.small) - Button(String(localized: "Generate"), action: onSubmit) - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(!session.canSubmit) - } - } - - private var diffPanel: some View { - ScrollView { - VStack(alignment: .leading, spacing: 6) { - if !session.originalText.isEmpty { - diffBlock(text: session.originalText, role: .removed) - } - if session.hasResponse { - diffBlock(text: session.proposedText, role: .added) - } else if session.isStreaming { - diffBlock(text: String(localized: "Streaming..."), role: .placeholder) - } - } - .padding(8) - } - .frame(maxHeight: Self.maxDiffHeight) - .background(Color(nsColor: .textBackgroundColor).opacity(0.6)) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) - } - - private enum DiffRole { - case removed, added, placeholder - } - - @ViewBuilder - private func diffBlock(text: String, role: DiffRole) -> some View { - let attributed = makeDiffString(text: text, role: role) - Text(attributed) - .font(.system(.body, design: .monospaced)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(6) - .background(diffBackground(for: role)) - .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) - .textSelection(.enabled) - } - - private func diffBackground(for role: DiffRole) -> Color { - switch role { - case .removed: return Color.red.opacity(0.10) - case .added: return Color.green.opacity(0.12) - case .placeholder: return Color.secondary.opacity(0.06) - } - } - - private func makeDiffString(text: String, role: DiffRole) -> AttributedString { - var attr = AttributedString(text) - switch role { - case .removed: - attr.foregroundColor = Color.red - attr.strikethroughStyle = .single - case .added: - attr.foregroundColor = Color.green - case .placeholder: - attr.foregroundColor = Color.secondary - } - return attr - } - - private func errorRow(message: String) -> some View { - Label(message, systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.red) - .padding(.horizontal, 4) - } -} diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 871df9420..3214a2dd0 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -27,8 +27,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { @ObservationIgnored var connectionAIPolicy: AIConnectionPolicy? @ObservationIgnored private var contextMenu: AIEditorContextMenu? @ObservationIgnored private var inlineSuggestionManager: InlineSuggestionManager? - @ObservationIgnored private var inlineAssistantPresenter: InlineAssistantPresenter? - @ObservationIgnored private var inlineAssistantKeyMonitor: Any? @ObservationIgnored private var aiChatInlineSource: AIChatInlineSource? @ObservationIgnored private var copilotDocumentSync: CopilotDocumentSync? @ObservationIgnored private var copilotInlineSource: CopilotInlineSource? @@ -82,9 +80,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { if let observer = windowKeyObserver { NotificationCenter.default.removeObserver(observer) } - if let monitor = inlineAssistantKeyMonitor { - NSEvent.removeMonitor(monitor) - } frameChangeTask?.cancel() } @@ -118,7 +113,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { self.fixFindPanelHitTesting(controller: controller) self.installAIContextMenu(controller: controller) self.installInlineSuggestionManager(controller: controller) - self.installInlineAssistant(controller: controller) self.installVimModeIfEnabled(controller: controller) self.installEditorSettingsObserver(controller: controller) if let textView = controller.textView { @@ -195,12 +189,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { inlineSuggestionManager?.uninstall() inlineSuggestionManager = nil - inlineAssistantPresenter?.uninstall() - inlineAssistantPresenter = nil - if let monitor = inlineAssistantKeyMonitor { - NSEvent.removeMonitor(monitor) - inlineAssistantKeyMonitor = nil - } copilotDocumentSync = nil copilotInlineSource = nil aiChatInlineSource = nil @@ -237,9 +225,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { if inlineSuggestionManager == nil, let controller { installInlineSuggestionManager(controller: controller) } - if inlineAssistantPresenter == nil, let controller { - installInlineAssistant(controller: controller) - } } // MARK: - AI Context Menu @@ -262,7 +247,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { } menu.onExplainWithAI = { [weak self] text in self?.onAIExplain?(text) } menu.onOptimizeWithAI = { [weak self] text in self?.onAIOptimize?(text) } - menu.onRewriteWithAI = { [weak self] in self?.presentInlineAssistant() } menu.onSaveAsFavorite = { [weak self] text in self?.onSaveAsFavorite?(text) } menu.onFormatSQL = { [weak self] in self?.onFormatSQL?() } contextMenu = menu @@ -354,47 +338,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { } } - // MARK: - Inline Assistant - - private func installInlineAssistant(controller: TextViewController) { - let presenter = InlineAssistantPresenter() - presenter.install( - controller: controller, - schemaProvider: { [weak self] in self?.schemaProvider }, - databaseType: { [weak self] in self?.databaseType } - ) - inlineAssistantPresenter = presenter - - if inlineAssistantKeyMonitor != nil { return } - inlineAssistantKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] nsEvent in - nonisolated(unsafe) let event = nsEvent - return MainActor.assumeIsolated { - guard let self else { return event } - guard self.shouldHandleRewriteShortcut(event: event) else { return event } - self.presentInlineAssistant() - return nil - } - } - } - - private func shouldHandleRewriteShortcut(event: NSEvent) -> Bool { - guard let textView = controller?.textView else { return false } - guard event.window === textView.window else { return false } - guard textView.window?.firstResponder === textView else { return false } - - let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard mods == .control else { return false } - guard event.keyCode == 36 || event.keyCode == 76 else { return false } - guard textView.selectedRange().length > 0 else { return false } - guard AppSettingsManager.shared.ai.enabled else { return false } - return true - } - - func presentInlineAssistant() { - guard let presenter = inlineAssistantPresenter else { return } - presenter.presentForSelection() - } - private func teardownInlineSources(except kind: InlineSourceKind) { if kind != .copilot { if let tabID, let sync = copilotDocumentSync { diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 03e6b9df0..9b616b16c 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -187,16 +187,9 @@ Default shortcuts: | Toggle AI Chat | `Cmd+Shift+L` | | Explain with AI | `Cmd+L` | | Optimize with AI | `Cmd+Option+L` | -| Rewrite with AI | `Ctrl+Return` | Customize under **Settings** > **Keyboard** > **AI**. -## Inline Assistant - -Select SQL in the editor and press `Ctrl+Return` (or pick **Rewrite with AI...** from the right-click menu). A prompt strip opens above the selection. Type what you want changed and press Return to generate. The proposed rewrite streams in below the prompt as a diff: the original selection is shown with a strikethrough, the proposed replacement in green. - -Accept the rewrite with `Cmd+Return` to replace the selection in place. Reject with `Esc` to leave the editor untouched. The active AI provider drives the rewrite; configure one under **Settings** > **AI** before using. - ## Ask AI to Fix Query error dialogs include an **Ask AI to Fix** button. It opens chat with the failed query and error pre-filled. diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 25f4b2a13..983dcebae 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -197,7 +197,6 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut |--------|----------|-------------| | Explain with AI | `Cmd+L` | Send current query to AI for explanation | | Optimize with AI | `Cmd+Option+L` | Send current query to AI for optimization | -| Rewrite with AI | `Ctrl+Return` | Open the inline assistant for the selected SQL | ## ER Diagram