771 lines
29 KiB
Swift
771 lines
29 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 = 160
|
|
|
|
// Default system prompt
|
|
private let defaultSystemPrompt = """
|
|
You are a helpful AI assistant. Follow these guidelines:
|
|
|
|
1. **Accuracy First**: Never invent information or make assumptions. If you're unsure about something, say so clearly.
|
|
|
|
2. **Ask for Clarification**: When a request is ambiguous or lacks necessary details, ask clarifying questions before proceeding.
|
|
|
|
3. **Be Honest About Limitations**: If you cannot or should not help with a request, explain why clearly and politely. Don't attempt tasks outside your capabilities.
|
|
|
|
4. **Stay Grounded**: Base responses on facts and known information. Clearly distinguish between facts, informed opinions, and speculation.
|
|
|
|
5. **Be Direct**: Provide concise, relevant answers. Avoid unnecessary preambles or apologies.
|
|
|
|
6. **Use Markdown Formatting**: Always format your responses using standard Markdown syntax:
|
|
- Use **bold** for emphasis
|
|
- Use bullet points and numbered lists for organization
|
|
- Use code blocks with language tags for code (e.g., ```python)
|
|
- Use proper headings (##, ###) to structure long responses
|
|
- If the user requests output in other formats (HTML, JSON, XML, etc.), wrap those in appropriate code blocks
|
|
|
|
7. **Break Down Complex Tasks**: When working with tools (file access, search, etc.), break complex tasks into smaller, manageable steps. If a task requires many operations:
|
|
- Complete one logical step at a time
|
|
- Present findings or progress after each step
|
|
- Ask the user if you should continue to the next step
|
|
- Be mindful of tool usage limits (typically 25-30 tool calls per request)
|
|
|
|
8. **Incremental Progress**: For large codebases or complex analyses, work incrementally. Don't try to explore everything at once. Focus on what's immediately relevant to the user's question.
|
|
|
|
Remember: It's better to ask questions or admit uncertainty than to provide incorrect or fabricated information.
|
|
"""
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Title
|
|
Text("Settings")
|
|
.font(.system(size: 22, 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)
|
|
Text("Advanced").tag(3)
|
|
}
|
|
.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
|
|
case 3:
|
|
advancedTab
|
|
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: 700, idealWidth: 800, minHeight: 600, idealHeight: 750)
|
|
}
|
|
|
|
// 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)
|
|
.font(.system(size: 13))
|
|
.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(.system(size: 14))
|
|
Spacer()
|
|
Button("Logout") {
|
|
oauthService.logout()
|
|
ProviderRegistry.shared.clearCache()
|
|
}
|
|
.font(.system(size: 14))
|
|
.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(.system(size: 14))
|
|
}
|
|
if let error = oauthError {
|
|
Text(error)
|
|
.font(.system(size: 13))
|
|
.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(.system(size: 14))
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
|
|
Text("or")
|
|
.font(.system(size: 13))
|
|
.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)
|
|
}
|
|
|
|
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(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - MCP Tab
|
|
|
|
@ViewBuilder
|
|
private var mcpTab: 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(.horizontal, 24)
|
|
.padding(.top, 12)
|
|
.padding(.bottom, 16)
|
|
|
|
divider()
|
|
|
|
// Enable toggle with status
|
|
sectionHeader("Status")
|
|
row("") {
|
|
Toggle("Enable MCP", isOn: $settingsService.mcpEnabled)
|
|
}
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
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)
|
|
}
|
|
}
|
|
|
|
if settingsService.mcpEnabled {
|
|
divider()
|
|
|
|
// Folders
|
|
sectionHeader("Allowed Folders")
|
|
|
|
if mcpService.allowedFolders.isEmpty {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "folder.badge.plus")
|
|
.font(.system(size: 32))
|
|
.foregroundStyle(.tertiary)
|
|
Text("No folders added yet")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
Text("Click 'Add Folder' below to grant AI access to a folder")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 24)
|
|
.background(Color.gray.opacity(0.05))
|
|
.cornerRadius(8)
|
|
.padding(.horizontal, labelWidth + 24)
|
|
} 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(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.leading, 6)
|
|
Spacer()
|
|
Button {
|
|
withAnimation { _ = mcpService.removeFolder(at: index) }
|
|
} label: {
|
|
Image(systemName: "trash.fill")
|
|
.foregroundStyle(.red)
|
|
.font(.system(size: 13))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 0) {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
Button {
|
|
showFolderPicker = true
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "plus")
|
|
Text("Add Folder...")
|
|
}
|
|
.font(.system(size: 14))
|
|
}
|
|
.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")
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
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)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.bottom, 12)
|
|
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
Text("Write Permissions (optional)")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
.padding(.bottom, 8)
|
|
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
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)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
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(.system(size: 13))
|
|
.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(.system(size: 13))
|
|
.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(.system(size: 13))
|
|
.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(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 40)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Advanced Tab
|
|
|
|
@ViewBuilder
|
|
private var advancedTab: some View {
|
|
sectionHeader("Response Generation")
|
|
row("") {
|
|
Toggle("Enable Streaming Responses", isOn: $settingsService.streamEnabled)
|
|
}
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
Text("Stream responses as they're generated. Disable for single, complete responses.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
divider()
|
|
|
|
sectionHeader("Model Parameters")
|
|
|
|
// Max Tokens
|
|
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)
|
|
}
|
|
}
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
Text("Maximum tokens in the response. Set to 0 to use model default. Higher values allow longer responses but cost more.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
// Temperature
|
|
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)
|
|
}
|
|
}
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Controls randomness. Set to 0 to use model default.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
Text("• Lower (0.0-0.7): More focused, deterministic")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
Text("• Higher (0.8-2.0): More creative, random")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
divider()
|
|
|
|
sectionHeader("System Prompts")
|
|
|
|
// Default prompt (read-only)
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
HStack(spacing: 4) {
|
|
Text("Default Prompt")
|
|
.font(.system(size: 14))
|
|
.fontWeight(.medium)
|
|
Text("(always used)")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
HStack(alignment: .top, spacing: 0) {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
ScrollView {
|
|
Text(defaultSystemPrompt)
|
|
.font(.system(size: 13, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(8)
|
|
}
|
|
.frame(height: 160)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(6)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
Text("This default prompt is always included to ensure accurate, helpful responses.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.padding(.bottom, 8)
|
|
|
|
// Custom prompt (editable)
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
HStack(spacing: 4) {
|
|
Text("Your Custom Prompt")
|
|
.font(.system(size: 14))
|
|
.fontWeight(.medium)
|
|
Text("(optional)")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
HStack(alignment: .top, spacing: 0) {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
TextEditor(text: Binding(
|
|
get: { settingsService.systemPrompt ?? "" },
|
|
set: { settingsService.systemPrompt = $0.isEmpty ? nil : $0 }
|
|
))
|
|
.font(.system(size: 13, design: .monospaced))
|
|
.frame(height: 120)
|
|
.padding(8)
|
|
.background(Color(NSColor.textBackgroundColor))
|
|
.cornerRadius(6)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
Text("Add additional instructions here. This will be appended to the default prompt. Leave empty if you don't need custom instructions.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
divider()
|
|
|
|
sectionHeader("Info")
|
|
HStack {
|
|
Spacer().frame(width: labelWidth + 12)
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("⚠️ These are advanced settings")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.orange)
|
|
.fontWeight(.medium)
|
|
Text("Changing these values affects how the AI generates responses. The defaults work well for most use cases. Only adjust if you know what you're doing.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Layout Helpers
|
|
|
|
private func sectionHeader(_ title: String) -> some View {
|
|
Text(title)
|
|
.font(.system(size: 14, 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(.system(size: 14))
|
|
.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()
|
|
}
|