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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- AI Chat: per-card tool approval. Write and destructive AI tool calls now show inline `Run`, `Always for this connection`, and `Cancel` buttons on the tool card instead of interrupting with a modal dialog. "Always for this connection" persists the tool name on the connection so future calls run without prompting. Cancel ends only that tool call; the assistant sees an error result and continues the conversation. Read-only tools auto-run; Read-only safe mode still blocks writes outright.
- AI Chat: panel layout redesign. The right inspector has a Details / AI Chat segmented picker at the top, with conversation history and new-conversation actions trailing on the same row. The chat tab is composer-focused: composer is a pill-shaped input with an Apple Intelligence focus glow, and a single-row footer (mention, slash commands, mode picker, model picker, send). The mode picker (Ask / Edit / Agent) is saved to settings but does not yet change provider behavior.
- AI Chat: inline model picker in the composer with per-turn model attribution. Switch between configured providers and any of their available models without leaving the chat. The model that produced each assistant turn is shown in the message footer.
- AI Chat: slash commands `/explain`, `/optimize`, `/fix`, and `/help`. Type the command in the composer or pick from the slash menu next to the model picker. `/explain`, `/optimize`, and `/fix` operate on the current query in the active editor. `/help` lists the commands inline.
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/AI/Chat/ChatToolRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ final class ChatToolRegistry {
allTools(for: mode).map(\.spec)
}

nonisolated static func requiresApproval(toolName: String) -> Bool {
!readOnlyToolNames.contains(toolName)
}

