Files
oai-swift/oAI/Views/Screens/SettingsView.swift
T
rune 6b4448df43 Fix Mail Automation permission: missing entitlement, not an OS bug
Confab.entitlements was missing com.apple.security.automation.apple-events,
so tccd's hardened-runtime policy silently refused to even prompt for
Automation consent to Mail.app — confirmed via tccd's own log, the same
failure class as the earlier Calendar/Contacts entitlement bug. Unhides
the Mail integration (MailTools.isHiddenPendingAppleFix = false).
Live-verified: Request Access now grants successfully on macOS 27 beta 7.
2026-08-26 11:48:29 +02:00

3709 lines
165 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// SettingsView.swift
// Confab
//
// Settings and configuration screen
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import SwiftUI
import UniformTypeIdentifiers
import FoundationModels
/// One editable row in the External MCP Servers "add" sheet's env var / custom header lists.
private struct MCPKeyValuePair: Identifiable {
let id = UUID()
var key: String = ""
var value: String = ""
}
/// The MCP tab's sidebar-navigated sub-pages. `visibleCases` hides Personal Data/Mail while
/// their respective kill switches (`PersonalDataTools`/`MailTools.isHiddenPendingAppleFix`) are
/// active, mirroring the same gating those sections' own content already enforces.
private enum MCPSubsection: String, CaseIterable, Identifiable {
case fileSystem, bash, researchAgents, externalMCP, cliAccess, personalData, mail
var id: String { rawValue }
var label: LocalizedStringKey {
switch self {
case .fileSystem: return "File System"
case .bash: return "Bash Execution"
case .researchAgents: return "Research Agents"
case .externalMCP: return "External MCP"
case .cliAccess: return "CLI Access"
case .personalData: return "Personal Data"
case .mail: return "Mail"
}
}
var icon: String {
switch self {
case .fileSystem: return "folder.badge.gearshape"
case .bash: return "terminal.fill"
case .researchAgents: return "person.3.fill"
case .externalMCP: return "server.rack"
case .cliAccess: return "terminal"
case .personalData: return "person.crop.circle.badge.checkmark"
case .mail: return "envelope.badge"
}
}
static var visibleCases: [MCPSubsection] {
allCases.filter { section in
if section == .personalData { return !PersonalDataTools.isHiddenPendingAppleFix }
if section == .mail { return !MailTools.isHiddenPendingAppleFix }
return true
}
}
}
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settingsService = SettingsService.shared
private var mcpService = MCPService.shared
private let gitSync = GitSyncService.shared
var chatViewModel: ChatViewModel?
init(chatViewModel: ChatViewModel? = nil) {
self.chatViewModel = chatViewModel
}
@State private var openrouterKey = ""
@State private var anthropicKey = ""
@State private var openaiKey = ""
@State private var googleKey = ""
@State private var googleEngineID = ""
@State private var selectedTab = 0
@State private var selectedMCPSubsection: MCPSubsection = .fileSystem
@State private var isFolderDropTargeted = false
@State private var logLevel: LogLevel = FileLogger.shared.minimumLevel
// Git Sync state
@State private var syncRepoURL = ""
@State private var syncLocalPath = "~/oAI-sync"
@State private var syncUsername = ""
@State private var syncPassword = ""
@State private var syncAccessToken = ""
@State private var showSyncPassword = false
@State private var showSyncToken = false
@State private var isTestingSync = false
@State private var syncTestResult: String?
@State private var isSyncing = false
// Anytype state
@State private var anytypeAPIKey = ""
@State private var anytypeURL = ""
@State private var showAnytypeKey = false
@State private var isTestingAnytype = false
@State private var anytypeTestResult: String?
// Jarvis state
@State private var jarvisURL = ""
@State private var jarvisAPIKey = ""
@State private var showJarvisKey = false
@State private var isTestingJarvis = false
@State private var jarvisTestResult: String?
// Default model picker state
@State private var showDefaultModelPicker = false
// External MCP Servers state
@State private var showAddExternalMCPServer = false
@State private var newMCPServerName = ""
@State private var newMCPServerTransportKind: MCPTransportKind = .stdio
@State private var newMCPServerCommand = ""
@State private var newMCPServerArgs = ""
@State private var newMCPServerEnvPairs: [MCPKeyValuePair] = []
@State private var newMCPServerURL = ""
@State private var newMCPServerBearerToken = ""
@State private var newMCPServerHeaderPairs: [MCPKeyValuePair] = []
@State private var newMCPServerTimeout: Double = 30
private var externalMCPManager = ExternalMCPManager.shared
// Personal Data state (Calendar/Reminders/Contacts/Location/Maps)
@State private var calendarAccessState = EventKitService.shared.calendarAccessState
@State private var remindersAccessState = EventKitService.shared.reminderAccessState
@State private var contactsAccessState = ContactsService.shared.accessState
@State private var locationAccessState = LocationMapsService.shared.accessState
// Paperless-NGX state
@State private var paperlessURL = ""
@State private var paperlessToken = ""
@State private var showPaperlessToken = false
@State private var isTestingPaperless = false
@State private var paperlessTestResult: String?
// Backup state
private let backupService = BackupService.shared
@State private var isExporting = false
@State private var isImporting = false
@State private var backupMessage: String?
@State private var backupMessageIsError = false
@State private var showRestoreFilePicker = false
// Email handler state
@State private var showEmailLog = false
@State private var showEmailModelSelector = false
@State private var emailHandlerSystemPrompt = ""
@State private var emailAvailableModels: [ModelInfo] = []
@State private var isLoadingEmailModels = false
@State private var showEmailPassword = false
@State private var isTestingEmailConnection = false
@State private var emailConnectionTestResult: String?
// CLI server state
@State private var showCLIModelSelector = false
@State private var cliAvailableModels: [ModelInfo] = []
@State private var isLoadingCLIModels = false
// Mail state
@State private var isTestingMail = false
@State private var mailTestResult: String?
@State private var mailAccessState: PersonalDataAccessState = AppleMailService.shared.accessState
@State private var mailAccessNote: String?
private let labelWidth: CGFloat = 160
// Default system prompt - generic for all models
private let defaultSystemPrompt = """
You are a helpful AI assistant. Follow these core principles:
## CORE BEHAVIOR
- **Accuracy First**: Never invent information. If unsure, say so clearly.
- **Ask for Clarification**: When ambiguous, ask questions before proceeding.
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
- **Complete Tasks Properly**: If you start something, finish it correctly.
- **Match the User's Language**: Always reply in the same language the user is writing in, even when the request itself involves another language (e.g. a translation or spelling request) — the target language applies to the content you produce, not to your own reply.
## FORMATTING
Always use Markdown formatting:
- **Bold** for emphasis
- Code blocks with language tags: ```python
- Headings (##, ###) for structure
- Lists for organization
## HONESTY
It's better to admit "I need more information" or "I cannot do that" than to fake completion or invent answers.
"""
var body: some View {
VStack(spacing: 0) {
// Header: close button (left) + active tab title (center)
ZStack(alignment: .leading) {
Text(tabTitle(selectedTab))
.font(.system(size: 15, weight: .semibold))
.frame(maxWidth: .infinity)
Button(action: { dismiss() }) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.padding(.leading, 14)
}
.padding(.top, 14)
.padding(.bottom, 4)
// Icon toolbar — Core | separator | Extras
GlassEffectContainer(spacing: 8) {
HStack(spacing: 0) {
tabButton(0, icon: "gear", label: "General")
tabButton(1, icon: "folder.badge.gearshape", label: "MCP")
tabButton(2, icon: "paintbrush", label: "Appearance")
tabButton(3, icon: "slider.horizontal.3", label: "Advanced")
Divider().frame(height: 44).padding(.horizontal, 4)
tabButton(6, icon: "command", label: "Shortcuts")
tabButton(7, icon: "brain", label: "Skills")
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
tabButton(5, icon: "envelope", label: "Email")
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
tabButton(11, icon: "server.rack", label: "Jarvis")
}
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
Divider()
// MCP gets its own sidebar-navigated layout outside the shared ScrollView below —
// nesting a plain ScrollView inside another ScrollView without an explicit height
// just sizes to content instead of scrolling independently, so its content pane
// needs to own its own top-level scroll region for the sidebar to stay pinned.
if selectedTab == 1 {
mcpTabWithSidebar
} else {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch selectedTab {
case 0:
generalTab
case 2:
appearanceTab
case 3:
advancedTab
case 4:
syncTab
case 5:
emailTab
case 6:
shortcutsTab
case 7:
agentSkillsTab
case 8:
paperlessTab
case 9:
backupTab
case 10:
anytypeTab
case 11:
jarvisTab
default:
generalTab
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
}
}
.frame(minWidth: 900, idealWidth: 1000, minHeight: 620, idealHeight: 760)
.sheet(isPresented: $showDefaultModelPicker) {
ModelSelectorView(
models: chatViewModel?.availableModels ?? [],
selectedModel: chatViewModel?.availableModels.first(where: { $0.id == settingsService.defaultModel }),
onSelect: { model in
let provider = chatViewModel.flatMap { vm in
vm.inferProviderPublic(from: model.id)
} ?? settingsService.defaultProvider
settingsService.defaultModel = model.id
settingsService.defaultProvider = provider
showDefaultModelPicker = false
}
)
}
.sheet(isPresented: $showEmailLog) {
EmailLogView()
}
// Duplicated (not moved) from ChatView.swift: SwiftUI won't stack a new sheet on top of
// this already-presented Settings sheet if the .sheet(item:) only lives on ChatView, which
// sits underneath/behind Settings once it's open — a conflict triggered by the "Sync Now"
// button in here would be silently dropped with no visible modal. Attaching the same
// binding here too lets it present correctly regardless of which one is on top; only the
// currently-frontmost host actually shows it, so there's no double-presentation risk.
.sheet(item: Binding(
get: { gitSync.pendingGitConflict },
set: { _ in }
)) { pending in
GitSyncConflictSheet(
pending: pending,
onFixForMe: { await gitSync.autoResolveUntrackedConflict(pending) },
onFixMyself: { gitSync.showManualFixInstructions(for: pending) },
onDismiss: { gitSync.dismissPendingGitConflict() }
)
}
.sheet(item: Binding(
get: { gitSync.pendingManualFixInstructions },
set: { _ in }
)) { pending in
GitSyncManualFixSheet(
files: pending.files,
syncPath: SettingsService.shared.syncLocalPath,
onDone: { gitSync.dismissManualFixInstructions() }
)
}
.fileImporter(
isPresented: $showRestoreFilePicker,
allowedContentTypes: [.json],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
Task { await performRestore(from: url) }
case .failure(let error):
backupMessage = "Could not open file: \(error.localizedDescription)"
backupMessageIsError = true
}
}
}
// MARK: - General Tab
@ViewBuilder
private var generalTab: some View {
// Provider
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Provider")
formSection {
row("Default Provider") {
Picker("", selection: $settingsService.defaultProvider) {
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
Text(provider.displayName).tag(provider)
}
}
.labelsHidden()
.fixedSize()
}
}
}
// API Keys
VStack(alignment: .leading, spacing: 6) {
sectionHeader("API Keys")
formSection {
row("OpenRouter") {
SecureField("sk-or-...", text: $openrouterKey)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13))
.frame(width: 360)
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
.onChange(of: openrouterKey) {
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
ProviderRegistry.shared.clearCache()
}
}
rowDivider()
row("Anthropic") {
SecureField("sk-ant-... (API key)", text: $anthropicKey)
.textFieldStyle(.roundedBorder)
.frame(width: 360)
.onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" }
.onChange(of: anthropicKey) {
settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey
ProviderRegistry.shared.clearCache()
}
}
rowDivider()
row("OpenAI") {
SecureField("sk-...", text: $openaiKey)
.textFieldStyle(.roundedBorder)
.frame(width: 360)
.onAppear { openaiKey = settingsService.openaiAPIKey ?? "" }
.onChange(of: openaiKey) {
settingsService.openaiAPIKey = openaiKey.isEmpty ? nil : openaiKey
ProviderRegistry.shared.clearCache()
}
}
rowDivider()
row("Ollama URL") {
TextField("http://localhost:11434", text: $settingsService.ollamaBaseURL)
.textFieldStyle(.roundedBorder)
.frame(width: 360)
.help("Enter your Ollama server URL to enable the Ollama provider")
}
}
}
// Apple Intelligence
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Apple Intelligence")
formSection {
row("Status") {
appleIntelligenceStatusBadge
}
rowDivider()
row("Model") {
Text("On-Device (4K context)")
.foregroundStyle(.secondary)
}
rowDivider()
row("") {
Button("Open Apple Intelligence Settings") {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.aisettings") {
NSWorkspace.shared.open(url)
}
}
}
VStack(alignment: .leading, spacing: 2) {
Text("⚠️ Beta — Apple's on-device model is still in active development (currently macOS 27 beta). Expect rough edges: a small 4K context window, occasional generation errors, and no tool support yet.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
}
// Features
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Features")
formSection {
row("Online Mode (Web Search)") {
Toggle("", isOn: $settingsService.onlineMode)
.toggleStyle(.switch)
}
rowDivider()
row("Conversation Memory") {
Toggle("", isOn: $settingsService.memoryEnabled)
.toggleStyle(.switch)
}
rowDivider()
row("Reasoning (Thinking)") {
Toggle("", isOn: $settingsService.reasoningEnabled)
.toggleStyle(.switch)
}
if settingsService.reasoningEnabled {
rowDivider()
row("Reasoning Effort") {
Picker("", selection: $settingsService.reasoningEffort) {
Text("High (~80%)").tag("high")
Text("Medium (~50%)").tag("medium")
Text("Low (~20%)").tag("low")
Text("Minimal (~10%)").tag("minimal")
}
.labelsHidden()
.fixedSize()
}
VStack(alignment: .leading, spacing: 2) {
Text(reasoningEffortDescription)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
rowDivider()
row("Hide Reasoning in Response") {
Toggle("", isOn: $settingsService.reasoningExclude)
.toggleStyle(.switch)
}
VStack(alignment: .leading, spacing: 2) {
Text("Model thinks internally but reasoning is not shown in chat")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
}
}
// Crash Recovery
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Crash Recovery")
formSection {
row("Save Draft Every") {
Picker("", selection: $settingsService.draftRecoveryIntervalSeconds) {
Text("Off").tag(0)
Text("1 second").tag(1)
Text("10 seconds").tag(10)
Text("30 seconds").tag(30)
Text("60 seconds").tag(60)
}
.labelsHidden()
.fixedSize()
}
VStack(alignment: .leading, spacing: 2) {
Text("Mirrors your in-progress conversation to disk so a crash or force-quit doesn't lose it. Never shown as a saved conversation.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
}
// Web Search
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Web Search")
formSection {
row("Search Provider") {
Picker("", selection: $settingsService.searchProvider) {
ForEach(Settings.SearchProvider.allCases, id: \.self) { provider in
Text(provider.displayName).tag(provider)
}
}
.labelsHidden()
.fixedSize()
}
// Always visible (not gated behind Search Provider == .google): this key is also
// used for Google embeddings in Semantic Search (Advanced tab), which has nothing
// to do with which web search provider is selected. Search Engine ID is web-search
// specific, so that one stays conditional.
rowDivider()
row("Google API Key") {
SecureField("", text: $googleKey)
.textFieldStyle(.roundedBorder)
.frame(width: 300)
.onAppear { googleKey = settingsService.googleAPIKey ?? "" }
.onChange(of: googleKey) {
settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey
}
}
VStack(alignment: .leading, spacing: 2) {
Text("Used for Google web search (if selected above) and for Google embeddings in Settings → Advanced → Semantic Search.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
if settingsService.searchProvider == .google {
rowDivider()
row("Search Engine ID") {
TextField("", text: $googleEngineID)
.textFieldStyle(.roundedBorder)
.frame(width: 300)
.onAppear { googleEngineID = settingsService.googleSearchEngineID ?? "" }
.onChange(of: googleEngineID) {
settingsService.googleSearchEngineID = googleEngineID.isEmpty ? nil : googleEngineID
}
}
}
}
}
// Model Settings
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Model Settings")
formSection {
row("Default Model") {
HStack(spacing: 8) {
let modelName: String = {
if let id = settingsService.defaultModel {
return chatViewModel?.availableModels.first(where: { $0.id == id })?.name ?? id
}
return "Not set"
}()
Text(modelName)
.foregroundStyle(settingsService.defaultModel == nil ? .secondary : .primary)
.frame(maxWidth: 240, alignment: .leading)
Button("Choose…") { showDefaultModelPicker = true }
.buttonStyle(.borderless)
if settingsService.defaultModel != nil {
Button("Clear") {
settingsService.defaultModel = nil
settingsService.defaultProvider = .openrouter
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
}
}
}
}
}
// Logging
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Logging")
formSection {
row("Log Level") {
Picker("", selection: Binding(
get: { logLevel },
set: { logLevel = $0; FileLogger.shared.minimumLevel = $0 }
)) {
ForEach(LogLevel.allCases, id: \.self) { level in
Text(level.displayName).tag(level)
}
}
.labelsHidden()
.fixedSize()
}
}
}
Text("Controls which messages are written to ~/Library/Logs/Confab.log")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
}
// MARK: - MCP Tab
@ViewBuilder
private func mcpSidebarRow(_ section: MCPSubsection) -> some View {
Button(action: { selectedMCPSubsection = section }) {
Group {
if selectedMCPSubsection == section {
// Untinted — see tabButton's comment on why a colored tint here renders
// as a near-opaque block instead of translucent glass.
mcpSidebarRowLabel(section).glassEffect(.regular, in: .rect(cornerRadius: 8))
} else {
mcpSidebarRowLabel(section)
}
}
}
.buttonStyle(.plain)
.foregroundStyle(selectedMCPSubsection == section ? .blue : .primary)
}
@ViewBuilder
private func mcpSidebarRowLabel(_ section: MCPSubsection) -> some View {
HStack(spacing: 10) {
Image(systemName: section.icon)
.font(.system(size: 14))
.frame(width: 18)
Text(section.label)
.font(.system(size: 13))
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
}
@ViewBuilder
private var mcpTabWithSidebar: some View {
HStack(alignment: .top, spacing: 0) {
GlassEffectContainer(spacing: 4) {
VStack(alignment: .leading, spacing: 2) {
ForEach(MCPSubsection.visibleCases) { mcpSidebarRow($0) }
Spacer()
}
}
.padding(10)
.frame(width: 180)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch selectedMCPSubsection {
case .fileSystem: fileSystemSection
case .bash: bashExecutionSection
case .researchAgents: researchAgentsSection
case .externalMCP: externalMCPSection
case .cliAccess: cliServerSection
case .personalData: personalDataSection
case .mail: mailSection
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
}
}
@ViewBuilder
private var fileSystemSection: some View {
// Description header
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "folder.badge.gearshape")
.font(.title2)
.foregroundStyle(.blue)
Text("Model Context Protocol")
.font(.system(size: 18, weight: .semibold))
}
Text("MCP gives the AI controlled access to read and optionally write files on your computer. The AI can search, read, and analyze files in allowed folders to help with coding, analysis, and other tasks.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 8)
// Status
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Status")
formSection {
row("Enable MCP") {
Toggle("", isOn: $settingsService.mcpEnabled)
.toggleStyle(.switch)
}
}
}
HStack(spacing: 4) {
Image(systemName: settingsService.mcpEnabled ? "checkmark.circle.fill" : "circle")
.foregroundStyle(settingsService.mcpEnabled ? .green : .secondary)
.font(.system(size: 13))
Text(settingsService.mcpEnabled ? "Active - AI can access allowed folders" : "Disabled - No file access")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 4)
if settingsService.mcpEnabled {
// Folders
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Allowed Folders")
// Folder list — also a drop target for Finder drags
VStack(spacing: 0) {
if mcpService.allowedFolders.isEmpty {
VStack(spacing: 8) {
Image(systemName: "folder.badge.plus")
.font(.system(size: 32))
.foregroundStyle(isFolderDropTargeted ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary))
Text(isFolderDropTargeted ? "Drop to add folder" : "No folders added yet")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(isFolderDropTargeted ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.secondary))
if !isFolderDropTargeted {
Text("Click 'Add Folder' below or drag folders here from Finder")
.font(.system(size: 13))
.foregroundStyle(.tertiary)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
} else {
ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in
HStack(spacing: 8) {
Image(systemName: "folder.fill")
.foregroundStyle(.blue)
.frame(width: 20)
VStack(alignment: .leading, spacing: 0) {
Text((folder as NSString).lastPathComponent)
.font(.body)
Text(abbreviatePath(folder))
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
Spacer()
Button {
withAnimation { _ = mcpService.removeFolder(at: index) }
} label: {
Image(systemName: "trash.fill")
.foregroundStyle(.red)
.font(.system(size: 13))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
if index < mcpService.allowedFolders.count - 1 {
rowDivider()
}
}
}
}
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(
isFolderDropTargeted ? Color.accentColor : Color.primary.opacity(0.10),
lineWidth: isFolderDropTargeted ? 2 : 0.5
)
)
.onDrop(of: [.fileURL], isTargeted: $isFolderDropTargeted) { providers in
for provider in providers {
_ = provider.loadObject(ofClass: URL.self) { url, _ in
guard let url, url.hasDirectoryPath else { return }
DispatchQueue.main.async {
withAnimation { _ = mcpService.addFolder(url.path) }
}
}
}
return true
}
// Add Folder button
Button {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = true
panel.prompt = "Add"
panel.message = "Choose folders to allow AI access"
panel.begin { response in
guard response == .OK else { return }
for url in panel.urls {
withAnimation { _ = mcpService.addFolder(url.path) }
}
}
} label: {
Label("Add Folder…", systemImage: "plus")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Color.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
// Permissions
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Permissions")
formSection {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.system(size: 12))
Text("Read access (always enabled)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
Text("The AI can read and search files in allowed folders")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
.padding(.leading, 18)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
rowDivider()
row("Write & Edit Files") {
Toggle("", isOn: $settingsService.mcpCanWriteFiles)
.toggleStyle(.switch)
}
rowDivider()
row("Delete Files") {
Toggle("", isOn: $settingsService.mcpCanDeleteFiles)
.toggleStyle(.switch)
}
rowDivider()
row("Create Directories") {
Toggle("", isOn: $settingsService.mcpCanCreateDirectories)
.toggleStyle(.switch)
}
rowDivider()
row("Move & Copy Files") {
Toggle("", isOn: $settingsService.mcpCanMoveFiles)
.toggleStyle(.switch)
}
}
}
// Filtering
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Filtering")
formSection {
row("Respect .gitignore") {
Toggle("", isOn: Binding(
get: { settingsService.mcpRespectGitignore },
set: { newValue in
settingsService.mcpRespectGitignore = newValue
mcpService.reloadGitignores()
}
))
.toggleStyle(.switch)
}
}
}
Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
}
// Anytype integration UI hidden (work in progress — see AnytypeMCPService.swift)
}
@ViewBuilder
private var bashExecutionSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "terminal.fill")
.font(.title2)
.foregroundStyle(.orange)
Text("Bash Execution")
.font(.system(size: 18, weight: .semibold))
}
Text("Allow the AI to run shell commands on your machine. Commands are executed via /bin/zsh. Enable approval mode to review each command before it runs.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 4)
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Status")
formSection {
row("Enable Bash Execution") {
Toggle("", isOn: $settingsService.bashEnabled)
.toggleStyle(.switch)
}
}
}
HStack(spacing: 4) {
Image(systemName: settingsService.bashEnabled ? "checkmark.circle.fill" : "circle")
.foregroundStyle(settingsService.bashEnabled ? .orange : .secondary)
.font(.system(size: 13))
Text(settingsService.bashEnabled ? "Active — AI can run shell commands" : "Disabled")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 4)
if settingsService.bashEnabled {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Settings")
formSection {
row("Require Approval") {
Toggle("", isOn: $settingsService.bashRequireApproval)
.toggleStyle(.switch)
}
rowDivider()
row("Working Directory") {
TextField("~", text: $settingsService.bashWorkingDirectory)
.textFieldStyle(.plain)
.font(.system(size: 13, design: .monospaced))
.multilineTextAlignment(.trailing)
.frame(width: 200)
}
rowDivider()
row("Timeout (seconds)") {
HStack(spacing: 8) {
Stepper("", value: $settingsService.bashTimeout, in: 5...300, step: 5)
.labelsHidden()
Text("\(settingsService.bashTimeout)s")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 36, alignment: .trailing)
}
}
}
}
if settingsService.bashRequireApproval {
HStack(spacing: 6) {
Image(systemName: "hand.raised.fill")
.font(.system(size: 12))
.foregroundStyle(.orange)
Text("Each command will require your approval before running.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 4)
} else {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 12))
.foregroundStyle(.red)
.padding(.top, 1)
Text("Auto-execute mode: commands run without approval. Use with caution.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 4)
}
}
}
@ViewBuilder
private var researchAgentsSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "person.3.fill")
.font(.title2)
.foregroundStyle(.indigo)
Text("Research Agents")
.font(.system(size: 18, weight: .semibold))
}
Text("Let the AI spawn read-only research sub-agents to investigate multiple things in parallel (read files, list/search directories, search the web — no writing, no bash). Intended for genuinely independent research tasks, not everyday questions.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 4)
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Status")
formSection {
row("Enable Research Agents") {
Toggle("", isOn: $settingsService.agentsEnabled)
.toggleStyle(.switch)
}
}
}
HStack(alignment: .top, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 12))
.foregroundStyle(.orange)
.padding(.top, 1)
Text("Cost warning: each sub-agent runs its own full chain of model calls. A single request that spawns several agents can cost several times a normal reply. The AI is instructed to only use this for genuinely parallel research, but model behavior can vary — leave this off unless you want that tradeoff.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 4)
if settingsService.agentsEnabled {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Settings")
formSection {
row("Max Concurrent Agents") {
HStack(spacing: 8) {
Stepper("", value: $settingsService.maxConcurrentAgents, in: 1...5)
.labelsHidden()
Text("\(settingsService.maxConcurrentAgents)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 24, alignment: .trailing)
}
}
}
}
}
}
@ViewBuilder
private var personalDataSection: some View {
if !PersonalDataTools.isHiddenPendingAppleFix {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "person.crop.circle.badge.checkmark")
.font(.title2)
.foregroundStyle(.teal)
Text("Personal Data")
.font(.system(size: 18, weight: .semibold))
}
Text("Let the AI access your Calendar, Reminders, Contacts, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses standard macOS permission prompts. This functionality is in beta and may change.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 4)
.onAppear {
// Permission status can change outside the app (System Settings, or a prior
// request elsewhere) — re-read it fresh every time this page appears rather than
// trusting the one-time @State initializer.
calendarAccessState = EventKitService.shared.calendarAccessState
remindersAccessState = EventKitService.shared.reminderAccessState
contactsAccessState = ContactsService.shared.accessState
locationAccessState = LocationMapsService.shared.accessState
}
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Services")
formSection {
personalDataRow(
title: "Calendar",
isEnabled: $settingsService.calendarEnabled,
state: calendarAccessState,
systemSettingsAnchor: "Privacy_Calendars",
requestAccess: { calendarAccessState = await EventKitService.shared.requestCalendarAccess() ? .granted : EventKitService.shared.calendarAccessState }
)
rowDivider()
personalDataRow(
title: "Reminders",
isEnabled: $settingsService.remindersEnabled,
state: remindersAccessState,
systemSettingsAnchor: "Privacy_Reminders",
requestAccess: { remindersAccessState = await EventKitService.shared.requestReminderAccess() ? .granted : EventKitService.shared.reminderAccessState }
)
rowDivider()
personalDataRow(
title: "Contacts",
isEnabled: $settingsService.contactsEnabled,
state: contactsAccessState,
systemSettingsAnchor: "Privacy_Contacts",
requestAccess: { contactsAccessState = await ContactsService.shared.requestAccess() ? .granted : ContactsService.shared.accessState }
)
rowDivider()
personalDataRow(
title: "Location & Maps",
isEnabled: $settingsService.locationMapsEnabled,
state: locationAccessState,
systemSettingsAnchor: "Privacy_LocationServices",
requestAccess: { locationAccessState = await LocationMapsService.shared.requestAccess() ? .granted : LocationMapsService.shared.accessState }
)
}
}
if settingsService.calendarEnabled || settingsService.remindersEnabled {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Write Actions")
formSection {
row("Require Approval for Changes") {
Toggle("", isOn: $settingsService.personalDataRequireApproval)
.toggleStyle(.switch)
}
}
}
Text("Creating calendar events or reminders, and completing reminders, will ask for your approval first.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
}
}
}
@ViewBuilder
private var mailSection: some View {
// MailTools.isHiddenPendingAppleFix: was a missing entitlement, not an OS bug — fixed, see
// feature_mail_applescript_integration in memory. Kill switch kept as infrastructure.
if !MailTools.isHiddenPendingAppleFix {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "envelope.badge")
.font(.title2)
.foregroundStyle(.teal)
Text("Mail")
.font(.system(size: 18, weight: .semibold))
}
Text("Let the AI search your Apple Mail inbox, read messages, and save attachments to disk (e.g. to hand off to Paperless). Uses AppleScript to talk to Mail.app — no separate credentials needed.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 4)
.onAppear {
mailAccessState = AppleMailService.shared.accessState
}
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Apple Mail")
formSection {
personalDataRow(
title: "Mail",
isEnabled: $settingsService.mailEnabled,
state: mailAccessState,
systemSettingsAnchor: "Privacy_Automation",
requestAccess: {
switch await AppleMailService.shared.requestAccess() {
case .granted:
mailAccessState = .granted
mailAccessNote = nil
case .denied:
mailAccessState = .denied
mailAccessNote = nil
case .suspectedPlatformBug:
// Known macOS 27 beta issue (see AppleMailService.requestAccess doc
// comment) — the OS never shows the consent dialog for this app, so
// there's nothing more to try in-app. Send the user to System Settings
// directly rather than leaving the button looking like it did nothing.
mailAccessState = .denied
mailAccessNote = "macOS isn't showing the permission prompt (a known macOS 27 beta issue) — opened System Settings instead. If Confab isn't listed there under Automation, this can't be granted until Apple fixes it."
openPrivacySettings(anchor: "Privacy_Automation")
}
}
)
if let mailAccessNote {
rowDivider()
Text(mailAccessNote)
.font(.system(size: 12))
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
if settingsService.mailEnabled {
rowDivider()
row("Require Approval for Every Action") {
Toggle("", isOn: $settingsService.mailRequireApproval)
.toggleStyle(.switch)
}
rowDivider()
HStack(spacing: 12) {
Button(action: { Task { await testMailConnection() } }) {
HStack {
if isTestingMail {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "checkmark.circle")
}
Text("Test Connection")
}
}
.disabled(isTestingMail)
if let result = mailTestResult {
Text(result)
.font(.system(size: 13))
.foregroundStyle(result.hasPrefix("✓") ? .green : .red)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
}
}
}
// MARK: - External MCP Servers Section
@ViewBuilder
private var externalMCPSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "server.rack")
.font(.title2)
.foregroundStyle(.purple)
Text("External MCP Servers")
.font(.system(size: 18, weight: .semibold))
}
Text("Connect any stdio MCP server (e.g. safaridriver --mcp) to give the AI access to its tools. Tool names are prefixed with the server slug.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 4)
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Configured Servers")
formSection {
if settingsService.externalMCPServers.isEmpty {
VStack(spacing: 8) {
Image(systemName: "server.rack")
.font(.system(size: 32))
.foregroundStyle(.tertiary)
Text("No external servers configured")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
Text("Add a server below, e.g.: safaridriver --mcp")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else {
ForEach(settingsService.externalMCPServers) { server in
HStack(spacing: 10) {
Circle()
.fill(mcpStatusColor(externalMCPManager.clientStates[server.id]))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(server.name)
.font(.system(size: 14))
Text(server.transportKind == .http ? server.url : ([server.command] + server.args).joined(separator: " "))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Text(mcpStatusLabel(externalMCPManager.clientStates[server.id]))
.font(.system(size: 11))
.foregroundStyle(.secondary)
Toggle("", isOn: Binding(
get: { server.isEnabled },
set: { _ in settingsService.toggleExternalMCPServer(id: server.id) }
))
.toggleStyle(.switch)
.labelsHidden()
Button {
settingsService.deleteExternalMCPServer(id: server.id)
} label: {
Image(systemName: "trash.fill")
.foregroundStyle(.red)
.font(.system(size: 13))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
if server.id != settingsService.externalMCPServers.last?.id {
rowDivider()
}
}
}
}
Button {
newMCPServerName = ""
newMCPServerTransportKind = .stdio
newMCPServerCommand = ""
newMCPServerArgs = ""
newMCPServerEnvPairs = []
newMCPServerURL = ""
newMCPServerBearerToken = ""
newMCPServerHeaderPairs = []
newMCPServerTimeout = 30
showAddExternalMCPServer = true
} label: {
Label("Add Server…", systemImage: "plus")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Color.purple)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
.sheet(isPresented: $showAddExternalMCPServer) {
addExternalMCPServerSheet
}
}
// MARK: - CLI Access Section
@ViewBuilder
private var cliServerSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "terminal")
.font(.title2)
.foregroundStyle(.green)
Text("CLI Access")
.font(.system(size: 18, weight: .semibold))
}
Text("Expose a local socket so a shell command (like a zsh \"ai\" function) can get a one-shot text reply from a single fixed model, without opening the app window. Confab must be running.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 4)
.onAppear {
Task { await loadCLIModels() }
}
.sheet(isPresented: $showCLIModelSelector) {
ModelSelectorView(
models: cliAvailableModels.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending },
selectedModel: cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })
) { selectedModel in
settingsService.cliServerModel = selectedModel.id
showCLIModelSelector = false
CLIServerService.shared.restart()
}
}
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Status")
formSection {
row("Enable CLI Access") {
Toggle("", isOn: $settingsService.cliServerEnabled)
.toggleStyle(.switch)
.onChange(of: settingsService.cliServerEnabled) {
CLIServerService.shared.restart()
}
}
}
}
if settingsService.cliServerEnabled {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Model")
formSection {
row("Provider") {
Picker("", selection: $settingsService.cliServerProvider) {
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
Text(provider.displayName).tag(provider.rawValue)
}
}
.labelsHidden()
.frame(width: 250)
.onChange(of: settingsService.cliServerProvider) {
Task { await loadCLIModels() }
CLIServerService.shared.restart()
}
}
rowDivider()
row("Model") {
if isLoadingCLIModels {
ProgressView().scaleEffect(0.7).frame(width: 250, alignment: .leading)
} else if cliAvailableModels.isEmpty {
Text("No models available")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.secondary)
.frame(width: 250, alignment: .leading)
} else {
Button(action: { showCLIModelSelector = true }) {
HStack {
Text(cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })?.name ?? "Select model...")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 10))
.foregroundColor(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(width: 250)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
}
}
}
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Shell Function")
Text("Add this to your ~/.zshrc, then run `ai \"your prompt\"` in Terminal:")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(Self.cliShellFunctionSnippet)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.primary)
.textSelection(.enabled)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
Text("Requires jq (brew install jq).")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
}
}
}
// `noglob` (applied via the alias, not inside the function) stops zsh from trying to glob-expand
// an unquoted prompt containing `?`/`*`/`[...]` — e.g. `ai Who are you?` — before `_ai_impl` ever
// runs. A plain `ai() { ... }` function can't protect its own call site this way: filename
// generation happens during command-line parsing, before the shell has even decided this is a
// function call. Quoting the prompt is still the fully robust habit for other shell metacharacters
// (`;`, `|`, backticks, `$()`), but this covers the specific class of crash users are most likely
// to hit by accident (an unquoted question ending in "?").
// Deliberately one line, no backslash continuations: a `\` line-continuation is silently
// broken by a trailing space or a dropped backslash from copy/pasting out of a chat UI or
// browser — each following line then gets parsed as its own bogus command ("command not
// found: -H", etc.). A long single line has no continuation character to mangle.
private static let cliShellFunctionSnippet = """
_ai_impl() {
curl -s --unix-socket "$HOME/Library/Application Support/oAI/cli.sock" -H "Content-Type: application/json" -d "$(jq -n --arg p "$*" '{prompt: $p}')" http://localhost/ | jq -r 'if .error then "Error: " + .error else .response end'
}
alias ai='noglob _ai_impl'
"""
private func loadCLIModels() async {
guard settingsService.cliServerEnabled else {
cliAvailableModels = []
return
}
let providerRawValue = settingsService.cliServerProvider
guard let providerType = Settings.Provider(rawValue: providerRawValue),
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
cliAvailableModels = []
return
}
isLoadingCLIModels = true
defer { isLoadingCLIModels = false }
do {
let models = try await provider.listModels()
cliAvailableModels = models
if !models.contains(where: { $0.id == settingsService.cliServerModel }) {
if let firstModel = models.first {
settingsService.cliServerModel = firstModel.id
}
}
} catch {
Log.ui.error("Failed to load CLI server models: \(error.localizedDescription)")
cliAvailableModels = []
}
}
@ViewBuilder
private var addExternalMCPServerSheet: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Add External MCP Server")
.font(.system(size: 16, weight: .semibold))
.frame(maxWidth: .infinity, alignment: .center)
formSection {
row("Name") {
TextField("Safari", text: $newMCPServerName)
.textFieldStyle(.roundedBorder)
.frame(width: 240)
}
rowDivider()
row("Type") {
Picker("", selection: $newMCPServerTransportKind) {
Text("Command").tag(MCPTransportKind.stdio)
Text("Remote (HTTP)").tag(MCPTransportKind.http)
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(width: 240)
.help("Command: a local program Confab launches itself. Remote (HTTP): an already-running MCP server reachable by URL.")
}
rowDivider()
switch newMCPServerTransportKind {
case .stdio:
row("Command") {
TextField("safaridriver", text: $newMCPServerCommand)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
}
rowDivider()
row("Arguments") {
TextField("--mcp", text: $newMCPServerArgs)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
.help("Space-separated arguments")
}
case .http:
row("Server URL") {
TextField("http://127.0.0.1:27123/mcp/", text: $newMCPServerURL)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
}
rowDivider()
row("Bearer Token") {
SecureField("Optional", text: $newMCPServerBearerToken)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
}
}
rowDivider()
row("Timeout") {
HStack(spacing: 8) {
Stepper("", value: $newMCPServerTimeout, in: 5...120, step: 5)
.labelsHidden()
Text("\(Int(newMCPServerTimeout))s")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 32, alignment: .leading)
}
}
}
formSection {
switch newMCPServerTransportKind {
case .stdio:
mcpKeyValueEditor(title: "Environment Variables", pairs: $newMCPServerEnvPairs)
case .http:
mcpKeyValueEditor(title: "Extra Headers", pairs: $newMCPServerHeaderPairs)
}
}
if !newMCPServerName.isEmpty {
let slug = ExternalMCPServer.makeSlug(from: newMCPServerName)
HStack(spacing: 6) {
Text("Tool prefix:")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text("\(slug)_")
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
}
if ExternalMCPServer.reservedSlugs.contains(slug) {
Text("'\(slug)' is a reserved prefix. Choose a different name.")
.font(.system(size: 12))
.foregroundStyle(.red)
}
}
HStack {
Button("Cancel") { showAddExternalMCPServer = false }
Spacer()
Button("Add") {
let server: ExternalMCPServer
switch newMCPServerTransportKind {
case .stdio:
server = ExternalMCPServer(
name: newMCPServerName,
transportKind: .stdio,
command: newMCPServerCommand,
args: ExternalMCPServer.parseArguments(newMCPServerArgs),
env: mcpDictionary(from: newMCPServerEnvPairs),
timeout: newMCPServerTimeout
)
case .http:
server = ExternalMCPServer(
name: newMCPServerName,
transportKind: .http,
url: newMCPServerURL,
bearerToken: newMCPServerBearerToken,
headers: mcpDictionary(from: newMCPServerHeaderPairs),
timeout: newMCPServerTimeout
)
}
settingsService.addExternalMCPServer(server)
showAddExternalMCPServer = false
}
.buttonStyle(.borderedProminent)
.disabled(isAddExternalMCPServerDisabled)
}
}
.padding(24)
.frame(minWidth: 460, minHeight: 320)
}
private var isAddExternalMCPServerDisabled: Bool {
if newMCPServerName.isEmpty { return true }
if ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)) { return true }
switch newMCPServerTransportKind {
case .stdio: return newMCPServerCommand.isEmpty
case .http: return newMCPServerURL.isEmpty
}
}
private func mcpDictionary(from pairs: [MCPKeyValuePair]) -> [String: String] {
Dictionary(uniqueKeysWithValues: pairs.filter { !$0.key.isEmpty }.map { ($0.key, $0.value) })
}
@ViewBuilder
private func mcpKeyValueEditor(title: LocalizedStringKey, pairs: Binding<[MCPKeyValuePair]>) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Button {
pairs.wrappedValue.append(MCPKeyValuePair())
} label: {
Image(systemName: "plus.circle.fill")
}
.buttonStyle(.plain)
}
ForEach(pairs) { $pair in
HStack(spacing: 6) {
TextField("Key", text: $pair.key)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12, design: .monospaced))
SecureField("Value", text: $pair.value)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12, design: .monospaced))
Button {
pairs.wrappedValue.removeAll { $0.id == pair.id }
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
if pairs.wrappedValue.isEmpty {
Text("None configured")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
private func mcpStatusColor(_ state: MCPClientState?) -> Color {
switch state {
case .ready: return .green
case .connecting: return .orange
case .error, .crashed: return .red
default: return Color(nsColor: .tertiaryLabelColor)
}
}
private func mcpStatusLabel(_ state: MCPClientState?) -> LocalizedStringKey {
switch state {
case .ready: return "Connected"
case .connecting: return "Connecting…"
case .error: return "Error"
case .crashed: return "Crashed"
default: return "Not started"
}
}
// MARK: - Appearance Tab
@ViewBuilder
private var appearanceTab: some View {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Text Sizes")
formSection {
row("GUI Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.guiTextSize, in: 10...20, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.guiTextSize)) pt")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
rowDivider()
row("Dialog Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.dialogTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.dialogTextSize)) pt")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
rowDivider()
row("Input Text") {
HStack(spacing: 8) {
Slider(value: $settingsService.inputTextSize, in: 10...24, step: 1)
.frame(maxWidth: 200)
Text("\(Int(settingsService.inputTextSize)) pt")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
}
}
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Toolbar")
formSection {
row("Icon Size") {
HStack(spacing: 8) {
Slider(value: $settingsService.toolbarIconSize, in: 16...40, step: 2)
.frame(maxWidth: 200)
Text("\(Int(settingsService.toolbarIconSize)) pt")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
rowDivider()
row("Show Icon Labels") {
Toggle("", isOn: $settingsService.showToolbarLabels)
.toggleStyle(.switch)
}
}
}
Text("Show text labels below toolbar icons (helpful for new users)")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
}
// MARK: - Advanced Tab
@ViewBuilder
private var advancedTab: some View {
// Response Generation
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Response Generation")
formSection {
row("Enable Streaming") {
Toggle("", isOn: $settingsService.streamEnabled)
.toggleStyle(.switch)
}
}
}
Text("Stream responses as they're generated. Disable for single, complete responses.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
// Model Parameters
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Model Parameters")
formSection {
row("Max Tokens") {
HStack(spacing: 8) {
Slider(value: Binding(
get: { Double(settingsService.maxTokens) },
set: { settingsService.maxTokens = Int($0) }
), in: 0...32000, step: 256)
.frame(maxWidth: 250)
Text(settingsService.maxTokens == 0 ? "Default" : "\(settingsService.maxTokens)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .leading)
}
}
rowDivider()
row("Temperature") {
HStack(spacing: 8) {
Slider(value: $settingsService.temperature, in: 0...2, step: 0.1)
.frame(maxWidth: 250)
Text(settingsService.temperature == 0.0 ? "Default" : String(format: "%.1f", settingsService.temperature))
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .leading)
}
}
}
}
VStack(alignment: .leading, spacing: 2) {
Text("Max Tokens: set to 0 to use model default. Higher values allow longer responses.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Text("Temperature: 0 = model default · 0.00.7 = focused · 0.82.0 = creative")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
// System Prompts
VStack(alignment: .leading, spacing: 6) {
sectionHeader("System Prompts")
formSection {
// Default prompt (read-only)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
Text("Default Prompt")
.font(.system(size: 14))
.fontWeight(.medium)
Text("(always used)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
ScrollView {
Text(defaultSystemPrompt)
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.frame(height: 140)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2), lineWidth: 1))
Text("This default prompt is always included to ensure accurate, helpful responses.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
rowDivider()
// Custom prompt mode toggle
row("Use Only Your Prompt") {
HStack(spacing: 8) {
Toggle("", isOn: Binding(
get: { settingsService.customPromptMode == .replace },
set: { settingsService.customPromptMode = $0 ? .replace : .append }
))
.toggleStyle(.switch)
.labelsHidden()
Text(settingsService.customPromptMode == .replace ? "BYOP Mode" : "Default + Custom")
.font(.system(size: 13))
.foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .secondary)
}
}
rowDivider()
// Custom prompt (editable)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
Text(settingsService.customPromptMode == .replace
? "Now using only your prompt shown below"
: "Your Custom Prompt")
.font(.system(size: 14))
.fontWeight(.medium)
.foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .primary)
if settingsService.customPromptMode == .append {
Text("(optional)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
TextEditor(text: Binding(
get: { settingsService.systemPrompt ?? "" },
set: { settingsService.systemPrompt = $0.isEmpty ? nil : $0 }
))
.font(.system(size: 13, design: .monospaced))
.frame(height: 100)
.padding(8)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.3), lineWidth: 1))
Text(settingsService.customPromptMode == .append
? "This will be added after the default prompt and tool-specific guidelines."
: "⚠️ In BYOP mode, ONLY your custom prompt will be used. Default prompt and tool guidelines are disabled.")
.font(.system(size: 13))
.foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
// Memory & Context
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Memory & Context")
formSection {
row("Smart Context Selection") {
Toggle("", isOn: $settingsService.contextSelectionEnabled)
.toggleStyle(.switch)
}
if settingsService.contextSelectionEnabled {
rowDivider()
row("Max Context Tokens") {
HStack(spacing: 8) {
TextField("", value: $settingsService.contextMaxTokens, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 120)
Text("tokens")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
}
}
}
Text("Automatically select relevant messages instead of sending all history. Reduces token usage for long conversations.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
// Conversation Notes
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Conversation Notes")
formSection {
row("Notes Folder") {
Button("Open Notes Folder") {
ConversationNotesService.shared.openNotesFolder()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
Text("Each conversation can keep its own persistent notes.md file, read and written automatically by the AI once turned on with /notes on. Use this to browse or edit notes files directly in Finder.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
// Semantic Search
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Semantic Search")
formSection {
row("Enable Embeddings") {
Toggle("", isOn: $settingsService.embeddingsEnabled)
.toggleStyle(.switch)
.disabled(!EmbeddingService.shared.isAvailable)
}
if settingsService.embeddingsEnabled {
rowDivider()
row("Model") {
Picker("", selection: $settingsService.embeddingProvider) {
if settingsService.openaiAPIKey != nil && !settingsService.openaiAPIKey!.isEmpty {
Text("OpenAI (text-embedding-3-small)").tag("openai-small")
Text("OpenAI (text-embedding-3-large)").tag("openai-large")
}
if settingsService.openrouterAPIKey != nil && !settingsService.openrouterAPIKey!.isEmpty {
Text("OpenRouter (OpenAI small)").tag("openrouter-openai-small")
Text("OpenRouter (OpenAI large)").tag("openrouter-openai-large")
Text("OpenRouter (Qwen 8B)").tag("openrouter-qwen")
}
if settingsService.googleAPIKey != nil && !settingsService.googleAPIKey!.isEmpty {
Text("Google (Gemini embedding)").tag("google-gemini")
}
}
.pickerStyle(.menu)
}
}
}
}
if let provider = EmbeddingService.shared.getBestAvailableProvider() {
Text("Enable AI-powered semantic search using \(provider.displayName) embeddings. Cost: ~$0.020.15/1M tokens.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
} else {
Text("⚠️ No embedding providers available. Configure an API key for OpenAI, OpenRouter, or Google (under General → Web Search) in the General tab.")
.font(.system(size: 13))
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
}
if settingsService.embeddingsEnabled {
HStack {
Button("Embed All Conversations") {
Task {
if let chatVM = chatViewModel {
await chatVM.batchEmbedAllConversations()
}
}
}
.help("Generate embeddings for all existing messages (one-time operation)")
Spacer()
}
.padding(.horizontal, 4)
Text("⚠️ One-time operation — generates embeddings for all messages. Estimated cost: ~$0.04 for 10,000 messages.")
.font(.system(size: 13))
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
}
// Progressive Summarization
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Progressive Summarization")
formSection {
row("Enable Summarization") {
Toggle("", isOn: $settingsService.progressiveSummarizationEnabled)
.toggleStyle(.switch)
}
if settingsService.progressiveSummarizationEnabled {
rowDivider()
row("Message Threshold") {
HStack(spacing: 8) {
TextField("", value: $settingsService.summarizationThreshold, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
Text("messages")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
}
}
}
Text("Automatically summarize old portions of long conversations to save tokens and improve context efficiency.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
// Info
VStack(alignment: .leading, spacing: 8) {
Text("⚠️ These are advanced settings")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.orange)
Text("Changing these values affects how the AI generates responses. The defaults work well for most use cases.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.background(Color.orange.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.orange.opacity(0.15), lineWidth: 0.5))
}
// MARK: - Sync Tab
@ViewBuilder
private var syncTab: some View {
Group {
// Enable toggle
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git Sync")
formSection {
row("Enable Git Sync") {
Toggle("", isOn: $settingsService.syncEnabled)
.toggleStyle(.switch)
}
}
}
Text("Sync conversations and settings across multiple machines using Git.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
if settingsService.syncEnabled {
// Status indicator
HStack(spacing: 8) {
Image(systemName: syncStatusIcon)
.foregroundStyle(syncStatusColor)
Text(syncStatusText)
.font(.system(size: 14))
.foregroundStyle(syncStatusColor)
}
.padding(.horizontal, 4)
// Connection
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Connection")
formSection {
row("URL") {
TextField("https://github.com/user/oai-sync.git", text: $syncRepoURL)
.textFieldStyle(.roundedBorder)
.onChange(of: syncRepoURL) {
settingsService.syncRepoURL = syncRepoURL
}
}
rowDivider()
row("Local Path") {
TextField("~/Library/Application Support/oAI/sync", text: $syncLocalPath)
.textFieldStyle(.roundedBorder)
.onChange(of: syncLocalPath) {
settingsService.syncLocalPath = syncLocalPath
}
}
}
}
Text("💡 Use HTTPS URL (e.g., https://gitlab.pm/user/repo.git) — works with all auth methods.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
// Authentication
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Authentication")
formSection {
row("Method") {
Picker("", selection: $settingsService.syncAuthMethod) {
Text("SSH Key").tag("ssh")
Text("Username + Password").tag("password")
Text("Access Token").tag("token")
}
.pickerStyle(.segmented)
.frame(width: 360)
}
if settingsService.syncAuthMethod == "ssh" {
VStack(alignment: .leading, spacing: 4) {
Text("️ SSH Key Authentication")
.font(.system(size: 13, weight: .semibold))
Text("• Uses your system SSH keys (~/.ssh/id_ed25519)")
.font(.system(size: 13))
Text("• Add public key to your git provider")
.font(.system(size: 13))
Text("• No credentials needed in Confab")
.font(.system(size: 13))
}
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
if settingsService.syncAuthMethod == "password" {
rowDivider()
row("Username") {
TextField("username", text: $syncUsername)
.textFieldStyle(.roundedBorder)
.onChange(of: syncUsername) {
settingsService.syncUsername = syncUsername.isEmpty ? nil : syncUsername
}
}
rowDivider()
row("Password") {
HStack {
if showSyncPassword {
TextField("", text: $syncPassword)
.textFieldStyle(.roundedBorder)
} else {
SecureField("", text: $syncPassword)
.textFieldStyle(.roundedBorder)
}
Button(action: { showSyncPassword.toggle() }) {
Image(systemName: showSyncPassword ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.onChange(of: syncPassword) {
settingsService.syncPassword = syncPassword.isEmpty ? nil : syncPassword
}
}
}
}
if settingsService.syncAuthMethod == "token" {
rowDivider()
row("Token") {
HStack {
if showSyncToken {
TextField("", text: $syncAccessToken)
.textFieldStyle(.roundedBorder)
} else {
SecureField("", text: $syncAccessToken)
.textFieldStyle(.roundedBorder)
}
Button(action: { showSyncToken.toggle() }) {
Image(systemName: showSyncToken ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.onChange(of: syncAccessToken) {
settingsService.syncAccessToken = syncAccessToken.isEmpty ? nil : syncAccessToken
}
}
}
}
rowDivider()
// Test connection row
HStack(spacing: 12) {
Button(action: { Task { await testSyncConnection() } }) {
HStack {
if isTestingSync {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "checkmark.circle")
}
Text("Test Connection")
}
}
.disabled(isTestingSync)
if let result = syncTestResult {
Text(result)
.font(.system(size: 13))
.foregroundStyle(result.hasPrefix("✓") ? .green : .red)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
if settingsService.syncAuthMethod == "password" {
Text("⚠️ Many providers (GitHub) no longer support password authentication. Use Access Token instead.")
.font(.system(size: 13))
.foregroundStyle(.orange)
.padding(.horizontal, 4)
}
if settingsService.syncAuthMethod == "token" {
if let tokenURL = tokenGenerationURL {
Link("→ Open \(extractProvider()) Settings to generate a token", destination: URL(string: tokenURL)!)
.font(.system(size: 13))
.padding(.horizontal, 4)
}
}
// Sync Options
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Sync Options")
formSection {
row("Auto-export on save") {
Toggle("", isOn: $settingsService.syncAutoExport)
.toggleStyle(.switch)
}
rowDivider()
row("Auto-pull on launch") {
Toggle("", isOn: $settingsService.syncAutoPull)
.toggleStyle(.switch)
}
}
}
// Manual Sync
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Manual Sync")
formSection {
HStack(spacing: 12) {
if !gitSync.syncStatus.isCloned {
Button {
Task { await cloneRepo() }
} label: {
HStack(spacing: 6) {
if isSyncing {
ProgressView().scaleEffect(0.7).frame(width: 16, height: 16)
} else {
Image(systemName: "arrow.down.circle")
}
Text("Clone Repository")
}
.frame(minWidth: 160)
}
.disabled(!settingsService.syncConfigured || isSyncing)
} else {
Button {
Task { await syncNow() }
} label: {
HStack(spacing: 6) {
if isSyncing {
ProgressView().scaleEffect(0.7).frame(width: 16, height: 16)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
}
Text("Sync Now")
}
.frame(minWidth: 160)
}
.disabled(isSyncing)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
if gitSync.syncStatus.isCloned {
VStack(alignment: .leading, spacing: 4) {
if let lastSync = gitSync.syncStatus.lastSyncTime {
Text("Last sync: \(timeAgo(lastSync))")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
if gitSync.syncStatus.uncommittedChanges > 0 {
Text("Uncommitted changes: \(gitSync.syncStatus.uncommittedChanges)")
.font(.system(size: 13))
.foregroundStyle(.orange)
}
if let branch = gitSync.syncStatus.currentBranch {
Text("Branch: \(branch)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
if let status = gitSync.syncStatus.remoteStatus {
Text("Remote: \(status)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 4)
}
}
}
.onAppear {
syncRepoURL = settingsService.syncRepoURL
syncLocalPath = settingsService.syncLocalPath
syncUsername = settingsService.syncUsername ?? ""
syncPassword = settingsService.syncPassword ?? ""
syncAccessToken = settingsService.syncAccessToken ?? ""
Task {
await gitSync.updateStatus()
}
}
}
// MARK: - Email Tab
@ViewBuilder
private var emailTab: some View {
Group {
// Security recommendation box
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "shield.fill")
.foregroundColor(.orange)
Text("Security Recommendation")
.font(.system(size: settingsService.guiTextSize, weight: .semibold))
.foregroundColor(.orange)
}
Text("Create a dedicated email account specifically for AI handling. Do NOT use your personal email address.")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text("Example: confab-bot-x7k2m9p3@gmail.com")
.font(.system(size: settingsService.guiTextSize - 1, design: .monospaced))
.foregroundColor(.blue)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color.blue.opacity(0.1))
.cornerRadius(4)
}
.padding(12)
.background(Color.orange.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.orange.opacity(0.3), lineWidth: 0.5))
// Enable toggle
VStack(alignment: .leading, spacing: 6) {
sectionHeader("AI Email Handler")
formSection {
row("Enable Email Handler") {
Toggle("", isOn: $settingsService.emailHandlerEnabled)
.toggleStyle(.switch)
}
}
}
if settingsService.emailHandlerEnabled {
// AI Configuration
VStack(alignment: .leading, spacing: 6) {
sectionHeader("AI Configuration")
formSection {
row("AI Provider") {
Picker("", selection: $settingsService.emailHandlerProvider) {
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
Text(provider.displayName).tag(provider.rawValue)
}
}
.labelsHidden()
.frame(width: 250)
.onChange(of: settingsService.emailHandlerProvider) {
Task { await loadEmailModels() }
}
}
rowDivider()
row("AI Model") {
if isLoadingEmailModels {
ProgressView().scaleEffect(0.7).frame(width: 250, alignment: .leading)
} else if emailAvailableModels.isEmpty {
Text("No models available")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.secondary)
.frame(width: 250, alignment: .leading)
} else {
Button(action: { showEmailModelSelector = true }) {
HStack {
Text(emailAvailableModels.first(where: { $0.id == settingsService.emailHandlerModel })?.name ?? "Select model...")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 10))
.foregroundColor(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(width: 250)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
}
}
// Email Server
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Email Server")
formSection {
row("IMAP Host") {
TextField("imap.gmail.com", text: Binding(
get: { settingsService.emailImapHost ?? "" },
set: { settingsService.emailImapHost = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 240)
}
rowDivider()
row("SMTP Host") {
TextField("smtp.gmail.com", text: Binding(
get: { settingsService.emailSmtpHost ?? "" },
set: { settingsService.emailSmtpHost = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 240)
}
rowDivider()
row("IMAP Port") {
TextField("993", text: Binding(
get: { String(settingsService.emailImapPort) },
set: { settingsService.emailImapPort = Int($0) ?? 993 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
}
rowDivider()
row("SMTP Port") {
TextField("587", text: Binding(
get: { String(settingsService.emailSmtpPort) },
set: { settingsService.emailSmtpPort = Int($0) ?? 587 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
}
rowDivider()
row("Username") {
TextField("your-email@gmail.com", text: Binding(
get: { settingsService.emailUsername ?? "" },
set: { settingsService.emailUsername = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
.frame(width: 240)
}
rowDivider()
row("Password") {
HStack {
if showEmailPassword {
TextField("", text: Binding(
get: { settingsService.emailPassword ?? "" },
set: { settingsService.emailPassword = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
} else {
SecureField("", text: Binding(
get: { settingsService.emailPassword ?? "" },
set: { settingsService.emailPassword = $0.isEmpty ? nil : $0 }
))
.textFieldStyle(.roundedBorder)
}
Button(action: { showEmailPassword.toggle() }) {
Image(systemName: showEmailPassword ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.frame(width: 240)
}
rowDivider()
// Test connection
HStack(spacing: 12) {
Button(action: { Task { await testEmailConnection() } }) {
HStack {
if isTestingEmailConnection {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "checkmark.circle")
}
Text("Test Connection")
}
}
.disabled(isTestingEmailConnection)
if let result = emailConnectionTestResult {
Text(result)
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(result.hasPrefix("✓") ? .green : .red)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
Text("💡 For Gmail, use an App Password. Google Account > Security > 2-Step Verification > App passwords.")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
// Email Trigger
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Email Trigger")
formSection {
row("Subject Identifier") {
TextField("", text: $settingsService.emailSubjectIdentifier)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
}
}
}
Text("Only emails with this text in the subject line will be processed. Example: \"[OAIBOT] What's the weather?\"")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
// Rate Limiting
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Rate Limiting")
formSection {
row("Enable Rate Limit") {
Toggle("", isOn: $settingsService.emailRateLimitEnabled)
.toggleStyle(.switch)
}
if settingsService.emailRateLimitEnabled {
rowDivider()
row("Max Emails Per Hour") {
HStack {
Slider(value: Binding(
get: { Double(settingsService.emailRateLimitPerHour) },
set: { settingsService.emailRateLimitPerHour = Int($0) }
), in: 1...100, step: 1)
.frame(width: 200)
Text(settingsService.emailRateLimitPerHour == 100 ? "Unlimited" : "\(settingsService.emailRateLimitPerHour)")
.font(.system(size: settingsService.guiTextSize))
.frame(width: 70, alignment: .trailing)
}
}
}
}
}
// Response Settings
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Response Settings")
formSection {
row("Max Response Tokens") {
HStack {
Slider(value: Binding(
get: { Double(settingsService.emailMaxTokens) },
set: { settingsService.emailMaxTokens = Int($0) }
), in: 100...8000, step: 100)
.frame(width: 200)
Text("\(settingsService.emailMaxTokens)")
.font(.system(size: settingsService.guiTextSize))
.frame(width: 60, alignment: .trailing)
}
}
rowDivider()
row("Enable Online Mode") {
Toggle("", isOn: $settingsService.emailOnlineMode)
.toggleStyle(.switch)
}
}
}
Text("~750 tokens ≈ 500 words. Online mode allows web search in responses.")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.padding(.horizontal, 4)
// Custom System Prompt
VStack(alignment: .leading, spacing: 6) {
sectionHeader("System Prompt (Optional)")
formSection {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("Email handler uses ONLY its own system prompt, completely isolated from your main chat settings. A custom prompt below will override the defaults.")
.font(.system(size: settingsService.guiTextSize - 1))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Text("Email Handler System Prompt")
.font(.system(size: settingsService.guiTextSize - 1, weight: .medium))
.foregroundColor(.secondary)
Spacer()
if !emailHandlerSystemPrompt.isEmpty {
Button("Clear") {
emailHandlerSystemPrompt = ""
settingsService.emailHandlerSystemPrompt = nil
}
.font(.system(size: settingsService.guiTextSize - 1))
}
}
TextEditor(text: $emailHandlerSystemPrompt)
.font(.system(size: settingsService.guiTextSize, design: .monospaced))
.frame(height: 100)
.padding(8)
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(6)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2), lineWidth: 1))
.onChange(of: emailHandlerSystemPrompt) {
settingsService.emailHandlerSystemPrompt = emailHandlerSystemPrompt.isEmpty ? nil : emailHandlerSystemPrompt
}
if emailHandlerSystemPrompt.isEmpty {
Text("Leave empty to use the default email handler system prompt.")
.font(.system(size: settingsService.guiTextSize - 2))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("⚠️ Custom prompt active — only this prompt will be sent to the model.")
.font(.system(size: settingsService.guiTextSize - 2))
.foregroundColor(.orange)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
// Email Log + MCP notice
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Activity")
formSection {
row("Email Log") {
Button(action: { showEmailLog = true }) {
HStack {
Image(systemName: "envelope.badge.fill")
Text("View Email Log")
}
}
}
}
}
// MCP Access Notice
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "info.circle.fill")
.foregroundColor(.blue)
Text("File Access Permissions")
.font(.system(size: settingsService.guiTextSize, weight: .semibold))
.foregroundColor(.blue)
}
Text("Email tasks have READ-ONLY access to MCP folders. The AI cannot write, delete, or modify files when processing emails.")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.background(Color.blue.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.blue.opacity(0.3), lineWidth: 0.5))
}
}
.onAppear {
emailHandlerSystemPrompt = settingsService.emailHandlerSystemPrompt ?? ""
Task {
await loadEmailModels()
}
}
.sheet(isPresented: $showEmailModelSelector) {
ModelSelectorView(
models: emailAvailableModels.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending },
selectedModel: emailAvailableModels.first(where: { $0.id == settingsService.emailHandlerModel })
) { selectedModel in
settingsService.emailHandlerModel = selectedModel.id
showEmailModelSelector = false
}
}
}
// MARK: - Shortcuts Tab
@ViewBuilder
private var shortcutsTab: some View {
ShortcutsTabContent()
}
// MARK: - Agent Skills Tab
@ViewBuilder
private var agentSkillsTab: some View {
AgentSkillsTabContent()
}
// MARK: - Paperless Tab
@ViewBuilder
private var paperlessTab: some View {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Paperless-NGX")
formSection {
row("Enable Paperless") {
Toggle("", isOn: $settingsService.paperlessEnabled)
.toggleStyle(.switch)
}
VStack(alignment: .leading, spacing: 2) {
Text("⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
}
if settingsService.paperlessEnabled {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Connection")
formSection {
row("Base URL") {
TextField("https://paperless.yourdomain.com", text: $paperlessURL)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
.onSubmit { settingsService.paperlessURL = paperlessURL }
.onChange(of: paperlessURL) { _, new in settingsService.paperlessURL = new }
}
rowDivider()
row("API Token") {
HStack(spacing: 6) {
if showPaperlessToken {
TextField("", text: $paperlessToken)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { settingsService.paperlessAPIToken = paperlessToken.isEmpty ? nil : paperlessToken }
.onChange(of: paperlessToken) { _, new in
settingsService.paperlessAPIToken = new.isEmpty ? nil : new
}
} else {
SecureField("", text: $paperlessToken)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { settingsService.paperlessAPIToken = paperlessToken.isEmpty ? nil : paperlessToken }
.onChange(of: paperlessToken) { _, new in
settingsService.paperlessAPIToken = new.isEmpty ? nil : new
}
}
Button(showPaperlessToken ? "Hide" : "Show") {
showPaperlessToken.toggle()
}
.buttonStyle(.borderless)
.font(.system(size: 13))
}
}
rowDivider()
HStack(spacing: 12) {
Button(action: { Task { await testPaperlessConnection() } }) {
HStack {
if isTestingPaperless {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "checkmark.circle")
}
Text("Test Connection")
}
}
.disabled(isTestingPaperless)
if let result = paperlessTestResult {
Text(result)
.font(.system(size: 13))
.foregroundStyle(result.hasPrefix("✓") ? .green : .red)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("How to get your API token:")
.font(.system(size: 13, weight: .medium))
Text("1. Open Paperless-NGX → Settings → API Tokens")
Text("2. Create or copy your token")
Text("3. Paste it above")
}
.font(.system(size: 13))
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
}
}
.onAppear {
paperlessURL = settingsService.paperlessURL
paperlessToken = settingsService.paperlessAPIToken ?? ""
}
}
private func testPaperlessConnection() async {
isTestingPaperless = true
paperlessTestResult = nil
guard settingsService.paperlessConfigured else {
paperlessTestResult = "✗ Enter a base URL and API token first."
isTestingPaperless = false
return
}
let result = await PaperlessService.shared.testConnection()
await MainActor.run {
switch result {
case .success(let msg):
paperlessTestResult = "✓ \(msg)"
case .failure(let err):
paperlessTestResult = "✗ \(err.localizedDescription)"
}
isTestingPaperless = false
}
}
// MARK: - Anytype Tab
@ViewBuilder
private var anytypeTab: some View {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Anytype")
formSection {
row("Enable Anytype") {
Toggle("", isOn: $settingsService.anytypeMcpEnabled)
.toggleStyle(.switch)
}
}
}
if settingsService.anytypeMcpEnabled {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Connection")
formSection {
row("API URL") {
TextField("http://127.0.0.1:31009", text: $anytypeURL)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
.onSubmit { settingsService.anytypeMcpURL = anytypeURL }
.onChange(of: anytypeURL) { _, new in settingsService.anytypeMcpURL = new }
}
rowDivider()
row("API Key") {
HStack(spacing: 6) {
if showAnytypeKey {
TextField("", text: $anytypeAPIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { settingsService.anytypeMcpAPIKey = anytypeAPIKey.isEmpty ? nil : anytypeAPIKey }
.onChange(of: anytypeAPIKey) { _, new in
settingsService.anytypeMcpAPIKey = new.isEmpty ? nil : new
}
} else {
SecureField("", text: $anytypeAPIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { settingsService.anytypeMcpAPIKey = anytypeAPIKey.isEmpty ? nil : anytypeAPIKey }
.onChange(of: anytypeAPIKey) { _, new in
settingsService.anytypeMcpAPIKey = new.isEmpty ? nil : new
}
}
Button(showAnytypeKey ? "Hide" : "Show") {
showAnytypeKey.toggle()
}
.buttonStyle(.borderless)
.font(.system(size: 13))
}
}
rowDivider()
HStack(spacing: 12) {
Button(action: { Task { await testAnytypeConnection() } }) {
HStack {
if isTestingAnytype {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "checkmark.circle")
}
Text("Test Connection")
}
}
.disabled(isTestingAnytype)
if let result = anytypeTestResult {
Text(result)
.font(.system(size: 13))
.foregroundStyle(result.hasPrefix("✓") ? .green : .red)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("How to get your API key:")
.font(.system(size: 13, weight: .medium))
Text("1. Open Anytype → Settings → Integrations")
Text("2. Create a new API key")
Text("3. Paste it above")
}
.font(.system(size: 13))
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
}
}
.onAppear {
anytypeURL = settingsService.anytypeMcpURL
anytypeAPIKey = settingsService.anytypeMcpAPIKey ?? ""
}
}
// MARK: - Jarvis Tab
private var jarvisTab: some View {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Jarvis")
formSection {
row("Enable Jarvis") {
Toggle("", isOn: $settingsService.jarvisEnabled)
.toggleStyle(.switch)
}
}
}
if settingsService.jarvisEnabled {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Connection")
formSection {
row("Server URL") {
TextField("https://jarvis.example.com", text: $jarvisURL)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
.onSubmit { settingsService.jarvisURL = jarvisURL }
.onChange(of: jarvisURL) { _, new in settingsService.jarvisURL = new }
}
rowDivider()
row("API Key") {
HStack(spacing: 6) {
if showJarvisKey {
TextField("", text: $jarvisAPIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { settingsService.jarvisAPIKey = jarvisAPIKey.isEmpty ? nil : jarvisAPIKey }
.onChange(of: jarvisAPIKey) { _, new in
settingsService.jarvisAPIKey = new.isEmpty ? nil : new
}
} else {
SecureField("", text: $jarvisAPIKey)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
.onSubmit { settingsService.jarvisAPIKey = jarvisAPIKey.isEmpty ? nil : jarvisAPIKey }
.onChange(of: jarvisAPIKey) { _, new in
settingsService.jarvisAPIKey = new.isEmpty ? nil : new
}
}
Button(showJarvisKey ? "Hide" : "Show") {
showJarvisKey.toggle()
}
.buttonStyle(.borderless)
.font(.system(size: 13))
}
}
rowDivider()
HStack(spacing: 12) {
Button(action: { Task { await testJarvisConnection() } }) {
HStack {
if isTestingJarvis {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "checkmark.circle")
}
Text("Test Connection")
}
}
.disabled(isTestingJarvis)
if let result = jarvisTestResult {
Text(result)
.font(.system(size: 13))
.foregroundStyle(result.hasPrefix("✓") ? .green : .red)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("Jarvis is oAI-Web, a self-hosted companion server that lets Confab sync and connect remotely.")
.foregroundStyle(.secondary)
Link("→ Jarvis / oAI-Web on Gitea", destination: URL(string: "https://gitlab.pm/rune/oai-web")!)
}
.font(.system(size: 13))
.padding(.horizontal, 4)
}
}
.onAppear {
jarvisURL = settingsService.jarvisURL
jarvisAPIKey = settingsService.jarvisAPIKey ?? ""
}
}
private func testJarvisConnection() async {
isTestingJarvis = true
jarvisTestResult = nil
guard settingsService.jarvisConfigured else {
jarvisTestResult = "✗ Enter a URL and API key first."
isTestingJarvis = false
return
}
let ok = await JarvisService.shared.testConnection()
await MainActor.run {
jarvisTestResult = ok ? "✓ Connected" : "✗ Connection failed"
isTestingJarvis = false
}
}
private func testAnytypeConnection() async {
isTestingAnytype = true
anytypeTestResult = nil
guard settingsService.anytypeMcpConfigured else {
anytypeTestResult = "✗ Enter an API key first."
isTestingAnytype = false
return
}
let result = await AnytypeMCPService.shared.testConnection()
await MainActor.run {
switch result {
case .success(let msg):
anytypeTestResult = "✓ \(msg)"
case .failure(let err):
anytypeTestResult = "✗ \(err.localizedDescription)"
}
isTestingAnytype = false
}
}
private func testMailConnection() async {
isTestingMail = true
mailTestResult = nil
let result = await AppleMailService.shared.testConnection()
await MainActor.run {
switch result {
case .success(let msg):
mailTestResult = "✓ \(msg)"
case .failure(let err):
mailTestResult = "✗ \(err.localizedDescription)"
}
isTestingMail = false
}
}
// MARK: - Backup Tab
@ViewBuilder
private var backupTab: some View {
VStack(alignment: .leading, spacing: 20) {
// Warning notice
VStack(alignment: .leading, spacing: 6) {
sectionHeader("iCloud Drive Backup")
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.system(size: 14))
Text("API keys and credentials are **not** included in the backup. You will need to re-enter them after restoring on a new machine.")
.font(.system(size: 13))
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 4)
.padding(.top, 2)
}
// Status
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Status")
formSection {
row("iCloud Drive") {
HStack(spacing: 6) {
Circle()
.fill(backupService.iCloudAvailable ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(backupService.iCloudAvailable ? "Available" : "Not available — using Downloads")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
rowDivider()
row("Last Backup") {
if let date = backupService.lastBackupDate {
Text(formatBackupDate(date))
.font(.system(size: 13))
.foregroundStyle(.secondary)
} else {
Text("Never")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
}
}
// Automatic Backup
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Automatic Backup")
formSection {
row("Frequency") {
Picker("", selection: $settingsService.autoBackupFrequency) {
Text("Off").tag("manual")
Text("Daily").tag("daily")
Text("Weekly").tag("weekly")
}
.pickerStyle(.segmented)
.frame(width: 220)
}
}
Text("When enabled, Confab backs up automatically in the background (checked at launch and hourly while running) — no need to press \"Back Up Now\" yourself.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
}
// Favorites Sync
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Favorite Models")
Text("Starred models sync automatically via the same iCloud Drive folder — star a model on one Mac and it appears starred on your others within a launch or two.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
}
// Actions
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Actions")
formSection {
HStack(spacing: 12) {
Button(action: { Task { await performBackup() } }) {
HStack(spacing: 6) {
if isExporting {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "icloud.and.arrow.up")
}
Text("Back Up Now")
}
}
.disabled(isExporting || isImporting)
Button(action: { showRestoreFilePicker = true }) {
HStack(spacing: 6) {
if isImporting {
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
} else {
Image(systemName: "icloud.and.arrow.down")
}
Text("Restore from File…")
}
}
.disabled(isExporting || isImporting)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
if let msg = backupMessage {
rowDivider()
HStack(spacing: 6) {
Image(systemName: backupMessageIsError ? "xmark.circle.fill" : "checkmark.circle.fill")
.foregroundStyle(backupMessageIsError ? .red : .green)
Text(msg)
.font(.system(size: 13))
.foregroundStyle(backupMessageIsError ? .red : .primary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
// Backup location
if let url = backupService.lastBackupURL {
VStack(alignment: .leading, spacing: 4) {
Text("Backup location:")
.font(.system(size: 13, weight: .medium))
Text(url.path.replacingOccurrences(
of: FileManager.default.homeDirectoryForCurrentUser.path,
with: "~"))
.font(.system(size: 12))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding(.horizontal, 4)
}
}
.onAppear {
backupService.checkForExistingBackup()
}
}
private func performBackup() async {
await MainActor.run {
isExporting = true
backupMessage = nil
}
do {
let url = try await backupService.exportSettings()
let shortPath = url.path.replacingOccurrences(
of: FileManager.default.homeDirectoryForCurrentUser.path,
with: "~")
await MainActor.run {
backupMessage = "Backup saved to \(shortPath)"
backupMessageIsError = false
isExporting = false
}
} catch {
await MainActor.run {
backupMessage = error.localizedDescription
backupMessageIsError = true
isExporting = false
}
}
}
private func performRestore(from url: URL) async {
await MainActor.run {
isImporting = true
backupMessage = nil
}
do {
_ = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
try await backupService.importSettings(from: url)
await MainActor.run {
backupMessage = "Settings restored. Re-enter your API keys to resume using Confab."
backupMessageIsError = false
isImporting = false
}
} catch {
await MainActor.run {
backupMessage = error.localizedDescription
backupMessageIsError = true
isImporting = false
}
}
}
private func formatBackupDate(_ date: Date) -> String {
let cal = Calendar.current
if cal.isDateInToday(date) {
let tf = DateFormatter()
tf.dateFormat = "HH:mm"
return "Today \(tf.string(from: date))"
}
let df = DateFormatter()
df.dateFormat = "dd.MM.yyyy HH:mm"
return df.string(from: date)
}
// MARK: - Tab Navigation
@ViewBuilder
private func tabButton(_ tag: Int, icon: String, label: LocalizedStringKey, beta: Bool = false) -> some View {
let selected = selectedTab == tag
Group {
if selected {
// Untinted glass — the blue comes from the icon/text foreground color below,
// not the glass itself. A colored tint here renders as a near-opaque solid
// block rather than translucent glass (found live, see CLAUDE.md's gotcha).
Button(action: { selectedTab = tag }) {
tabButtonLabel(icon: icon, label: label, beta: beta, selected: true)
}
.buttonStyle(.glass)
} else {
Button(action: { selectedTab = tag }) {
tabButtonLabel(icon: icon, label: label, beta: beta, selected: false)
}
.buttonStyle(.plain)
}
}
.buttonBorderShape(.roundedRectangle(radius: 8))
}
@ViewBuilder
private func tabButtonLabel(icon: String, label: LocalizedStringKey, beta: Bool, selected: Bool) -> some View {
VStack(spacing: 3) {
ZStack(alignment: .topTrailing) {
Image(systemName: icon)
.font(.system(size: 22))
.frame(height: 28)
.foregroundStyle(selected ? .blue : .secondary)
if beta {
Text("β")
.font(.system(size: 9, weight: .heavy))
.foregroundStyle(.white)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.orange)
.clipShape(Capsule())
.offset(x: 8, y: -3)
}
}
Text(label)
.font(.system(size: 11))
.foregroundStyle(selected ? .blue : .secondary)
}
.frame(minWidth: 55)
}
private func tabTitle(_ tag: Int) -> LocalizedStringKey {
switch tag {
case 0: return "General"
case 1: return "MCP"
case 2: return "Appearance"
case 3: return "Advanced"
case 4: return "Sync"
case 5: return "Email"
case 6: return "Shortcuts"
case 7: return "Skills"
case 8: return "Paperless"
case 9: return "Backup"
case 10: return "Anytype"
case 11: return "Jarvis"
default: return "Settings"
}
}
// MARK: - Reasoning Helpers
private var reasoningEffortDescription: LocalizedStringKey {
switch settingsService.reasoningEffort {
case "high": return "Uses ~80% of max tokens for reasoning — best for hard problems"
case "medium": return "Uses ~50% of max tokens for reasoning — balanced default"
case "low": return "Uses ~20% of max tokens for reasoning — faster, cheaper"
case "minimal": return "Uses ~10% of max tokens for reasoning — lightest thinking"
default: return "Uses ~50% of max tokens for reasoning — balanced default"
}
}
// MARK: - Layout Helpers
private func row<Content: View>(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
HStack(alignment: .center, spacing: 12) {
Text(label).font(.system(size: 14))
Spacer()
content()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
.padding(.horizontal, 4)
}
private func formSection<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(spacing: 0) { content() }
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.primary.opacity(0.10), lineWidth: 0.5))
}
private func rowDivider() -> some View {
Divider().padding(.leading, 16)
}
@ViewBuilder
private func personalDataRow(title: LocalizedStringKey, isEnabled: Binding<Bool>, state: PersonalDataAccessState, systemSettingsAnchor: String, requestAccess: @escaping () async -> Void) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 12) {
Text(title).font(.system(size: 14))
Spacer()
Toggle("", isOn: isEnabled)
.toggleStyle(.switch)
}
if isEnabled.wrappedValue {
HStack(spacing: 6) {
Image(systemName: state == .granted ? "checkmark.circle.fill" : (state == .denied ? "exclamationmark.circle.fill" : "circle"))
.foregroundStyle(state == .granted ? .green : (state == .denied ? .orange : .secondary))
.font(.system(size: 12))
Text(statusText(for: state))
.font(.system(size: 12))
.foregroundStyle(.secondary)
Spacer()
if state == .notDetermined {
Button("Request Access") {
Task { await requestAccess() }
}
.buttonStyle(.bordered)
.controlSize(.small)
} else if state == .denied {
Button("Open System Settings") {
openPrivacySettings(anchor: systemSettingsAnchor)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
private func statusText(for state: PersonalDataAccessState) -> LocalizedStringKey {
switch state {
case .granted: return "Access granted"
case .denied: return "Access denied — enable in System Settings"
case .notDetermined: return "Access not granted"
}
}
private func openPrivacySettings(anchor: String) {
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(anchor)") else { return }
NSWorkspace.shared.open(url)
}
private func abbreviatePath(_ path: String) -> String {
let home = NSHomeDirectory()
if path.hasPrefix(home) {
return "~" + path.dropFirst(home.count)
}
return path
}
// MARK: - Email Helpers
private func loadEmailModels() async {
guard settingsService.emailHandlerEnabled else {
emailAvailableModels = []
return
}
let providerRawValue = settingsService.emailHandlerProvider
guard let providerType = Settings.Provider(rawValue: providerRawValue),
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
emailAvailableModels = []
return
}
isLoadingEmailModels = true
defer { isLoadingEmailModels = false }
do {
let models = try await provider.listModels()
emailAvailableModels = models
// If current model is not in the list, select the first one
if !models.contains(where: { $0.id == settingsService.emailHandlerModel }) {
if let firstModel = models.first {
settingsService.emailHandlerModel = firstModel.id
}
}
} catch {
Log.ui.error("Failed to load email models: \(error.localizedDescription)")
emailAvailableModels = []
}
}
private func testEmailConnection() async {
isTestingEmailConnection = true
emailConnectionTestResult = nil
guard settingsService.emailServerConfigured else {
emailConnectionTestResult = "✗ Enter your IMAP/SMTP host, username, and password first."
isTestingEmailConnection = false
return
}
do {
let result = try await EmailService.shared.testConnection()
emailConnectionTestResult = "✓ \(result)"
} catch {
emailConnectionTestResult = "✗ \(error.localizedDescription)"
}
isTestingEmailConnection = false
}
// MARK: - Sync Helpers
private func testSyncConnection() async {
isTestingSync = true
syncTestResult = nil
guard settingsService.syncConfigured else {
syncTestResult = "✗ Enter a repository URL and credentials first."
isTestingSync = false
return
}
do {
let result = try await gitSync.testConnection()
syncTestResult = "✓ \(result)"
} catch {
syncTestResult = "✗ \(error.localizedDescription)"
}
isTestingSync = false
}
private var syncStatusIcon: String {
guard settingsService.syncEnabled else { return "externaldrive.slash" }
guard settingsService.syncConfigured else { return "exclamationmark.triangle" }
guard gitSync.syncStatus.isCloned else { return "externaldrive.badge.questionmark" }
return "externaldrive.badge.checkmark"
}
private var syncStatusColor: Color {
guard settingsService.syncEnabled else { return .secondary }
guard settingsService.syncConfigured else { return .orange }
guard gitSync.syncStatus.isCloned else { return .orange }
return .green
}
private var syncStatusText: LocalizedStringKey {
guard settingsService.syncEnabled else { return "Disabled" }
guard settingsService.syncConfigured else { return "Not configured" }
guard gitSync.syncStatus.isCloned else { return "Not cloned" }
return "Ready"
}
private func cloneRepo() async {
do {
try await gitSync.cloneRepository()
syncTestResult = "✓ Repository cloned successfully"
} catch {
syncTestResult = "✗ \(error.localizedDescription)"
}
}
private func exportConversations() async {
do {
try await gitSync.exportAllConversations()
syncTestResult = "✓ Conversations exported"
} catch {
syncTestResult = "✗ \(error.localizedDescription)"
}
}
private func pushToGit() async {
do {
// First export conversations
try await gitSync.exportAllConversations()
// Then push
try await gitSync.push()
syncTestResult = "✓ Changes pushed successfully"
} catch {
syncTestResult = "✗ \(error.localizedDescription)"
}
}
private func pullFromGit() async {
do {
try await gitSync.pull()
syncTestResult = "✓ Changes pulled successfully"
} catch {
syncTestResult = "✗ \(error.localizedDescription)"
}
}
private func importConversations() async {
do {
let result = try await gitSync.importAllConversations()
syncTestResult = "✓ Imported \(result.imported) conversations (skipped \(result.skipped) duplicates)"
} catch {
syncTestResult = "✗ \(error.localizedDescription)"
}
}
private func syncNow() async {
isSyncing = true
syncTestResult = "Syncing..."
do {
// Orchestration (pull → import → export → push) and the guard against racing
// autoSync()/syncOnStartup() on the same working tree both live in GitSyncService now
// — see its syncNow() for why.
let result = try await gitSync.syncNow()
syncTestResult = "✓ Sync complete: \(result.imported) imported, \(result.skipped) skipped"
} catch {
syncTestResult = "✗ Sync failed: \(error.localizedDescription)"
}
isSyncing = false
}
private var tokenGenerationURL: String? {
let url = settingsService.syncRepoURL.lowercased()
if url.contains("github.com") {
return "https://github.com/settings/tokens"
} else if url.contains("gitlab.com") {
return "https://gitlab.com/-/profile/personal_access_tokens"
} else if url.contains("gitea") {
return extractProvider() + "/user/settings/applications"
} else {
return nil
}
}
private func extractProvider() -> String {
let url = settingsService.syncRepoURL
if url.contains("github.com") {
return "GitHub"
} else if url.contains("gitlab.com") {
return "GitLab"
} else if url.contains("gitea") {
return "Gitea"
} else {
return "Git repository"
}
}
private func timeAgo(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: .now)
}
@ViewBuilder
private var appleIntelligenceStatusBadge: some View {
let availability = SystemLanguageModel.default.availability
switch availability {
case .available:
Label("Available", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
case .unavailable(.deviceNotEligible):
Label("Not supported on this Mac", systemImage: "xmark.circle.fill")
.foregroundStyle(.red)
case .unavailable(.appleIntelligenceNotEnabled):
Label("Not enabled — open Apple Intelligence Settings", systemImage: "exclamationmark.circle.fill")
.foregroundStyle(.orange)
case .unavailable(.modelNotReady):
Label("Model downloading…", systemImage: "arrow.down.circle.fill")
.foregroundStyle(.orange)
default:
Label("Unavailable", systemImage: "questionmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
#Preview {
SettingsView()
}