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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- AI Chat: persisted conversations now carry a schema version so future migrations can read older files cleanly
- AI Chat: custom slash commands reject duplicate names, including case-insensitive collisions on rename
- Internal: unify JSON value type used by AI tools and MCP wire
- Internal: shared schema builder for AI chat tools, removes ~100 lines of duplicated JSON Schema boilerplate
- Internal: AI chat tools declare their access mode (read-only, write, agent-only) rather than relying on a hardcoded allowlist; new tools are picked up automatically
- AI providers: Anthropic test connection uses the configured model, known model list updated through Claude 4.7, and Ollama detection now logs the actual error category instead of swallowing every failure as 'not running'
- AI Chat views: replace custom pill buttons with native `.borderless` styles, switch hardcoded text colors to semantic system colors, use relative font sizing in Markdown rendering, align spacing to the 8-pt grid, and add accessibility labels to icon-only buttons

### Fixed

- AI Chat: `@` mention detection no longer breaks when the cursor sits right after an emoji or other non-BMP character

## [0.39.1] - 2026-05-08

Expand Down
11 changes: 10 additions & 1 deletion TablePro/Core/AI/AIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
//

import Foundation
import os

enum AIProvider {
static let modelListTimeout: TimeInterval = 5.0
static let logger = Logger(subsystem: "com.TablePro", category: "AIProvider")
}

