501 lines
18 KiB
Swift
501 lines
18 KiB
Swift
//
|
|
// 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<Content: View>(_ 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()
|
|
}
|