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 @@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Theme color wells and the theme preview unreadable to VoiceOver.
- Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background.
- GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed.
- Settings > AI reading sign-in tokens and running command line tools for providers that were never added.

### Security

Expand Down
17 changes: 17 additions & 0 deletions TablePro/Core/AI/AIProviderAccountProbes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// AIProviderAccountProbes.swift
// TablePro
//

import Foundation

/// A sign-in status in Settings > AI costs a Keychain read or a spawned CLI, so it is checked only
/// for a provider the user has added, and not at all while AI is off.
enum AIProviderAccountProbes {
static let probedTypes: Set<AIProviderType> = [.chatgptCodex, .claudeAgent]

static func types(toProbe settings: AISettings) -> Set<AIProviderType> {
guard settings.enabled else { return [] }
return Set(settings.providers.map(\.type)).intersection(probedTypes)
}
}
22 changes: 17 additions & 5 deletions TablePro/Views/Settings/AISettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,9 @@ struct AISettingsView: View {
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.task { refreshKeyAvailability() }
.task { await chatGPTCodexService.refreshAuthState() }
.task { await claudeAgentService.performRefresh() }
.onChange(of: settings.providers.map(\.id)) { _ in
refreshKeyAvailability()
.task(id: settings.enabled ? settings.providers.map(\.id) : []) { refreshKeyAvailability() }
.task(id: AIProviderAccountProbes.types(toProbe: settings)) {
await refreshAccountStatus(of: AIProviderAccountProbes.types(toProbe: settings))
}
.sheet(item: editingProviderBinding) { provider in
AIProviderDetailSheet(
Expand Down Expand Up @@ -418,7 +416,21 @@ struct AISettingsView: View {
return String(localized: "Not configured")
}

private func refreshAccountStatus(of types: Set<AIProviderType>) async {
let chatGPT = chatGPTCodexService
let claudeAgent = claudeAgentService
await withTaskGroup(of: Void.self) { group in
if types.contains(.chatgptCodex) {
group.addTask { await chatGPT.refreshAuthState() }
}
if types.contains(.claudeAgent) {
group.addTask { await claudeAgent.performRefresh() }
}
}
}

private func refreshKeyAvailability() {
guard settings.enabled else { return }
var ids: Set<UUID> = []
for provider in settings.providers where provider.authStyle.usesAPIKey {
if let key = AIKeyStorage.shared.loadAPIKey(for: provider.id), !key.isEmpty {
Expand Down
43 changes: 43 additions & 0 deletions TableProTests/Core/AI/AIProviderAccountProbesTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// AIProviderAccountProbesTests.swift
// TableProTests
//

import Foundation
@testable import TablePro
import Testing

struct AIProviderAccountProbesTests {
private func settings(enabled: Bool = true, _ types: AIProviderType...) -> AISettings {
AISettings(enabled: enabled, providers: types.map { AIProviderConfig(type: $0) })
}

@Test("Nothing is probed when no provider is configured")
func noProvidersProbesNothing() {
#expect(AIProviderAccountProbes.types(toProbe: settings()).isEmpty)
}

@Test("Nothing is probed while AI is off, even with sign-in providers configured")
func aiOffProbesNothing() {
let configured = settings(enabled: false, .claudeAgent, .chatgptCodex)
#expect(AIProviderAccountProbes.types(toProbe: configured).isEmpty)
}

@Test("Only the configured sign-in providers are probed")
func onlyConfiguredProvidersAreProbed() {
let configured = settings(.claudeAgent, .openAI, .gemini)
#expect(AIProviderAccountProbes.types(toProbe: configured) == [.claudeAgent])
}

@Test("Every configured sign-in provider is probed once")
func everyConfiguredSignInProviderIsProbed() {
let configured = settings(.chatgptCodex, .claudeAgent, .claudeAgent)
#expect(AIProviderAccountProbes.types(toProbe: configured) == [.chatgptCodex, .claudeAgent])
}

@Test("API key providers never need an account probe")
func apiKeyProvidersAreNotProbed() {
let configured = settings(.claude, .openAI, .openRouter, .gemini, .xai, .ollama, .custom, .copilot)
#expect(AIProviderAccountProbes.types(toProbe: configured).isEmpty)
}
}
Loading