// // SettingsView.swift // oAI // // Settings and configuration screen // import SwiftUI import UniformTypeIdentifiers struct SettingsView: View { @Environment(\.dismiss) var dismiss @Bindable private var settingsService = SettingsService.shared private var mcpService = MCPService.shared @State private var openrouterKey = "" @State private var anthropicKey = "" @State private var openaiKey = "" @State private var googleKey = "" @State private var googleEngineID = "" @State private var showFolderPicker = false @State private var selectedTab = 0 // OAuth state @State private var oauthCode = "" @State private var oauthError: String? @State private var showOAuthCodeField = false private var oauthService = AnthropicOAuthService.shared private let labelWidth: CGFloat = 140 var body: some View { VStack(spacing: 0) { // Title Text("Settings") .font(.system(size: 18, weight: .bold)) .padding(.top, 20) .padding(.bottom, 12) // Tab picker Picker("", selection: $selectedTab) { Text("General").tag(0) Text("MCP").tag(1) Text("Appearance").tag(2) } .pickerStyle(.segmented) .padding(.horizontal, 24) .padding(.bottom, 12) Divider() ScrollView { VStack(alignment: .leading, spacing: 20) { switch selectedTab { case 0: generalTab case 1: mcpTab case 2: appearanceTab default: generalTab } } .padding(.horizontal, 24) .padding(.vertical, 16) } Divider() // Bottom bar HStack { Spacer() Button("Done") { dismiss() } .keyboardShortcut(.return, modifiers: []) .buttonStyle(.borderedProminent) .controlSize(.regular) Spacer() } .padding(.horizontal, 24) .padding(.vertical, 12) } .frame(minWidth: 550, idealWidth: 600, minHeight: 500, idealHeight: 650) } // MARK: - General Tab @ViewBuilder private var generalTab: some View { // Provider sectionHeader("Provider") row("Default Provider") { Picker("", selection: $settingsService.defaultProvider) { ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in Text(provider.displayName).tag(provider) } } .labelsHidden() .fixedSize() } divider() // API Keys sectionHeader("API Keys") row("OpenRouter") { SecureField("sk-or-...", text: $openrouterKey) .textFieldStyle(.roundedBorder) .onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" } .onChange(of: openrouterKey) { settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey ProviderRegistry.shared.clearCache() } } // Anthropic: OAuth or API key row("Anthropic") { VStack(alignment: .leading, spacing: 8) { if oauthService.isAuthenticated { // Logged in via OAuth HStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) Text("Logged in via Claude Pro/Max") .font(.subheadline) Spacer() Button("Logout") { oauthService.logout() ProviderRegistry.shared.clearCache() } .font(.subheadline) .foregroundStyle(.red) } } else if showOAuthCodeField { // Waiting for code paste HStack(spacing: 8) { TextField("Paste authorization code...", text: $oauthCode) .textFieldStyle(.roundedBorder) Button("Submit") { Task { await submitOAuthCode() } } .disabled(oauthCode.isEmpty || oauthService.isLoggingIn) Button("Cancel") { showOAuthCodeField = false oauthCode = "" oauthError = nil } .font(.subheadline) } if let error = oauthError { Text(error) .font(.caption) .foregroundStyle(.red) } } else { // Login button + API key field HStack(spacing: 8) { Button { startOAuthLogin() } label: { HStack(spacing: 4) { Image(systemName: "person.circle") Text("Login with Claude Pro/Max") } .font(.subheadline) } .buttonStyle(.borderedProminent) .controlSize(.small) Text("or") .font(.caption) .foregroundStyle(.secondary) } SecureField("sk-ant-... (API key)", text: $anthropicKey) .textFieldStyle(.roundedBorder) .onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" } .onChange(of: anthropicKey) { settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey ProviderRegistry.shared.clearCache() } } } } row("OpenAI") { SecureField("sk-...", text: $openaiKey) .textFieldStyle(.roundedBorder) .onAppear { openaiKey = settingsService.openaiAPIKey ?? "" } .onChange(of: openaiKey) { settingsService.openaiAPIKey = openaiKey.isEmpty ? nil : openaiKey ProviderRegistry.shared.clearCache() } } row("Ollama URL") { TextField("http://localhost:11434", text: $settingsService.ollamaBaseURL) .textFieldStyle(.roundedBorder) .help("Enter your Ollama server URL to enable the Ollama provider") } divider() // Features sectionHeader("Features") row("") { VStack(alignment: .leading, spacing: 6) { Toggle("Online Mode (Web Search)", isOn: $settingsService.onlineMode) Toggle("Conversation Memory", isOn: $settingsService.memoryEnabled) Toggle("MCP (File Access)", isOn: $settingsService.mcpEnabled) } } divider() // Web Search sectionHeader("Web Search") row("Search Provider") { Picker("", selection: $settingsService.searchProvider) { ForEach(Settings.SearchProvider.allCases, id: \.self) { provider in Text(provider.displayName).tag(provider) } } .labelsHidden() .fixedSize() } if settingsService.searchProvider == .google { row("Google API Key") { SecureField("", text: $googleKey) .textFieldStyle(.roundedBorder) .onAppear { googleKey = settingsService.googleAPIKey ?? "" } .onChange(of: googleKey) { settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey } } row("Search Engine ID") { TextField("", text: $googleEngineID) .textFieldStyle(.roundedBorder) .onAppear { googleEngineID = settingsService.googleSearchEngineID ?? "" } .onChange(of: googleEngineID) { settingsService.googleSearchEngineID = googleEngineID.isEmpty ? nil : googleEngineID } } } divider() // Model Settings sectionHeader("Model Settings") row("Default Model ID") { TextField("e.g. anthropic/claude-sonnet-4", text: Binding( get: { settingsService.defaultModel ?? "" }, set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 } )) .textFieldStyle(.roundedBorder) } row("") { Toggle("Streaming Responses", isOn: $settingsService.streamEnabled) } divider() // Logging sectionHeader("Logging") row("Log Level") { Picker("", selection: Binding( get: { FileLogger.shared.minimumLevel }, set: { FileLogger.shared.minimumLevel = $0 } )) { ForEach(LogLevel.allCases, id: \.self) { level in Text(level.displayName).tag(level) } } .labelsHidden() .fixedSize() } HStack { Spacer().frame(width: labelWidth + 12) Text("Controls which messages are written to ~/Library/Logs/oAI.log") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } // MARK: - MCP Tab @ViewBuilder private var mcpTab: some View { // Enable toggle sectionHeader("MCP") row("") { Toggle("MCP Enabled (File Access)", isOn: $settingsService.mcpEnabled) } if settingsService.mcpEnabled { divider() // Folders sectionHeader("Allowed Folders") if mcpService.allowedFolders.isEmpty { HStack { Spacer().frame(width: labelWidth + 12) Text("No folders added") .foregroundStyle(.secondary) .font(.subheadline) } } else { ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in HStack(spacing: 0) { Spacer().frame(width: labelWidth + 12) 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(.caption) .foregroundStyle(.secondary) } .padding(.leading, 6) Spacer() Button { withAnimation { _ = mcpService.removeFolder(at: index) } } label: { Image(systemName: "trash.fill") .foregroundStyle(.red) .font(.caption) } .buttonStyle(.plain) } } } HStack(spacing: 0) { Spacer().frame(width: labelWidth + 12) Button { showFolderPicker = true } label: { HStack(spacing: 4) { Image(systemName: "plus") Text("Add Folder...") } .font(.subheadline) } .buttonStyle(.borderless) Spacer() } .fileImporter( isPresented: $showFolderPicker, allowedContentTypes: [.folder], allowsMultipleSelection: false ) { result in if case .success(let urls) = result, let url = urls.first { if url.startAccessingSecurityScopedResource() { withAnimation { _ = mcpService.addFolder(url.path) } url.stopAccessingSecurityScopedResource() } } } divider() // Permissions sectionHeader("Permissions") row("") { VStack(alignment: .leading, spacing: 6) { Toggle("Write & Edit Files", isOn: $settingsService.mcpCanWriteFiles) Toggle("Delete Files", isOn: $settingsService.mcpCanDeleteFiles) Toggle("Create Directories", isOn: $settingsService.mcpCanCreateDirectories) Toggle("Move & Copy Files", isOn: $settingsService.mcpCanMoveFiles) } } HStack { Spacer().frame(width: labelWidth + 12) Text("Read access is always available when MCP is enabled. Write permissions must be explicitly enabled.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } divider() // Filtering sectionHeader("Filtering") row("") { Toggle("Respect .gitignore", isOn: Binding( get: { settingsService.mcpRespectGitignore }, set: { newValue in settingsService.mcpRespectGitignore = newValue mcpService.reloadGitignores() } )) } HStack { Spacer().frame(width: labelWidth + 12) Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } } // MARK: - Appearance Tab @ViewBuilder private var appearanceTab: some View { sectionHeader("Text Sizes") row("GUI Text") { HStack(spacing: 8) { Slider(value: $settingsService.guiTextSize, in: 10...20, step: 1) .frame(maxWidth: 200) Text("\(Int(settingsService.guiTextSize)) pt") .font(.caption) .foregroundStyle(.secondary) .frame(width: 40) } } row("Dialog Text") { HStack(spacing: 8) { Slider(value: $settingsService.dialogTextSize, in: 10...24, step: 1) .frame(maxWidth: 200) Text("\(Int(settingsService.dialogTextSize)) pt") .font(.caption) .foregroundStyle(.secondary) .frame(width: 40) } } row("Input Text") { HStack(spacing: 8) { Slider(value: $settingsService.inputTextSize, in: 10...24, step: 1) .frame(maxWidth: 200) Text("\(Int(settingsService.inputTextSize)) pt") .font(.caption) .foregroundStyle(.secondary) .frame(width: 40) } } } // MARK: - Layout Helpers private func sectionHeader(_ title: String) -> some View { Text(title) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.secondary) .textCase(.uppercase) } private func row(_ label: String, @ViewBuilder content: () -> Content) -> some View { HStack(alignment: .center, spacing: 12) { Text(label) .font(.body) .frame(width: labelWidth, alignment: .trailing) content() } } private func divider() -> some View { Divider().padding(.vertical, 2) } private func abbreviatePath(_ path: String) -> String { let home = NSHomeDirectory() if path.hasPrefix(home) { return "~" + path.dropFirst(home.count) } return path } // MARK: - OAuth Helpers private func startOAuthLogin() { let url = oauthService.generateAuthorizationURL() #if os(macOS) NSWorkspace.shared.open(url) #endif showOAuthCodeField = true oauthError = nil oauthCode = "" } private func submitOAuthCode() async { oauthService.isLoggingIn = true oauthError = nil do { try await oauthService.exchangeCode(oauthCode) showOAuthCodeField = false oauthCode = "" ProviderRegistry.shared.clearCache() } catch { oauthError = error.localizedDescription } oauthService.isLoggingIn = false } } #Preview { SettingsView() }