From 567fa19883f029acb4a56f6f33ffbba2e673778d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 7 May 2026 14:15:49 +0700 Subject: [PATCH 1/2] feat: phase 5 (saved-query chips and user-defined slash commands) --- CHANGELOG.md | 2 + .../Core/AI/Chat/ContextItem+Display.swift | 6 +- TablePro/Core/AI/Chat/ContextItem.swift | 9 +- .../AI/Chat/CustomSlashCommandRenderer.swift | 33 ++++ .../Storage/CustomSlashCommandStorage.swift | 66 +++++++ TablePro/Models/AI/CustomSlashCommand.swift | 46 +++++ TablePro/ViewModels/AIChatViewModel.swift | 60 ++++++- TablePro/Views/AIChat/AIChatPanelView.swift | 36 +++- TablePro/Views/Settings/AISettingsView.swift | 1 + .../Settings/CustomSlashCommandsSection.swift | 167 ++++++++++++++++++ 10 files changed, 416 insertions(+), 10 deletions(-) create mode 100644 TablePro/Core/AI/Chat/CustomSlashCommandRenderer.swift create mode 100644 TablePro/Core/Storage/CustomSlashCommandStorage.swift create mode 100644 TablePro/Models/AI/CustomSlashCommand.swift create mode 100644 TablePro/Views/Settings/CustomSlashCommandsSection.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e0544b33..ee2e8bc19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Read-only tools: `list_connections`, `get_connection_status`, `list_databases`, `list_schemas`, `list_tables`, `describe_table`, `get_table_ddl`. - Tool calls and their results render as expandable pills in the assistant's reply. - Supported providers: Anthropic, OpenAI, OpenRouter, Gemini, Ollama (model-dependent), and custom OpenAI-compatible endpoints. GitHub Copilot is not yet supported. +- 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. ### Changed diff --git a/TablePro/Core/AI/Chat/ContextItem+Display.swift b/TablePro/Core/AI/Chat/ContextItem+Display.swift index 4504c4b11..b5290273b 100644 --- a/TablePro/Core/AI/Chat/ContextItem+Display.swift +++ b/TablePro/Core/AI/Chat/ContextItem+Display.swift @@ -16,8 +16,8 @@ extension ContextItem { return String(localized: "Current Query") case .queryResult: return String(localized: "Query Results") - case .savedQuery: - return String(localized: "Saved Query") + case .savedQuery(_, let name): + return name.isEmpty ? String(localized: "Saved Query") : name case .file(let url): return url.lastPathComponent } @@ -50,7 +50,7 @@ extension ContextItem { return "currentQuery" case .queryResult: return "queryResult" - case .savedQuery(let id): + case .savedQuery(let id, _): return "savedQuery:\(id.uuidString)" case .file(let url): return "file:\(url.absoluteString)" diff --git a/TablePro/Core/AI/Chat/ContextItem.swift b/TablePro/Core/AI/Chat/ContextItem.swift index 6f248aaaa..f101c95c6 100644 --- a/TablePro/Core/AI/Chat/ContextItem.swift +++ b/TablePro/Core/AI/Chat/ContextItem.swift @@ -10,7 +10,7 @@ enum ContextItem: Codable, Equatable, Sendable { case table(connectionId: UUID, name: String) case currentQuery(text: String) case queryResult(summary: String) - case savedQuery(id: UUID) + case savedQuery(id: UUID, name: String) case file(url: URL) private enum CodingKeys: String, CodingKey { @@ -37,7 +37,9 @@ enum ContextItem: Codable, Equatable, Sendable { case .queryResult: self = .queryResult(summary: try container.decode(String.self, forKey: .summary)) case .savedQuery: - self = .savedQuery(id: try container.decode(UUID.self, forKey: .id)) + let id = try container.decode(UUID.self, forKey: .id) + let name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + self = .savedQuery(id: id, name: name) case .file: self = .file(url: try container.decode(URL.self, forKey: .url)) } @@ -59,9 +61,10 @@ enum ContextItem: Codable, Equatable, Sendable { case .queryResult(let summary): try container.encode(Kind.queryResult, forKey: .kind) try container.encode(summary, forKey: .summary) - case .savedQuery(let id): + case .savedQuery(let id, let name): try container.encode(Kind.savedQuery, forKey: .kind) try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) case .file(let url): try container.encode(Kind.file, forKey: .kind) try container.encode(url, forKey: .url) diff --git a/TablePro/Core/AI/Chat/CustomSlashCommandRenderer.swift b/TablePro/Core/AI/Chat/CustomSlashCommandRenderer.swift new file mode 100644 index 000000000..47f32e4cd --- /dev/null +++ b/TablePro/Core/AI/Chat/CustomSlashCommandRenderer.swift @@ -0,0 +1,33 @@ +// +// CustomSlashCommandRenderer.swift +// TablePro +// + +import Foundation + +/// Renders a `CustomSlashCommand` template into a final prompt by substituting +/// `{{query}}`, `{{schema}}`, `{{database}}`, and `{{body}}` placeholders with +/// the current chat context. Unknown placeholders pass through unchanged so +/// users can leave them visible if they want literal braces. +enum CustomSlashCommandRenderer { + struct Context { + let query: String? + let schema: String? + let database: String? + let body: String + } + + static func render(_ command: CustomSlashCommand, context: Context) -> String { + var output = command.promptTemplate + let substitutions: [(CustomSlashCommandVariable, String)] = [ + (.query, context.query ?? ""), + (.schema, context.schema ?? ""), + (.database, context.database ?? ""), + (.body, context.body) + ] + for (variable, value) in substitutions { + output = output.replacingOccurrences(of: variable.placeholder, with: value) + } + return output + } +} diff --git a/TablePro/Core/Storage/CustomSlashCommandStorage.swift b/TablePro/Core/Storage/CustomSlashCommandStorage.swift new file mode 100644 index 000000000..3c02ce8d5 --- /dev/null +++ b/TablePro/Core/Storage/CustomSlashCommandStorage.swift @@ -0,0 +1,66 @@ +// +// CustomSlashCommandStorage.swift +// TablePro +// + +import Foundation +import Observation +import os + +/// UserDefaults-backed store for `CustomSlashCommand`s. Observable so the +/// chat composer's slash menu and the Settings list rerender on edits. +@MainActor +@Observable +final class CustomSlashCommandStorage { + static let shared = CustomSlashCommandStorage() + + private static let logger = Logger(subsystem: "com.TablePro", category: "CustomSlashCommandStorage") + private static let defaultsKey = "ai.customSlashCommands.v1" + private let defaults: UserDefaults + + private(set) var commands: [CustomSlashCommand] = [] + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.commands = Self.load(from: defaults) + } + + func add(_ command: CustomSlashCommand) { + commands.append(command) + persist() + } + + func update(_ command: CustomSlashCommand) { + guard let idx = commands.firstIndex(where: { $0.id == command.id }) else { return } + commands[idx] = command + persist() + } + + func delete(id: UUID) { + commands.removeAll { $0.id == id } + persist() + } + + func command(named name: String) -> CustomSlashCommand? { + commands.first { $0.name.caseInsensitiveCompare(name) == .orderedSame } + } + + private func persist() { + do { + let data = try JSONEncoder().encode(commands) + defaults.set(data, forKey: Self.defaultsKey) + } catch { + Self.logger.warning("Failed to persist custom slash commands: \(error.localizedDescription, privacy: .public)") + } + } + + private static func load(from defaults: UserDefaults) -> [CustomSlashCommand] { + guard let data = defaults.data(forKey: Self.defaultsKey) else { return [] } + do { + return try JSONDecoder().decode([CustomSlashCommand].self, from: data) + } catch { + Self.logger.warning("Failed to load custom slash commands: \(error.localizedDescription, privacy: .public)") + return [] + } + } +} diff --git a/TablePro/Models/AI/CustomSlashCommand.swift b/TablePro/Models/AI/CustomSlashCommand.swift new file mode 100644 index 000000000..209bf870e --- /dev/null +++ b/TablePro/Models/AI/CustomSlashCommand.swift @@ -0,0 +1,46 @@ +// +// CustomSlashCommand.swift +// TablePro +// + +import Foundation + +/// A user-defined slash command for the AI chat. Users author these in +/// Settings -> AI -> Custom Commands. Templates support variables that get +/// substituted at execution time: `{{query}}` (current editor query), +/// `{{schema}}` (the formatted schema for the active connection), +/// `{{database}}` (active database name), `{{body}}` (text typed after the +/// command in the composer). +struct CustomSlashCommand: Codable, Equatable, Identifiable, Sendable { + let id: UUID + var name: String + var description: String + var promptTemplate: String + + init( + id: UUID = UUID(), + name: String = "", + description: String = "", + promptTemplate: String = "" + ) { + self.id = id + self.name = name + self.description = description + self.promptTemplate = promptTemplate + } + + /// Whether the command has the minimum fields populated to run. + var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty + && !promptTemplate.trimmingCharacters(in: .whitespaces).isEmpty + } +} + +enum CustomSlashCommandVariable: String, CaseIterable { + case query + case schema + case database + case body + + var placeholder: String { "{{\(rawValue)}}" } +} diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index ce9646286..2b2e211ba 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -149,6 +149,22 @@ final class AIChatViewModel { } } + func runCustomSlashCommand(_ command: CustomSlashCommand, body: String = "") { + guard command.isValid else { return } + inputText = "" + errorMessage = nil + let invocationText = body.isEmpty ? "/\(command.name)" : "/\(command.name) \(body)" + let renderingContext = CustomSlashCommandRenderer.Context( + query: currentQuery, + schema: nil, + database: connection.flatMap { DatabaseManager.shared.activeDatabaseName(for: $0) }, + body: body + ) + let prompt = CustomSlashCommandRenderer.render(command, context: renderingContext) + messages.append(ChatTurn(role: .user, blocks: [.text(invocationText)])) + sendWithContext(prompt: prompt) + } + private func resolveQuery(body: String, command: SlashCommand) -> String? { if !body.isEmpty { return body @@ -268,11 +284,40 @@ final class AIChatViewModel { await ensureSchemaLoaded() case .table(_, let name): await ensureColumnsLoaded(forTable: name) - case .currentQuery, .queryResult, .savedQuery, .file: + case .savedQuery(let id, _): + await ensureSavedQueryLoaded(id: id) + case .currentQuery, .queryResult, .file: break } } + /// Loaded `SQLFavorite` instances keyed by id, populated when saved-query + /// chips are attached so `resolveSavedQueryAttachment` can serialize them. + @ObservationIgnored private var cachedSavedQueries: [UUID: SQLFavorite] = [:] + + /// Saved queries available as `@`-mention candidates for the active connection. + /// Refreshed on connection change via `loadSavedQueries()`. + var savedQueries: [SQLFavorite] = [] + + func loadSavedQueries() async { + guard let connectionId = connection?.id else { + savedQueries = [] + return + } + let favorites = await SQLFavoriteManager.shared.fetchFavorites(connectionId: connectionId) + savedQueries = favorites + for favorite in favorites { + cachedSavedQueries[favorite.id] = favorite + } + } + + private func ensureSavedQueryLoaded(id: UUID) async { + if cachedSavedQueries[id] != nil { return } + if let favorite = await SQLFavoriteManager.shared.fetchFavorite(id: id) { + cachedSavedQueries[id] = favorite + } + } + /// Ensure column + foreign-key data for `tableName` is in `columnsByTable`. /// Idempotent and dedups concurrent calls so chip attach + send-time resolve /// share a single fetch. @@ -422,11 +467,22 @@ final class AIChatViewModel { let snapshot = summary.isEmpty ? (queryResults ?? "") : summary guard !snapshot.isEmpty else { return nil } return "## Query Results\n\(snapshot)" - case .savedQuery, .file: + case .savedQuery(let id, let name): + return resolveSavedQueryAttachment(id: id, fallbackName: name) + case .file: return nil } } + private func resolveSavedQueryAttachment(id: UUID, fallbackName: String) -> String? { + guard let favorite = cachedSavedQueries[id] else { return nil } + let displayName = favorite.name.isEmpty ? fallbackName : favorite.name + let header = displayName.isEmpty + ? String(localized: "Saved Query") + : "\(String(localized: "Saved Query")): \(displayName)" + return "## \(header)\n```sql\n\(favorite.query)\n```" + } + private func resolveSchemaAttachment() -> String? { guard !tables.isEmpty else { return nil } let settings = AppSettingsManager.shared.ai diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index b8ad796fb..fb1bab141 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -53,6 +53,9 @@ struct AIChatPanelView: View { .task(id: settingsManager.ai.providers.map(\.id)) { await viewModel.loadAvailableModels() } + .task(id: connection.id) { + await viewModel.loadSavedQueries() + } .alert( String(localized: "Allow AI Access"), isPresented: $viewModel.showAIAccessConfirmation @@ -443,7 +446,8 @@ struct AIChatPanelView: View { } private var slashCommandMenu: some View { - Menu { + let customCommands = CustomSlashCommandStorage.shared.commands.filter(\.isValid) + return Menu { ForEach(SlashCommand.allCommands) { command in Button { updateContext() @@ -452,6 +456,23 @@ struct AIChatPanelView: View { Text("/\(command.name) · \(command.description)") } } + if !customCommands.isEmpty { + Divider() + Section(String(localized: "Custom")) { + ForEach(customCommands) { command in + Button { + updateContext() + viewModel.runCustomSlashCommand(command) + } label: { + if command.description.isEmpty { + Text("/\(command.name)") + } else { + Text("/\(command.name) · \(command.description)") + } + } + } + } + } } label: { Image(systemName: "command") .font(.caption) @@ -590,7 +611,7 @@ struct AIChatPanelView: View { } } - let tableBudget = max(0, Self.maxMentionCandidates - items.count) + let tableBudget = max(0, (Self.maxMentionCandidates / 2) - items.count) let matchingTables = viewModel.tables .filter { query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) } .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } @@ -601,6 +622,17 @@ struct AIChatPanelView: View { )) } + let savedBudget = max(0, Self.maxMentionCandidates - items.count) + let matchingSavedQueries = viewModel.savedQueries + .filter { query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + .prefix(savedBudget) + for favorite in matchingSavedQueries { + items.append(MentionCandidate( + item: .savedQuery(id: favorite.id, name: favorite.name) + )) + } + return items } diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 1b84153dd..cce281cb4 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -25,6 +25,7 @@ struct AISettingsView: View { providersSection inlineSuggestionsSection contextSection + CustomSlashCommandsSection(storage: CustomSlashCommandStorage.shared) privacySection } } diff --git a/TablePro/Views/Settings/CustomSlashCommandsSection.swift b/TablePro/Views/Settings/CustomSlashCommandsSection.swift new file mode 100644 index 000000000..0cf68aeb6 --- /dev/null +++ b/TablePro/Views/Settings/CustomSlashCommandsSection.swift @@ -0,0 +1,167 @@ +// +// CustomSlashCommandsSection.swift +// TablePro +// + +import SwiftUI + +struct CustomSlashCommandsSection: View { + @Bindable var storage: CustomSlashCommandStorage + @State private var editing: CustomSlashCommand? + @State private var isCreating = false + + var body: some View { + Section { + if storage.commands.isEmpty { + emptyState + } else { + ForEach(storage.commands) { command in + row(for: command) + } + } + HStack { + Spacer() + Button { + editing = CustomSlashCommand() + isCreating = true + } label: { + Label(String(localized: "Add Command"), systemImage: "plus") + } + .controlSize(.small) + } + } header: { + Text(String(localized: "Custom Slash Commands")) + } footer: { + Text(String( + localized: "Create your own slash commands. Use {{query}}, {{schema}}, {{database}}, or {{body}} in the template to insert chat context at runtime." + )) + .font(.caption) + .foregroundStyle(.secondary) + } + .sheet(item: $editing) { command in + CustomSlashCommandEditorSheet( + initial: command, + isCreating: isCreating, + onSave: { updated in + if isCreating { + storage.add(updated) + } else { + storage.update(updated) + } + editing = nil + isCreating = false + }, + onCancel: { + editing = nil + isCreating = false + } + ) + } + } + + @ViewBuilder + private var emptyState: some View { + Text(String(localized: "No custom commands yet.")) + .font(.caption) + .foregroundStyle(.secondary) + } + + @ViewBuilder + private func row(for command: CustomSlashCommand) -> some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("/\(command.name)") + .font(.body) + if !command.description.isEmpty { + Text(command.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + Button(String(localized: "Edit")) { + editing = command + isCreating = false + } + .controlSize(.small) + Button(role: .destructive) { + storage.delete(id: command.id) + } label: { + Image(systemName: "trash") + } + .controlSize(.small) + } + } +} + +struct CustomSlashCommandEditorSheet: View { + @State var draft: CustomSlashCommand + let isCreating: Bool + let onSave: (CustomSlashCommand) -> Void + let onCancel: () -> Void + + init( + initial: CustomSlashCommand, + isCreating: Bool, + onSave: @escaping (CustomSlashCommand) -> Void, + onCancel: @escaping () -> Void + ) { + _draft = State(initialValue: initial) + self.isCreating = isCreating + self.onSave = onSave + self.onCancel = onCancel + } + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + LabeledContent(String(localized: "Name")) { + TextField("review", text: $draft.name) + .textFieldStyle(.roundedBorder) + } + LabeledContent(String(localized: "Description")) { + TextField(String(localized: "Optional one-line description"), text: $draft.description) + .textFieldStyle(.roundedBorder) + } + } + Section { + TextEditor(text: $draft.promptTemplate) + .font(.body.monospaced()) + .frame(minHeight: 140) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(nsColor: .separatorColor)) + ) + } header: { + Text(String(localized: "Prompt template")) + } footer: { + Text(String(localized: """ + Use {{query}} for the current editor query, {{schema}} for the active schema, \ + {{database}} for the active database name, and {{body}} for any text typed \ + after the command. + """)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + + Divider() + + HStack { + Spacer() + Button(String(localized: "Cancel"), action: onCancel) + .keyboardShortcut(.cancelAction) + Button(isCreating ? String(localized: "Add") : String(localized: "Save")) { + onSave(draft) + } + .keyboardShortcut(.defaultAction) + .disabled(!draft.isValid) + } + .padding(12) + } + .frame(minWidth: 480, minHeight: 360) + } +} From b814cad4774a761ac5c896ac0163accbdca49ec4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 7 May 2026 14:27:26 +0700 Subject: [PATCH 2/2] refactor: address phase 5 review (docs, wire schema substitution, single-pass renderer, tests) --- .../AI/Chat/CustomSlashCommandRenderer.swift | 37 ++++++-- TablePro/ViewModels/AIChatViewModel.swift | 29 +++++- TablePro/Views/AIChat/AIChatPanelView.swift | 2 +- .../ContextItemSavedQueryCodableTests.swift | 46 ++++++++++ .../AI/CustomSlashCommandRendererTests.swift | 89 +++++++++++++++++++ docs/features/ai-assistant.mdx | 34 +++++++ 6 files changed, 224 insertions(+), 13 deletions(-) create mode 100644 TableProTests/Core/AI/ContextItemSavedQueryCodableTests.swift create mode 100644 TableProTests/Core/AI/CustomSlashCommandRendererTests.swift diff --git a/TablePro/Core/AI/Chat/CustomSlashCommandRenderer.swift b/TablePro/Core/AI/Chat/CustomSlashCommandRenderer.swift index 47f32e4cd..58fad5580 100644 --- a/TablePro/Core/AI/Chat/CustomSlashCommandRenderer.swift +++ b/TablePro/Core/AI/Chat/CustomSlashCommandRenderer.swift @@ -18,16 +18,35 @@ enum CustomSlashCommandRenderer { } static func render(_ command: CustomSlashCommand, context: Context) -> String { - var output = command.promptTemplate - let substitutions: [(CustomSlashCommandVariable, String)] = [ - (.query, context.query ?? ""), - (.schema, context.schema ?? ""), - (.database, context.database ?? ""), - (.body, context.body) + let values: [String: String] = [ + CustomSlashCommandVariable.query.rawValue: context.query ?? "", + CustomSlashCommandVariable.schema.rawValue: context.schema ?? "", + CustomSlashCommandVariable.database.rawValue: context.database ?? "", + CustomSlashCommandVariable.body.rawValue: context.body ] - for (variable, value) in substitutions { - output = output.replacingOccurrences(of: variable.placeholder, with: value) + let template = command.promptTemplate + var result = "" + var index = template.startIndex + while index < template.endIndex { + if let openRange = template.range(of: "{{", range: index.. String? { + guard !tables.isEmpty else { return nil } + let settings = AppSettingsManager.shared.ai + let identifierQuote = connection.flatMap { + PluginManager.shared.sqlDialect(for: $0.type)?.identifierQuote + } ?? "\"" + let section = AISchemaContext.buildSchemaSection( + tables: tables, + columnsByTable: columnsByTable, + foreignKeys: foreignKeysByTable, + maxTables: settings.maxSchemaTables, + identifierQuote: identifierQuote + ) + return section.isEmpty ? nil : section + } + private func resolveQuery(body: String, command: SlashCommand) -> String? { if !body.isEmpty { return body diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index fb1bab141..672c56771 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -462,7 +462,7 @@ struct AIChatPanelView: View { ForEach(customCommands) { command in Button { updateContext() - viewModel.runCustomSlashCommand(command) + Task { await viewModel.runCustomSlashCommand(command) } } label: { if command.description.isEmpty { Text("/\(command.name)") diff --git a/TableProTests/Core/AI/ContextItemSavedQueryCodableTests.swift b/TableProTests/Core/AI/ContextItemSavedQueryCodableTests.swift new file mode 100644 index 000000000..732b266f2 --- /dev/null +++ b/TableProTests/Core/AI/ContextItemSavedQueryCodableTests.swift @@ -0,0 +1,46 @@ +// +// ContextItemSavedQueryCodableTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ContextItem.savedQuery Codable migration") +struct ContextItemSavedQueryCodableTests { + @Test("Decodes legacy payload missing the name field") + func decodesLegacyMissingName() throws { + let id = UUID() + let json = #"{"kind":"savedQuery","id":"\#(id.uuidString)"}"# + let item = try JSONDecoder().decode(ContextItem.self, from: Data(json.utf8)) + guard case .savedQuery(let decodedId, let name) = item else { + Issue.record("Expected .savedQuery; got \(item)") + return + } + #expect(decodedId == id) + #expect(name == "") + } + + @Test("Decodes new payload with name") + func decodesNewWithName() throws { + let id = UUID() + let json = #"{"kind":"savedQuery","id":"\#(id.uuidString)","name":"Top Customers"}"# + let item = try JSONDecoder().decode(ContextItem.self, from: Data(json.utf8)) + guard case .savedQuery(let decodedId, let name) = item else { + Issue.record("Expected .savedQuery; got \(item)") + return + } + #expect(decodedId == id) + #expect(name == "Top Customers") + } + + @Test("Round-trips through JSON") + func roundTrip() throws { + let id = UUID() + let original = ContextItem.savedQuery(id: id, name: "Audit Log") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ContextItem.self, from: data) + #expect(decoded == original) + } +} diff --git a/TableProTests/Core/AI/CustomSlashCommandRendererTests.swift b/TableProTests/Core/AI/CustomSlashCommandRendererTests.swift new file mode 100644 index 000000000..c8ca66f44 --- /dev/null +++ b/TableProTests/Core/AI/CustomSlashCommandRendererTests.swift @@ -0,0 +1,89 @@ +// +// CustomSlashCommandRendererTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("CustomSlashCommandRenderer") +struct CustomSlashCommandRendererTests { + private func makeCommand(template: String) -> CustomSlashCommand { + CustomSlashCommand(name: "test", description: "", promptTemplate: template) + } + + private func makeContext( + query: String? = nil, + schema: String? = nil, + database: String? = nil, + body: String = "" + ) -> CustomSlashCommandRenderer.Context { + .init(query: query, schema: schema, database: database, body: body) + } + + @Test("Substitutes a single placeholder") + func substitutesSingle() { + let result = CustomSlashCommandRenderer.render( + makeCommand(template: "Run: {{query}}"), + context: makeContext(query: "SELECT 1") + ) + #expect(result == "Run: SELECT 1") + } + + @Test("Substitutes multiple placeholders independently") + func substitutesMultiple() { + let result = CustomSlashCommandRenderer.render( + makeCommand(template: "DB={{database}} | Q={{query}} | B={{body}}"), + context: makeContext(query: "SELECT 1", database: "main", body: "extra") + ) + #expect(result == "DB=main | Q=SELECT 1 | B=extra") + } + + @Test("Missing values render as empty strings") + func missingValuesAreEmpty() { + let result = CustomSlashCommandRenderer.render( + makeCommand(template: "schema={{schema}}, q={{query}}"), + context: makeContext() + ) + #expect(result == "schema=, q=") + } + + @Test("Unknown placeholders pass through unchanged") + func unknownPlaceholdersPassThrough() { + let result = CustomSlashCommandRenderer.render( + makeCommand(template: "{{query}} and {{notARealVar}}"), + context: makeContext(query: "x") + ) + #expect(result == "x and {{notARealVar}}") + } + + @Test("Placeholder text inside a substituted value is not re-expanded") + func noRecursiveExpansion() { + // body contains literal `{{query}}` text. It should remain literal, + // not get replaced by the query variable on a later pass. + let result = CustomSlashCommandRenderer.render( + makeCommand(template: "Body: {{body}}"), + context: makeContext(query: "SELECT 1", body: "fix this {{query}}") + ) + #expect(result == "Body: fix this {{query}}") + } + + @Test("Empty template returns empty string") + func emptyTemplate() { + let result = CustomSlashCommandRenderer.render( + makeCommand(template: ""), + context: makeContext(query: "SELECT 1") + ) + #expect(result == "") + } + + @Test("Template without placeholders is returned verbatim") + func noPlaceholders() { + let result = CustomSlashCommandRenderer.render( + makeCommand(template: "just literal text"), + context: makeContext(query: "ignored") + ) + #expect(result == "just literal text") + } +} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index cbe358c83..3d5dedd8a 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -103,6 +103,40 @@ Conversations auto-save and auto-title from your first message. Browse, clear, o Failed responses show **Retry**. Successful ones show **Regenerate**. Click **Stop** to cancel a streaming response. +### Attach context with `@` + +Type `@` in the composer to anchor a picker at the caret. Pick from: + +- **Schema**: every table's columns and foreign keys. +- A specific **Table**: that table's columns and foreign keys only. +- **Current Query**: whatever's in the active editor tab. +- **Query Results**: the most recent query result snapshot. +- **Saved Query**: pick one of your starred queries to send its name and SQL alongside the message. + +Up/Down navigates, Return or Tab inserts, Escape dismisses. The `@` button next to the composer opens the same picker as a menu. + +### Slash commands + +Type `/` (or click the `⌘` button next to the composer) to invoke a command: + +- **`/explain`**: explain the active query. +- **`/optimize`**: suggest optimizations for the active query. +- **`/fix`**: fix the last error against the active query. +- **`/help`**: list the commands inline in the chat. + +Create your own under **Settings → AI → Custom Slash Commands**. Templates support these placeholders that get substituted at send time: + +- `{{query}}`: the current editor query. +- `{{schema}}`: the formatted schema for the active connection (capped by **Max schema tables** in AI settings). +- `{{database}}`: the active database name. +- `{{body}}`: text typed after the command (e.g. `/review WHERE clauses` passes `WHERE clauses`). + +### Tool calling + +The AI can look up your database on demand by calling a tool. You'll see a pill like `Calling list_tables` in the assistant's reply, expanding to show the arguments. The result pill shows the tool's response. + +Read-only tools available now: `list_connections`, `get_connection_status`, `list_databases`, `list_schemas`, `list_tables`, `describe_table`, `get_table_ddl`. Supported on Anthropic, OpenAI, OpenRouter, Gemini, Ollama (model-dependent), and custom OpenAI-compatible endpoints. GitHub Copilot is not yet supported. +