nonisolated static func isToolAllowed(name: String, in mode: AIChatMode) -> Bool {
switch mode {
case .ask:
Expand Down
35 changes: 35 additions & 0 deletions TablePro/Core/AI/Chat/ChatTurn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,41 @@ struct ToolUseBlock: Codable, Equatable, Sendable {
let id: String
let name: String
let input: JSONValue
var approvalState: ToolApprovalState

init(id: String, name: String, input: JSONValue, approvalState: ToolApprovalState = .approved) {
self.id = id
self.name = name
self.input = input
self.approvalState = approvalState
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
input = try container.decode(JSONValue.self, forKey: .input)
approvalState = try container.decodeIfPresent(ToolApprovalState.self, forKey: .approvalState) ?? .approved
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(input, forKey: .input)
try container.encode(approvalState, forKey: .approvalState)
}

private enum CodingKeys: String, CodingKey {
case id, name, input, approvalState
}
}

enum ToolApprovalState: Codable, Equatable, Sendable {
case approved
case pending
case denied(reason: String)
case cancelled
}

struct ToolResultBlock: Codable, Equatable, Sendable {
Expand Down
49 changes: 49 additions & 0 deletions TablePro/Core/AI/Chat/ToolApprovalCenter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// ToolApprovalCenter.swift
// TablePro
//

import Foundation
import os

enum ToolApprovalDecision: Sendable {
case run
case alwaysAllow
case cancel
}

@MainActor
final class ToolApprovalCenter {
static let shared = ToolApprovalCenter()

private static let logger = Logger(subsystem: "com.TablePro", category: "ToolApprovalCenter")

private var pending: [String: CheckedContinuation<ToolApprovalDecision, Never>] = [:]

func awaitDecision(for toolUseId: String) async -> ToolApprovalDecision {
await withCheckedContinuation { continuation in
if let existing = pending[toolUseId] {
Self.logger.warning(
"Duplicate awaitDecision for tool use id \(toolUseId, privacy: .public); cancelling prior continuation"
)
existing.resume(returning: .cancel)
}
pending[toolUseId] = continuation
}
}

func resolve(toolUseId: String, decision: ToolApprovalDecision) {
guard let continuation = pending.removeValue(forKey: toolUseId) else { return }
continuation.resume(returning: decision)
}

func cancelAll() {
let snapshot = pending
pending.removeAll()
for (_, continuation) in snapshot {
continuation.resume(returning: .cancel)
}
}

var hasPending: Bool { !pending.isEmpty }
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,6 @@ struct ConfirmDestructiveOperationChatTool: ChatTool {
)
}

do {
try await context.authPolicy.checkSafeModeDialog(
sql: query,
connectionId: connectionId,
databaseType: meta.databaseType,
safeModeLevel: meta.safeModeLevel
)
} catch {
return ChatToolResult(content: "User declined to run this query.", isError: true)
}

let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp }
let services = MCPToolServices(connectionBridge: context.bridge, authPolicy: context.authPolicy)
let payload = try await ToolQueryExecutor.executeAndLog(
Expand Down
11 changes: 0 additions & 11 deletions TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,6 @@ struct ExecuteQueryChatTool: ChatTool {
_ = try await context.bridge.switchSchema(connectionId: connectionId, schema: schema)
}

do {
try await context.authPolicy.checkSafeModeDialog(
sql: query,
connectionId: connectionId,
databaseType: meta.databaseType,
safeModeLevel: meta.safeModeLevel
)
} catch {
return ChatToolResult(content: "User declined to run this query.", isError: true)
}

let services = MCPToolServices(connectionBridge: context.bridge, authPolicy: context.authPolicy)
let payload = try await ToolQueryExecutor.executeAndLog(
services: services,
Expand Down
11 changes: 11 additions & 0 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ final class ConnectionStorage {
safeModeLevel: connection.safeModeLevel,
aiPolicy: connection.aiPolicy,
aiRules: connection.aiRules,
aiAlwaysAllowedTools: connection.aiAlwaysAllowedTools,
redisDatabase: connection.redisDatabase,
startupCommands: connection.startupCommands,
sortOrder: connection.sortOrder,
Expand Down Expand Up @@ -445,6 +446,9 @@ private struct StoredConnection: Codable {
// AI policy
let aiPolicy: String?

// AI tools whitelisted for this connection
let aiAlwaysAllowedTools: [String]?

// MongoDB-specific
let mongoAuthSource: String?
let mongoReadPreference: String?
Expand Down Expand Up @@ -523,6 +527,9 @@ private struct StoredConnection: Codable {

// AI policy
self.aiPolicy = connection.aiPolicy?.rawValue
self.aiAlwaysAllowedTools = connection.aiAlwaysAllowedTools.isEmpty
? nil
: Array(connection.aiAlwaysAllowedTools).sorted()

// MongoDB-specific
self.mongoAuthSource = connection.mongoAuthSource
Expand Down Expand Up @@ -567,6 +574,7 @@ private struct StoredConnection: Codable {
case safeModeLevel
case isReadOnly // Legacy key for migration reading only
case aiPolicy
case aiAlwaysAllowedTools
case mongoAuthSource, mongoReadPreference, mongoWriteConcern, redisDatabase
case mssqlSchema, oracleServiceName, startupCommands, sortOrder
case sshTunnelModeJson
Expand Down Expand Up @@ -605,6 +613,7 @@ private struct StoredConnection: Codable {
try container.encodeIfPresent(sshProfileId, forKey: .sshProfileId)
try container.encode(safeModeLevel, forKey: .safeModeLevel)
try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy)
try container.encodeIfPresent(aiAlwaysAllowedTools, forKey: .aiAlwaysAllowedTools)
try container.encodeIfPresent(redisDatabase, forKey: .redisDatabase)
try container.encodeIfPresent(startupCommands, forKey: .startupCommands)
try container.encode(sortOrder, forKey: .sortOrder)
Expand Down Expand Up @@ -665,6 +674,7 @@ private struct StoredConnection: Codable {
safeModeLevel = wasReadOnly ? SafeModeLevel.readOnly.rawValue : SafeModeLevel.silent.rawValue
}
aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy)
aiAlwaysAllowedTools = try container.decodeIfPresent([String].self, forKey: .aiAlwaysAllowedTools)
mongoAuthSource = try container.decodeIfPresent(String.self, forKey: .mongoAuthSource)
mongoReadPreference = try container.decodeIfPresent(String.self, forKey: .mongoReadPreference)
mongoWriteConcern = try container.decodeIfPresent(String.self, forKey: .mongoWriteConcern)
Expand Down Expand Up @@ -758,6 +768,7 @@ private struct StoredConnection: Codable {
sshTunnelMode: resolvedTunnelMode,
safeModeLevel: SafeModeLevel(rawValue: safeModeLevel) ?? .silent,
aiPolicy: parsedAIPolicy,
aiAlwaysAllowedTools: Set(aiAlwaysAllowedTools ?? []),
redisDatabase: redisDatabase,
startupCommands: startupCommands,
sortOrder: sortOrder,
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Sync/SyncRecordMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ struct SyncRecordMapper {
if let aiPolicy = connection.aiPolicy {
record["aiPolicy"] = aiPolicy.rawValue as CKRecordValue
}
if !connection.aiAlwaysAllowedTools.isEmpty {
let sorted = Array(connection.aiAlwaysAllowedTools).sorted()
record["aiAlwaysAllowedTools"] = sorted as CKRecordValue
}
if let redisDatabase = connection.redisDatabase {
record["redisDatabase"] = Int64(redisDatabase) as CKRecordValue
}
Expand Down Expand Up @@ -131,6 +135,7 @@ struct SyncRecordMapper {
let tagId = (record["tagId"] as? String).flatMap { UUID(uuidString: $0) }
let groupId = (record["groupId"] as? String).flatMap { UUID(uuidString: $0) }
let aiPolicyRaw = record["aiPolicy"] as? String
let aiAlwaysAllowedToolsArray = record["aiAlwaysAllowedTools"] as? [String] ?? []
let redisDatabase = (record["redisDatabase"] as? Int64).map { Int($0) }
let startupCommands = record["startupCommands"] as? String
let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0
Expand Down Expand Up @@ -170,6 +175,7 @@ struct SyncRecordMapper {
sshProfileId: sshProfileId,
safeModeLevel: SafeModeLevel(rawValue: safeModeLevelRaw) ?? .silent,
aiPolicy: aiPolicyRaw.flatMap { AIConnectionPolicy(rawValue: $0) },
aiAlwaysAllowedTools: Set(aiAlwaysAllowedToolsArray),
redisDatabase: redisDatabase,
startupCommands: startupCommands,
sortOrder: sortOrder,
Expand Down
9 changes: 8 additions & 1 deletion TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ struct DatabaseConnection: Identifiable, Hashable {
var safeModeLevel: SafeModeLevel
var aiPolicy: AIConnectionPolicy?
var aiRules: String?
var aiAlwaysAllowedTools: Set<String> = []
var externalAccess: ExternalAccessLevel = .readOnly
var additionalFields: [String: String] = [:]
var redisDatabase: Int?
Expand Down Expand Up @@ -358,6 +359,7 @@ struct DatabaseConnection: Identifiable, Hashable {
safeModeLevel: SafeModeLevel = .silent,
aiPolicy: AIConnectionPolicy? = nil,
aiRules: String? = nil,
aiAlwaysAllowedTools: Set<String> = [],
externalAccess: ExternalAccessLevel = .readOnly,
mongoAuthSource: String? = nil,
mongoReadPreference: String? = nil,
Expand Down Expand Up @@ -405,6 +407,7 @@ struct DatabaseConnection: Identifiable, Hashable {
}
self.aiPolicy = aiPolicy
self.aiRules = aiRules
self.aiAlwaysAllowedTools = aiAlwaysAllowedTools
self.externalAccess = externalAccess
self.redisDatabase = redisDatabase
self.startupCommands = startupCommands
Expand Down Expand Up @@ -457,7 +460,7 @@ extension DatabaseConnection: Codable {
private enum CodingKeys: String, CodingKey {
case id, name, host, port, database, username, type
case sshConfig, sslConfig, color, tagId, groupId, sshProfileId
case sshTunnelMode, safeModeLevel, aiPolicy, aiRules, externalAccess, additionalFields
case sshTunnelMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields
case redisDatabase, startupCommands, sortOrder, localOnly, isSample
}

Expand All @@ -479,6 +482,7 @@ extension DatabaseConnection: Codable {
safeModeLevel = try container.decodeIfPresent(SafeModeLevel.self, forKey: .safeModeLevel) ?? .silent
aiPolicy = try container.decodeIfPresent(AIConnectionPolicy.self, forKey: .aiPolicy)
aiRules = try container.decodeIfPresent(String.self, forKey: .aiRules)
aiAlwaysAllowedTools = try container.decodeIfPresent(Set<String>.self, forKey: .aiAlwaysAllowedTools) ?? []
externalAccess = try container.decodeIfPresent(ExternalAccessLevel.self, forKey: .externalAccess) ?? .readOnly
additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) ?? [:]
redisDatabase = try container.decodeIfPresent(Int.self, forKey: .redisDatabase)
Expand Down Expand Up @@ -522,6 +526,9 @@ extension DatabaseConnection: Codable {
try container.encode(safeModeLevel, forKey: .safeModeLevel)
try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy)
try container.encodeIfPresent(aiRules, forKey: .aiRules)
if !aiAlwaysAllowedTools.isEmpty {
try container.encode(aiAlwaysAllowedTools, forKey: .aiAlwaysAllowedTools)
}
try container.encode(externalAccess, forKey: .externalAccess)
try container.encode(additionalFields, forKey: .additionalFields)
try container.encodeIfPresent(redisDatabase, forKey: .redisDatabase)
Expand Down
Loading
Loading