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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **File > Close All Tabs**, **Close Other Tabs**, and **Close Tabs for Other Databases** close a group of tabs in one step instead of one at a time. Closing every tab leaves the window open on its empty state with the connection still live, and each closed tab stays in **Recently Closed**. None of the three has a shortcut out of the box; bind them in **Settings > Keyboard**. (#1972)
- AI Explain, Optimize, and Fix Error now answer with a walkthrough in the chat panel: a before/after SQL diff you can switch between unified and split, numbered steps anchored to the lines they change, and a follow-up prompt on any step. Optimize and Fix add an Apply to Editor button that asks before replacing your query. (#1945)
- Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959)
- A tool call limit you can raise or turn off, in **Settings > AI**. It defaults to 25 per reply, up from a fixed 10. Reaching it now pauses the reply and keeps everything the AI found, with **Continue** to carry on and **Adjust Limit** to change the setting. Before, a long investigation ended in "AI made too many tool calls in one response" and the partial answer was thrown away. Copilot runs its own tool loop and ignores this setting. (#1987)
- A pin button on result tabs. It shows on the tab you are on and when you point at a tab, and stays visible once the result is pinned. Pinned results move to the front of the strip. Pinning was already there, but only as a right-click item and a View menu command, so nobody found it. (#1982)

### Changed
Expand All @@ -20,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- A message sent in the AI panel right after launch is no longer replaced by the last saved conversation. Restoring that conversation reads from disk in the background, and it used to overwrite whatever you had already typed and sent.
- A PostgreSQL partitioned table is listed once instead of once per partition. Expand it in the sidebar to see its partitions, and expand one again if it is subpartitioned. A table split into hundreds of partitions no longer buries the rest of the schema, and those partitions no longer fill up query autocomplete, Cmd+K, and the export picker. (#1984)
- An SSH tunnel that cannot reach the database now says so, instead of leaving the database driver to report a timeout reading the server greeting. MySQL showed this as "Lost connection to server at 'handshake: reading initial communication packet', system error: 35", which named no cause. TablePro now checks the destination is reachable before the tunnel is handed over, and reports whether it was refused or timed out. The most common cause is the **Host** field holding the server's public address while the database only listens on `127.0.0.1`; the SSH server resolves that address from where it sits, so **Host** should be `localhost`. (#1981)
- The macOS local network prompt now says why TablePro wants access. The app never declared a reason, so the prompt showed a generic message that was easy to deny, which left SSH tunnels and databases on your own network failing with "no route to host".
Expand Down
24 changes: 24 additions & 0 deletions TablePro/Models/AI/AIModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,15 @@ struct AISettings: Codable, Equatable, Sendable {
var includeCurrentQuery: Bool
var includeQueryResults: Bool
var maxSchemaTables: Int
var maxToolRoundtrips: Int
var maxToolRoundtripsEnabled: Bool
var defaultConnectionPolicy: AIConnectionPolicy
var chatMode: AIChatMode

static let defaultInlineSuggestionDebounceMs: Int = 500
static let inlineSuggestionDebounceRange: ClosedRange<Int> = 100...3_000
static let defaultMaxToolRoundtrips: Int = 25
static let maxToolRoundtripsRange: ClosedRange<Int> = 5...200

static let `default` = AISettings(
enabled: true,
Expand All @@ -243,6 +247,8 @@ struct AISettings: Codable, Equatable, Sendable {
includeCurrentQuery: true,
includeQueryResults: false,
maxSchemaTables: 20,
maxToolRoundtrips: AISettings.defaultMaxToolRoundtrips,
maxToolRoundtripsEnabled: true,
defaultConnectionPolicy: .askEachTime,
chatMode: .ask
)
Expand All @@ -257,6 +263,8 @@ struct AISettings: Codable, Equatable, Sendable {
includeCurrentQuery: Bool = true,
includeQueryResults: Bool = false,
maxSchemaTables: Int = 20,
maxToolRoundtrips: Int = AISettings.defaultMaxToolRoundtrips,
maxToolRoundtripsEnabled: Bool = true,
defaultConnectionPolicy: AIConnectionPolicy = .askEachTime,
chatMode: AIChatMode = .ask
) {
Expand All @@ -269,6 +277,8 @@ struct AISettings: Codable, Equatable, Sendable {
self.includeCurrentQuery = includeCurrentQuery
self.includeQueryResults = includeQueryResults
self.maxSchemaTables = maxSchemaTables
self.maxToolRoundtrips = maxToolRoundtrips
self.maxToolRoundtripsEnabled = maxToolRoundtripsEnabled
self.defaultConnectionPolicy = defaultConnectionPolicy
self.chatMode = chatMode
}
Expand All @@ -286,6 +296,12 @@ struct AISettings: Codable, Equatable, Sendable {
includeCurrentQuery = try container.decodeIfPresent(Bool.self, forKey: .includeCurrentQuery) ?? true
includeQueryResults = try container.decodeIfPresent(Bool.self, forKey: .includeQueryResults) ?? false
maxSchemaTables = try container.decodeIfPresent(Int.self, forKey: .maxSchemaTables) ?? 20
maxToolRoundtrips = try container.decodeIfPresent(
Int.self, forKey: .maxToolRoundtrips
) ?? AISettings.defaultMaxToolRoundtrips
maxToolRoundtripsEnabled = try container.decodeIfPresent(
Bool.self, forKey: .maxToolRoundtripsEnabled
) ?? true
defaultConnectionPolicy = try container.decodeIfPresent(
AIConnectionPolicy.self, forKey: .defaultConnectionPolicy
) ?? .askEachTime
Expand All @@ -309,6 +325,14 @@ struct AISettings: Codable, Equatable, Sendable {
AISettings.inlineSuggestionDebounceRange.upperBound
)
}

var effectiveMaxToolRoundtrips: Int? {
guard maxToolRoundtripsEnabled else { return nil }
return min(
max(maxToolRoundtrips, AISettings.maxToolRoundtripsRange.lowerBound),
AISettings.maxToolRoundtripsRange.upperBound
)
}
}

struct AITokenUsage: Codable, Equatable, Sendable {
Expand Down
7 changes: 3 additions & 4 deletions TablePro/ViewModels/AIChatViewModel+Persistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@ extension AIChatViewModel {
await MainActor.run {
guard let self else { return }
self.conversations = loaded
if let mostRecent = loaded.first {
self.activeConversationID = mostRecent.id
self.messages = mostRecent.messages.map { ChatTurn(wire: $0) }
}
guard self.messages.isEmpty, let mostRecent = loaded.first else { return }
self.activeConversationID = mostRecent.id
self.messages = mostRecent.messages.map { ChatTurn(wire: $0) }
}
}
}
Expand Down
90 changes: 72 additions & 18 deletions TablePro/ViewModels/AIChatViewModel+Streaming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@

import Foundation
import os
import SwiftUI
import TableProPluginKit

extension AIChatViewModel {
static let maxToolRoundtrips = 10
static let hardToolRoundtripCeiling = 500

struct ToolRoundtripContinuation {
let nextAssistantID: UUID
Expand All @@ -31,7 +32,12 @@ extension AIChatViewModel {
}

func startStreaming() {
guard case .idle = streamingState else { return }
switch streamingState {
case .idle, .pausedAtToolLimit:
break
case .loading, .streaming, .awaitingApproval, .failed:
return
}

let settings = services.appSettings.ai

Expand Down Expand Up @@ -60,6 +66,39 @@ extension AIChatViewModel {
}
}

beginStreamingTurn(
resolved: resolved,
settings: settings,
includeWalkthroughDirective: pendingWalkthroughBeforeSQL != nil
)
}

func continueToolLoop() {
guard case .pausedAtToolLimit = streamingState else { return }

let settings = services.appSettings.ai
let resolved = AIProviderFactory.resolve(
settings: settings,
overrideProviderId: selectedProviderId,
overrideModel: selectedModel
)
guard let resolved else {
errorMessage = String(localized: "No AI provider configured. Go to Settings > AI to add one.")
return
}

beginStreamingTurn(
resolved: resolved,
settings: settings,
includeWalkthroughDirective: pendingWalkthroughBeforeSQL != nil
)
}

private func beginStreamingTurn(
resolved: AIProviderFactory.ResolvedProvider,
settings: AISettings,
includeWalkthroughDirective: Bool
) {
let assistantMessage = ChatTurn(
role: .assistant,
blocks: [],
Expand Down Expand Up @@ -96,7 +135,7 @@ extension AIChatViewModel {
resolved: resolved,
assistantID: assistantID,
settings: settings,
includeWalkthroughDirective: self.pendingWalkthroughBeforeSQL != nil
includeWalkthroughDirective: includeWalkthroughDirective
)
self.prepTask = nil
}
Expand All @@ -109,9 +148,14 @@ extension AIChatViewModel {
resolved: AIProviderFactory.ResolvedProvider,
assistantID: UUID,
settings: AISettings,
includeWalkthroughDirective: Bool = false
includeWalkthroughDirective: Bool = false,
registry: ChatToolRegistry? = nil
) {
let chatMode = settings.chatMode
let roundtripLimit = min(
settings.effectiveMaxToolRoundtrips ?? Self.hardToolRoundtripCeiling,
Self.hardToolRoundtripCeiling
)
streamingTask = Task.detached(priority: .userInitiated) { [weak self] in
var currentAssistantID = assistantID
do {
Expand All @@ -128,10 +172,21 @@ extension AIChatViewModel {
)
guard preflightOK else { return }

let toolSpecs = await MainActor.run { ChatToolRegistry.shared.allSpecs(for: chatMode) }
let toolSpecs = await MainActor.run {
(registry ?? ChatToolRegistry.shared).allSpecs(for: chatMode)
}
var workingTurns = chatMessages
var executedRoundtrips = 0

while true {
if executedRoundtrips >= roundtripLimit {
await self.pauseAtToolLimit(
assistantID: currentAssistantID,
count: executedRoundtrips
)
return
}

for roundtrip in 0..<Self.maxToolRoundtrips {
let round = try await self.consumeStreamRound(
resolved: resolved,
systemPrompt: systemPrompt,
Expand All @@ -143,11 +198,6 @@ extension AIChatViewModel {
if round.cancelled { return }
if round.toolUseOrder.isEmpty { break }

if roundtrip == Self.maxToolRoundtrips - 1 {
await self.failTooManyRoundtrips(assistantID: currentAssistantID)
break
}

let assembled = Self.assembleToolUseBlocks(
order: round.toolUseOrder,
names: round.toolUseNames,
Expand All @@ -163,7 +213,8 @@ extension AIChatViewModel {
}
let toolUseBlocks = await self.resolveAndAwaitApprovals(
assembledBlocks: assembled,
assistantID: currentAssistantID
assistantID: currentAssistantID,
registry: registry
)
guard !Task.isCancelled else { return }

Expand All @@ -172,7 +223,7 @@ extension AIChatViewModel {
return false
}
let executedResults = await Self.executeToolUses(
approvedBlocks, mode: chatMode, context: context
approvedBlocks, mode: chatMode, context: context, registry: registry
)
guard !Task.isCancelled else { return }

Expand All @@ -189,6 +240,7 @@ extension AIChatViewModel {
currentAssistantID = continuation.nextAssistantID
workingTurns.append(continuation.assistantTurn)
workingTurns.append(continuation.userTurn)
executedRoundtrips += 1
}

guard !Task.isCancelled else { return }
Expand Down Expand Up @@ -439,18 +491,20 @@ extension AIChatViewModel {
return "\(base)\n\n\(directive)"
}

private func failTooManyRoundtrips(assistantID: UUID) async {
private func pauseAtToolLimit(assistantID: UUID, count: Int) async {
await MainActor.run { [weak self] in
guard let self else { return }
self.errorMessage = String(
localized: "AI made too many tool calls in one response. Try simplifying the request."
)
self.finalizeStreamingMessage(id: assistantID)
if let idx = self.messages.firstIndex(where: { $0.id == assistantID }),
self.messages[idx].blocks.isEmpty {
self.messages.remove(at: idx)
}
self.streamingState = .failed(nil)
self.streamingState = .pausedAtToolLimit(count: count)
self.streamingTask = nil
self.persistCurrentConversation()
AccessibilityNotification.Announcement(
String(format: String(localized: "Paused after %d tool calls."), count)
).post()
}
}

Expand Down
12 changes: 8 additions & 4 deletions TablePro/ViewModels/AIChatViewModel+ToolApproval.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ extension AIChatViewModel {

func resolveAndAwaitApprovals(
assembledBlocks: [ToolUseBlock],
assistantID: UUID
assistantID: UUID,
registry: ChatToolRegistry? = nil
) async -> [ToolUseBlock] {
let initialBlocks = await MainActor.run { [weak self] () -> [ToolUseBlock] in
guard let self else { return assembledBlocks }
let initial = assembledBlocks.map { block -> ToolUseBlock in
let state = self.computeInitialApprovalState(for: block.name)
let state = self.computeInitialApprovalState(for: block.name, registry: registry)
return ToolUseBlock(
id: block.id,
name: block.name,
Expand Down Expand Up @@ -77,8 +78,11 @@ extension AIChatViewModel {
}

@MainActor
func computeInitialApprovalState(for toolName: String) -> ToolApprovalState {
let tool = ChatToolRegistry.shared.tool(named: toolName)
func computeInitialApprovalState(
for toolName: String,
registry: ChatToolRegistry? = nil
) -> ToolApprovalState {
let tool = (registry ?? ChatToolRegistry.shared).tool(named: toolName)
let toolMode = tool?.mode

if toolMode == .readOnly {
Expand Down
10 changes: 9 additions & 1 deletion TablePro/ViewModels/AIChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class AIChatViewModel {
case loading
case streaming(assistantID: UUID)
case awaitingApproval
case pausedAtToolLimit(count: Int)
case failed(AIProviderError?)
}

Expand Down Expand Up @@ -51,7 +52,7 @@ final class AIChatViewModel {
switch streamingState {
case .loading, .streaming:
return true
case .idle, .awaitingApproval, .failed:
case .idle, .awaitingApproval, .pausedAtToolLimit, .failed:
return false
}
}
Expand All @@ -61,6 +62,13 @@ final class AIChatViewModel {
return false
}

var toolLimitPauseCount: Int? {
if case .pausedAtToolLimit(let count) = streamingState { return count }
return nil
}

var isPausedAtToolLimit: Bool { toolLimitPauseCount != nil }

var lastError: AIProviderError? {
if case .failed(let error) = streamingState { return error }
return nil
Expand Down
32 changes: 32 additions & 0 deletions TablePro/Views/AIChat/AIChatMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ struct AIChatMessageView: View {
var onRetry: (() -> Void)?
var onRegenerate: (() -> Void)?
var onEdit: (() -> Void)?
var onContinue: (() -> Void)?
var onAdjustToolLimit: (() -> Void)?
var pausedToolCallCount: Int?

private var attachedContextItems: [ContextItem] {
message.blocks.compactMap { block in
Expand Down Expand Up @@ -113,9 +116,38 @@ struct AIChatMessageView: View {
.buttonStyle(.plain)
.padding(.horizontal, 8)
}

toolLimitPauseRow
}
}

@ViewBuilder
private var toolLimitPauseRow: some View {
if let onContinue, let onAdjustToolLimit, let pausedToolCallCount {
HStack(spacing: 8) {
Image(systemName: "pause.circle")
.foregroundStyle(.secondary)
Text(pausedDescription(count: pausedToolCallCount))
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Button(String(localized: "Adjust Limit")) { onAdjustToolLimit() }
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
.help(String(localized: "Open AI settings to change the tool call limit."))
Button(String(localized: "Continue")) { onContinue() }
.controlSize(.small)
.help(String(localized: "Resume with a fresh tool call budget."))
}
.font(.caption)
.padding(.horizontal, 8)
.padding(.top, 2)
}
}

private func pausedDescription(count: Int) -> String {
String(format: String(localized: "Paused after %d tool calls."), count)
}

private var roleHeader: some View {
HStack(spacing: 4) {
Image(systemName: "sparkles")
Expand Down
Loading
Loading