enum AIProviderError: Error, LocalizedError {
Expand Down Expand Up @@ -84,9 +86,16 @@ enum AIProviderError: Error, LocalizedError {
extension ChatTransport {
func collectErrorBody(from bytes: URLSession.AsyncBytes) async throws -> String {
var body = ""
var truncated = false
for try await line in bytes.lines {
body += line
if (body as NSString).length > 2_000 { break }
if (body as NSString).length > 2_000 {
truncated = true
break
}
}
if truncated {
AIProvider.logger.warning("Error response body truncated at 2000 bytes; full body suppressed")
}
return body
}
Expand Down
13 changes: 8 additions & 5 deletions TablePro/Core/AI/AnthropicProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ final class AnthropicProvider: ChatTransport {

private let endpoint: String
private let apiKey: String
private let model: String
private let maxOutputTokens: Int
private let session: URLSession

init(endpoint: String, apiKey: String, maxOutputTokens: Int = 4_096) {
init(endpoint: String, apiKey: String, model: String = "", maxOutputTokens: Int = 4_096) {
self.endpoint = endpoint.normalizedEndpoint()
self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
self.model = model.trimmingCharacters(in: .whitespacesAndNewlines)
self.maxOutputTokens = maxOutputTokens
self.session = URLSession(configuration: .ephemeral)
}
Expand Down Expand Up @@ -100,16 +102,17 @@ final class AnthropicProvider: ChatTransport {
}

private static let knownModels = [
"claude-sonnet-4-6",
"claude-opus-4-6",
"claude-opus-4-7-20260101",
"claude-sonnet-4-6-20251101",
"claude-haiku-4-5-20251001",
"claude-sonnet-4-5-20250929",
"claude-opus-4-5-20251101"
"claude-opus-4-5-20250929"
]

func testConnection() async throws -> Bool {
let testModel = model.isEmpty ? (Self.knownModels.first ?? "") : model
let testTurn = ChatTurn(role: .user, blocks: [.text("Hi")])
let testOptions = ChatTransportOptions(model: "claude-haiku-4-5-20251001", maxOutputTokens: 1)
let testOptions = ChatTransportOptions(model: testModel, maxOutputTokens: 1)
let request = try buildMessagesRequest(turns: [testTurn], options: testOptions, stream: false)

let (data, response) = try await session.data(for: request)
Expand Down
37 changes: 31 additions & 6 deletions TablePro/Core/AI/Chat/ChatTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,46 @@

import Foundation

/// A tool the AI can call from a chat turn. Implementations are registered
/// with `ChatToolRegistry` and exposed to providers via `ChatToolSpec`.
enum ChatToolMode: Sendable {
case readOnly
case write
case agentOnly
}

protocol ChatTool: Sendable {
var name: String { get }
var description: String { get }
var inputSchema: JsonValue { get }
var mode: ChatToolMode { get }

func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult
}

extension ChatToolMode {
func isAllowed(in chatMode: AIChatMode) -> Bool {
switch (self, chatMode) {
case (_, .agent):
return true
case (.readOnly, .ask), (.readOnly, .edit):
return true
case (.write, .edit):
return true
case (.write, .ask):
return false
case (.agentOnly, .ask), (.agentOnly, .edit):
return false
}
}

var requiresApproval: Bool {
switch self {
case .readOnly: return false
case .write, .agentOnly: return true
}
}
}

struct ChatToolResult: Sendable, Equatable, Codable {
/// Tool results are UTF-8 text in this version. A future expansion may
/// widen `content` to accept multiple typed blocks (text, image,
/// structured data); treat the current shape as a forward-compat floor.
let content: String
let isError: Bool

Expand All @@ -29,7 +55,6 @@ struct ChatToolResult: Sendable, Equatable, Codable {
}

extension ChatTool {
/// Wire-format spec for `ChatTransportOptions.tools`.
var spec: ChatToolSpec {
ChatToolSpec(name: name, description: description, inputSchema: inputSchema)
}
Expand Down
21 changes: 21 additions & 0 deletions TablePro/Core/AI/Chat/ChatToolContext+Helpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// ChatToolContext+Helpers.swift
// TablePro
//

import Foundation

extension ChatToolContext {
func resolveConnectionId(_ input: JsonValue) throws -> UUID {
if let connectionId = try? ChatToolArgumentDecoder.requireUUID(input, key: "connection_id") {
return connectionId
}
if let active = connectionId {
return active
}
throw ChatToolArgumentError.missingOrInvalid(
key: "connection_id",
expected: "UUID string (or attach a connection in the chat)"
)
}
}
39 changes: 11 additions & 28 deletions TablePro/Core/AI/Chat/ChatToolRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,12 @@
import Foundation
import os

/// Process-wide registry of `ChatTool` implementations available to AI chat.
@MainActor
final class ChatToolRegistry {
static let shared = ChatToolRegistry()

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

private static let readOnlyToolNames: Set<String> = [
"list_connections",
"get_connection_status",
"list_databases",
"list_schemas",
"list_tables",
"describe_table",
"get_table_ddl"
]

private static let editModeToolNames: Set<String> = readOnlyToolNames.union([
"execute_query"
])

private var tools: [String: any ChatTool] = [:]

init() {}
Expand All @@ -48,8 +33,9 @@ final class ChatToolRegistry {
}

func tool(named name: String, in mode: AIChatMode) -> (any ChatTool)? {
guard Self.isToolAllowed(name: name, in: mode) else { return nil }
return tools[name]
guard let tool = tools[name] else { return nil }
guard tool.mode.isAllowed(in: mode) else { return nil }
return tool
}

var allTools: [any ChatTool] {
Expand All @@ -62,25 +48,22 @@ final class ChatToolRegistry {
}

func allTools(for mode: AIChatMode) -> [any ChatTool] {
allTools.filter { Self.isToolAllowed(name: $0.name, in: mode) }
allTools.filter { $0.mode.isAllowed(in: mode) }
}

func allSpecs(for mode: AIChatMode) -> [ChatToolSpec] {
allTools(for: mode).map(\.spec)
}

nonisolated static func requiresApproval(toolName: String) -> Bool {
!readOnlyToolNames.contains(toolName)
func requiresApproval(toolName: String) -> Bool {
guard let tool = tools[toolName] else { return true }
return tool.mode.requiresApproval
}

nonisolated static func isToolAllowed(name: String, in mode: AIChatMode) -> Bool {
switch mode {
case .ask:
return readOnlyToolNames.contains(name)
case .edit:
return editModeToolNames.contains(name)
case .agent:
return true
func isToolAllowed(name: String, in mode: AIChatMode) -> Bool {
guard let tool = tools[name] else {
return mode == .agent
}
return tool.mode.isAllowed(in: mode)
}
}
58 changes: 58 additions & 0 deletions TablePro/Core/AI/Chat/ChatToolSchemaBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// ChatToolSchemaBuilder.swift
// TablePro
//

import Foundation

enum ChatToolSchemaBuilder {
static func object(properties: [String: JsonValue], required: [String] = []) -> JsonValue {
var fields: [String: JsonValue] = [
"type": .string("object"),
"properties": .object(properties)
]
if !required.isEmpty {
fields["required"] = .array(required.map(JsonValue.string))
}
return .object(fields)
}

static func string(description: String) -> JsonValue {
.object([
"type": .string("string"),
"description": .string(description)
])
}

static func enumString(_ values: [String], description: String) -> JsonValue {
.object([
"type": .string("string"),
"enum": .array(values.map(JsonValue.string)),
"description": .string(description)
])
}

static func boolean(description: String) -> JsonValue {
.object([
"type": .string("boolean"),
"description": .string(description)
])
}

static func integer(description: String) -> JsonValue {
.object([
"type": .string("integer"),
"description": .string(description)
])
}
}

extension ChatToolSchemaBuilder {
static var connectionId: JsonValue {
string(description: "UUID of the connection")
}

static var schemaName: JsonValue {
string(description: "Schema name (uses current if omitted)")
}
}
60 changes: 31 additions & 29 deletions TablePro/Core/AI/Chat/MentionDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,46 +11,48 @@ struct MentionMatch: Equatable, Sendable {
}

enum MentionDetector {
private static let triggerScalar: Unicode.Scalar = "@"

static func detect(in text: String, caret: Int) -> MentionMatch? {
let nsText = text as NSString
guard caret >= 0, caret <= nsText.length else { return nil }

var idx = caret - 1
while idx >= 0 {
let c = nsText.character(at: idx)
if c == 0x40 {
guard isBoundary(before: idx, in: nsText) else { return nil }
let queryStart = idx + 1
let queryLength = caret - queryStart
let query = nsText.substring(with: NSRange(location: queryStart, length: queryLength))
guard caret >= 0 else { return nil }
let utf16Length = text.utf16.count
guard caret <= utf16Length else { return nil }

let caretIndex = String.Index(utf16Offset: caret, in: text)
let scalars = text.unicodeScalars
let scalarStart = scalars.startIndex
let scalarCaret = caretIndex.samePosition(in: scalars) ?? caretIndex
var cursor = scalarCaret

while cursor > scalarStart {
let previous = scalars.index(before: cursor)
let scalar = scalars[previous]
if scalar == triggerScalar {
guard isBoundary(before: previous, in: scalars) else { return nil }
let triggerOffset = previous.utf16Offset(in: text)
let queryStart = scalars.index(after: previous)
let query = String(scalars[queryStart ..< scalarCaret])
return MentionMatch(
range: NSRange(location: idx, length: caret - idx),
range: NSRange(location: triggerOffset, length: caret - triggerOffset),
query: query
)
}
if !isQueryCharacter(c) {
return nil
}
idx -= 1
if !isQueryCharacter(scalar) { return nil }
cursor = previous
}
return nil
}

private static func isQueryCharacter(_ c: unichar) -> Bool {
if c >= 0x41 && c <= 0x5A { return true }
if c >= 0x61 && c <= 0x7A { return true }
if c >= 0x30 && c <= 0x39 { return true }
if c == 0x5F { return true }
guard let scalar = Unicode.Scalar(c) else { return false }
private static func isQueryCharacter(_ scalar: Unicode.Scalar) -> Bool {
if scalar == "_" { return true }
if CharacterSet.alphanumerics.contains(scalar) { return true }
return CharacterSet.letters.contains(scalar)
}

private static func isBoundary(before idx: Int, in nsText: NSString) -> Bool {
guard idx > 0 else { return true }
let prev = nsText.character(at: idx - 1)
guard let scalar = Unicode.Scalar(prev) else { return true }
if CharacterSet.whitespacesAndNewlines.contains(scalar) { return true }
if CharacterSet.punctuationCharacters.contains(scalar) { return true }
return false
private static func isBoundary(before index: String.UnicodeScalarView.Index,
in scalars: String.UnicodeScalarView) -> Bool {
guard index > scalars.startIndex else { return true }
let scalar = scalars[scalars.index(before: index)]
return !isQueryCharacter(scalar)
}
}
Loading
Loading