diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe2c5e88..fb8722fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ 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 new file mode 100644 index 000000000..0d1c98ff1 --- /dev/null +++ b/TablePro/Core/AI/InlineAssistant/AIPromptTemplates+Rewrite.swift @@ -0,0 +1,46 @@ +// +// 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 new file mode 100644 index 000000000..7e9971b49 --- /dev/null +++ b/TablePro/Core/AI/InlineAssistant/InlineAssistantSession.swift @@ -0,0 +1,194 @@ +// +// 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 + } + + deinit { + task?.cancel() + } + + // 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 c6b97cd86..2cc9a689e 100644 --- a/TablePro/Views/Editor/AIEditorContextMenu.swift +++ b/TablePro/Views/Editor/AIEditorContextMenu.swift @@ -15,6 +15,7 @@ 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)? @@ -94,6 +95,18 @@ 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 @@ -108,6 +121,10 @@ 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 new file mode 100644 index 000000000..b0c05d687 --- /dev/null +++ b/TablePro/Views/Editor/InlineAssistant/InlineAssistantOverlayController.swift @@ -0,0 +1,151 @@ +// +// 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 new file mode 100644 index 000000000..5f1c9a6f5 --- /dev/null +++ b/TablePro/Views/Editor/InlineAssistant/InlineAssistantPresenter.swift @@ -0,0 +1,120 @@ +// +// 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.setSelectedRange(newRange) + dismiss() + } + + private func handleReject() { + dismiss() + } +} diff --git a/TablePro/Views/Editor/InlineAssistant/InlineAssistantPromptView.swift b/TablePro/Views/Editor/InlineAssistant/InlineAssistantPromptView.swift new file mode 100644 index 000000000..c156cd88a --- /dev/null +++ b/TablePro/Views/Editor/InlineAssistant/InlineAssistantPromptView.swift @@ -0,0 +1,160 @@ +// +// 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 3214a2dd0..871df9420 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -27,6 +27,8 @@ 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? @@ -80,6 +82,9 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { if let observer = windowKeyObserver { NotificationCenter.default.removeObserver(observer) } + if let monitor = inlineAssistantKeyMonitor { + NSEvent.removeMonitor(monitor) + } frameChangeTask?.cancel() } @@ -113,6 +118,7 @@ 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 { @@ -189,6 +195,12 @@ 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 @@ -225,6 +237,9 @@ 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 @@ -247,6 +262,7 @@ 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 @@ -338,6 +354,47 @@ 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 9b616b16c..03e6b9df0 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -187,9 +187,16 @@ 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 983dcebae..25f4b2a13 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -197,6 +197,7 @@ 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