From 41185cc08b9b0d457183263f1568e99170063ed7 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 20 Feb 2026 14:49:56 +0100 Subject: [PATCH] Version 2.3.2 --- oAI.xcodeproj/project.pbxproj | 4 +- oAI/Services/MCPService.swift | 10 + oAI/Services/PaperlessService.swift | 496 +++++ oAI/Services/SettingsService.swift | 44 + oAI/Services/UpdateCheckService.swift | 27 +- oAI/Views/Main/FooterView.swift | 2 +- oAI/Views/Screens/SettingsView.swift | 2482 +++++++++++++------------ 7 files changed, 1842 insertions(+), 1223 deletions(-) create mode 100644 oAI/Services/PaperlessService.swift diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index 0c88173..bd660f1 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -279,7 +279,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.3.1; + MARKETING_VERSION = 2.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -323,7 +323,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.3.1; + MARKETING_VERSION = 2.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/oAI/Services/MCPService.swift b/oAI/Services/MCPService.swift index 5cb1d38..f696476 100644 --- a/oAI/Services/MCPService.swift +++ b/oAI/Services/MCPService.swift @@ -110,6 +110,7 @@ class MCPService { var respectGitignore: Bool { settings.mcpRespectGitignore } private let anytypeService = AnytypeMCPService.shared + private let paperlessService = PaperlessService.shared // MARK: - Tool Schema Generation @@ -214,6 +215,11 @@ class MCPService { tools.append(contentsOf: anytypeService.getToolSchemas()) } + // Add Paperless-NGX tools if enabled and configured + if settings.paperlessEnabled && settings.paperlessConfigured { + tools.append(contentsOf: paperlessService.getToolSchemas()) + } + return tools } @@ -332,6 +338,10 @@ class MCPService { if name.hasPrefix("anytype_") { return await anytypeService.executeTool(name: name, arguments: arguments) } + // Route paperless_* tools to PaperlessService + if name.hasPrefix("paperless_") { + return await paperlessService.executeTool(name: name, arguments: arguments) + } return ["error": "Unknown tool: \(name)"] } } diff --git a/oAI/Services/PaperlessService.swift b/oAI/Services/PaperlessService.swift new file mode 100644 index 0000000..1d95024 --- /dev/null +++ b/oAI/Services/PaperlessService.swift @@ -0,0 +1,496 @@ +// +// PaperlessService.swift +// oAI +// +// Paperless-NGX integration: search, read, and upload documents via REST API +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Rune Olsen +// +// This file is part of oAI. +// +// oAI is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// oAI is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +// Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with oAI. If not, see . + + +import Foundation +import os + +@Observable +class PaperlessService { + static let shared = PaperlessService() + + private let settings = SettingsService.shared + private let log = Logger(subsystem: "com.oai.oAI", category: "mcp") + private let readTimeout: TimeInterval = 15 + private let uploadTimeout: TimeInterval = 60 + + private(set) var isConnected = false + + // In-memory caches for ID → name resolution + private var tagCache: [Int: String] = [:] + private var correspondentCache: [Int: String] = [:] + private var documentTypeCache: [Int: String] = [:] + + private init() {} + + // MARK: - Connection Test + + func testConnection() async -> Result { + do { + let result = try await request(endpoint: "/api/documents/", queryParams: ["page_size": "1"]) + if let count = result["count"] as? Int { + isConnected = true + return .success("Connected (\(count) document\(count == 1 ? "" : "s"))") + } else { + isConnected = true + return .success("Connected to Paperless-NGX") + } + } catch { + isConnected = false + return .failure(error) + } + } + + // MARK: - Tool Schemas + + func getToolSchemas() -> [Tool] { + return [ + makeTool( + name: "paperless_search", + description: "Search for documents in Paperless-NGX by title, content, tags, or any text. Returns document metadata and a preview of OCR-extracted content. Use this to find invoices, contracts, letters, or any stored document.", + properties: [ + "query": prop("string", "Search query — can be text from document content, title, correspondent name, or tag"), + "page": prop("number", "Page number for pagination (default: 1, each page has 25 results)") + ], + required: ["query"] + ), + makeTool( + name: "paperless_get_document", + description: "Get the full details and complete OCR-extracted text content of a specific Paperless-NGX document by ID. Use after paperless_search to read the full text of a document.", + properties: [ + "document_id": prop("number", "The numeric ID of the document to retrieve") + ], + required: ["document_id"] + ), + makeTool( + name: "paperless_list_tags", + description: "List all tags defined in Paperless-NGX with their document counts.", + properties: [:], + required: [] + ), + makeTool( + name: "paperless_list_correspondents", + description: "List all correspondents (senders/recipients) defined in Paperless-NGX with their document counts.", + properties: [:], + required: [] + ), + makeTool( + name: "paperless_list_document_types", + description: "List all document types defined in Paperless-NGX with their document counts.", + properties: [:], + required: [] + ), + makeTool( + name: "paperless_upload_document", + description: "Upload a local file to Paperless-NGX for OCR processing and storage. Supports PDF, PNG, JPEG, TIFF, and other image formats.", + properties: [ + "file_path": prop("string", "Absolute path to the local file to upload"), + "title": prop("string", "Optional title for the document"), + "tag_ids": prop("string", "Optional comma-separated tag IDs to assign (e.g. '1,3,7')") + ], + required: ["file_path"] + ) + ] + } + + // MARK: - Tool Execution + + func executeTool(name: String, arguments: String) async -> [String: Any] { + log.info("Executing Paperless tool: \(name)") + + guard let argData = arguments.data(using: .utf8), + let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else { + return ["error": "Invalid arguments JSON"] + } + + do { + switch name { + case "paperless_search": + guard let query = args["query"] as? String else { + return ["error": "Missing required parameter: query"] + } + let page: Int + if let p = args["page"] as? Int { page = p } + else if let p = args["page"] as? Double { page = Int(p) } + else { page = 1 } + return try await searchDocuments(query: query, page: page) + + case "paperless_get_document": + let docId: Int + if let id = args["document_id"] as? Int { docId = id } + else if let id = args["document_id"] as? Double { docId = Int(id) } + else { return ["error": "Missing or invalid parameter: document_id (expected integer)"] } + return try await getDocument(id: docId) + + case "paperless_list_tags": + return try await listTags() + + case "paperless_list_correspondents": + return try await listCorrespondents() + + case "paperless_list_document_types": + return try await listDocumentTypes() + + case "paperless_upload_document": + guard let filePath = args["file_path"] as? String else { + return ["error": "Missing required parameter: file_path"] + } + let title = args["title"] as? String + let tagIds = args["tag_ids"] as? String + return try await uploadDocument(filePath: filePath, title: title, tagIds: tagIds) + + default: + return ["error": "Unknown Paperless tool: \(name)"] + } + } catch PaperlessError.notConfigured { + return ["error": "Paperless-NGX is not configured. Set your URL and API token in Settings > Paperless."] + } catch PaperlessError.unauthorized { + return ["error": "Invalid API token. Check your Paperless-NGX token in Settings > Paperless."] + } catch PaperlessError.httpError(let code, let msg) { + return ["error": "Paperless-NGX API error \(code): \(msg)"] + } catch { + return ["error": "Paperless error: \(error.localizedDescription)"] + } + } + + // MARK: - API Operations + + private func searchDocuments(query: String, page: Int) async throws -> [String: Any] { + await prefetchCaches() + + let result = try await request(endpoint: "/api/documents/", queryParams: [ + "query": query, + "page": String(page) + ]) + + let total = result["count"] as? Int ?? 0 + guard let rawResults = result["results"] as? [[String: Any]] else { + return ["total": total, "page": page, "results": []] + } + + let formatted = rawResults.map { doc -> [String: Any] in + var item: [String: Any] = [:] + item["id"] = doc["id"] ?? 0 + item["title"] = doc["title"] ?? "Untitled" + item["created"] = (doc["created"] as? String).map { String($0.prefix(10)) } ?? "" + + if let corrId = doc["correspondent"] as? Int { + item["correspondent"] = correspondentCache[corrId] ?? "ID:\(corrId)" + } + + if let dtId = doc["document_type"] as? Int { + item["document_type"] = documentTypeCache[dtId] ?? "ID:\(dtId)" + } + + if let tagIds = doc["tags"] as? [Int] { + item["tags"] = tagIds.map { tagCache[$0] ?? "ID:\($0)" } + } + + // Content preview capped at 500 chars + if let content = doc["content"] as? String, !content.isEmpty { + let preview = content.trimmingCharacters(in: .whitespacesAndNewlines) + item["content_preview"] = String(preview.prefix(500)) + } + + return item + } + + return ["total": total, "page": page, "results": formatted] + } + + private func getDocument(id: Int) async throws -> [String: Any] { + await prefetchCaches() + + let doc = try await request(endpoint: "/api/documents/\(id)/") + + var result: [String: Any] = [:] + result["id"] = doc["id"] ?? id + result["title"] = doc["title"] ?? "Untitled" + result["created"] = (doc["created"] as? String).map { String($0.prefix(10)) } ?? "" + result["added"] = (doc["added"] as? String).map { String($0.prefix(10)) } ?? "" + result["modified"] = (doc["modified"] as? String).map { String($0.prefix(10)) } ?? "" + + if let corrId = doc["correspondent"] as? Int { + result["correspondent"] = correspondentCache[corrId] ?? "ID:\(corrId)" + } + if let dtId = doc["document_type"] as? Int { + result["document_type"] = documentTypeCache[dtId] ?? "ID:\(dtId)" + } + if let tagIds = doc["tags"] as? [Int] { + result["tags"] = tagIds.map { tagCache[$0] ?? "ID:\($0)" } + } + if let asn = doc["archive_serial_number"] as? String { + result["archive_serial_number"] = asn + } + + // Full OCR content capped at 30,000 chars + if let content = doc["content"] as? String { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + result["content"] = String(trimmed.prefix(30_000)) + result["content_length"] = trimmed.count + } + + return result + } + + private func listTags() async throws -> [String: Any] { + let result = try await request(endpoint: "/api/tags/", queryParams: ["page_size": "250"]) + guard let items = result["results"] as? [[String: Any]] else { + return ["count": 0, "tags": []] + } + let formatted = items.map { tag -> [String: Any] in + ["id": tag["id"] ?? 0, "name": tag["name"] ?? "Unknown", "count": tag["document_count"] ?? 0] + } + return ["count": formatted.count, "tags": formatted] + } + + private func listCorrespondents() async throws -> [String: Any] { + let result = try await request(endpoint: "/api/correspondents/", queryParams: ["page_size": "250"]) + guard let items = result["results"] as? [[String: Any]] else { + return ["count": 0, "correspondents": []] + } + let formatted = items.map { c -> [String: Any] in + ["id": c["id"] ?? 0, "name": c["name"] ?? "Unknown", "count": c["document_count"] ?? 0] + } + return ["count": formatted.count, "correspondents": formatted] + } + + private func listDocumentTypes() async throws -> [String: Any] { + let result = try await request(endpoint: "/api/document_types/", queryParams: ["page_size": "250"]) + guard let items = result["results"] as? [[String: Any]] else { + return ["count": 0, "document_types": []] + } + let formatted = items.map { dt -> [String: Any] in + ["id": dt["id"] ?? 0, "name": dt["name"] ?? "Unknown", "count": dt["document_count"] ?? 0] + } + return ["count": formatted.count, "document_types": formatted] + } + + private func uploadDocument(filePath: String, title: String?, tagIds: String?) async throws -> [String: Any] { + let expanded = (filePath as NSString).expandingTildeInPath + let resolved = (expanded as NSString).standardizingPath + + guard FileManager.default.fileExists(atPath: resolved) else { + return ["error": "File not found: \(filePath)"] + } + guard let fileData = FileManager.default.contents(atPath: resolved) else { + return ["error": "Cannot read file: \(filePath)"] + } + + let fileName = (resolved as NSString).lastPathComponent + + guard let token = settings.paperlessAPIToken, !token.isEmpty else { + throw PaperlessError.notConfigured + } + let baseURL = settings.paperlessURL + guard !baseURL.isEmpty, let url = URL(string: baseURL + "/api/documents/post_document/") else { + throw PaperlessError.notConfigured + } + + let boundary = "PaperlessBoundary\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" + var body = Data() + + func appendField(_ name: String, _ value: String) { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!) + body.append("\(value)\r\n".data(using: .utf8)!) + } + + if let title = title, !title.isEmpty { + appendField("title", title) + } + + if let tagIds = tagIds { + let ids = tagIds.split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + for id in ids { + appendField("tags", String(id)) + } + } + + let mimeType = mimeTypeFor(fileName: fileName) + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"document\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) + body.append(fileData) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + var urlRequest = URLRequest(url: url, timeoutInterval: uploadTimeout) + urlRequest.httpMethod = "POST" + urlRequest.setValue("Token \(token)", forHTTPHeaderField: "Authorization") + urlRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = body + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + guard let httpResponse = response as? HTTPURLResponse else { + throw PaperlessError.httpError(0, "Invalid response") + } + + if httpResponse.statusCode == 401 { throw PaperlessError.unauthorized } + + if (200...299).contains(httpResponse.statusCode) { + return ["success": true, "message": "Document uploaded successfully. Paperless-NGX will process it shortly."] + } + + let msg = String(data: data, encoding: .utf8) ?? "Unknown error" + throw PaperlessError.httpError(httpResponse.statusCode, msg) + } + + // MARK: - Cache Prefetch + + private func prefetchCaches() async { + if tagCache.isEmpty { + if let result = try? await request(endpoint: "/api/tags/", queryParams: ["page_size": "250"]), + let items = result["results"] as? [[String: Any]] { + for item in items { + if let id = item["id"] as? Int, let name = item["name"] as? String { + tagCache[id] = name + } + } + } + } + if correspondentCache.isEmpty { + if let result = try? await request(endpoint: "/api/correspondents/", queryParams: ["page_size": "250"]), + let items = result["results"] as? [[String: Any]] { + for item in items { + if let id = item["id"] as? Int, let name = item["name"] as? String { + correspondentCache[id] = name + } + } + } + } + if documentTypeCache.isEmpty { + if let result = try? await request(endpoint: "/api/document_types/", queryParams: ["page_size": "250"]), + let items = result["results"] as? [[String: Any]] { + for item in items { + if let id = item["id"] as? Int, let name = item["name"] as? String { + documentTypeCache[id] = name + } + } + } + } + } + + // MARK: - HTTP Client + + private func request(endpoint: String, queryParams: [String: String] = [:]) async throws -> [String: Any] { + guard let token = settings.paperlessAPIToken, !token.isEmpty else { + throw PaperlessError.notConfigured + } + let baseURL = settings.paperlessURL + guard !baseURL.isEmpty else { throw PaperlessError.notConfigured } + + var urlString = baseURL + endpoint + if !queryParams.isEmpty { + var comps = URLComponents(string: urlString) ?? URLComponents() + comps.queryItems = queryParams.map { URLQueryItem(name: $0.key, value: $0.value) } + urlString = comps.url?.absoluteString ?? urlString + } + + guard let url = URL(string: urlString) else { + throw PaperlessError.httpError(0, "Invalid URL: \(urlString)") + } + + var urlRequest = URLRequest(url: url, timeoutInterval: readTimeout) + urlRequest.httpMethod = "GET" + urlRequest.setValue("Token \(token)", forHTTPHeaderField: "Authorization") + urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") + + do { + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw PaperlessError.httpError(0, "Invalid response") + } + + if httpResponse.statusCode == 401 { throw PaperlessError.unauthorized } + + guard (200...299).contains(httpResponse.statusCode) else { + let msg = String(data: data, encoding: .utf8) ?? "Unknown error" + throw PaperlessError.httpError(httpResponse.statusCode, msg) + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [:] + } + return json + } catch let error as PaperlessError { + throw error + } catch { + throw PaperlessError.httpError(0, error.localizedDescription) + } + } + + // MARK: - Helpers + + private func mimeTypeFor(fileName: String) -> String { + let ext = (fileName as NSString).pathExtension.lowercased() + switch ext { + case "pdf": return "application/pdf" + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "tiff", "tif": return "image/tiff" + case "gif": return "image/gif" + case "webp": return "image/webp" + default: return "application/octet-stream" + } + } + + private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool { + Tool( + type: "function", + function: Tool.Function( + name: name, + description: description, + parameters: Tool.Function.Parameters( + type: "object", + properties: properties, + required: required + ) + ) + ) + } + + private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property { + Tool.Function.Parameters.Property(type: type, description: description, enum: nil) + } +} + +// MARK: - Error Types + +enum PaperlessError: LocalizedError { + case notConfigured + case unauthorized + case httpError(Int, String) + + var errorDescription: String? { + switch self { + case .notConfigured: + return "Paperless-NGX is not configured. Set your URL and API token in Settings > Paperless." + case .unauthorized: + return "Invalid API token. Check your Paperless-NGX token in Settings > Paperless." + case .httpError(let code, let msg): + return "Paperless-NGX API error \(code): \(msg)" + } + } +} diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 1fab9a3..76743cf 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -42,6 +42,7 @@ class SettingsService { static let googleAPIKey = "googleAPIKey" static let googleSearchEngineID = "googleSearchEngineID" static let anytypeMcpAPIKey = "anytypeMcpAPIKey" + static let paperlessAPIToken = "paperlessAPIToken" } // Old keychain keys (for migration only) @@ -446,6 +447,49 @@ class SettingsService { return !key.isEmpty } + // MARK: - Paperless-NGX Settings + + var paperlessEnabled: Bool { + get { cache["paperlessEnabled"] == "true" } + set { + cache["paperlessEnabled"] = String(newValue) + DatabaseService.shared.setSetting(key: "paperlessEnabled", value: String(newValue)) + } + } + + var paperlessURL: String { + get { cache["paperlessURL"] ?? "" } + set { + var trimmed = newValue.trimmingCharacters(in: .whitespaces) + // Remove trailing slash for consistency + while trimmed.hasSuffix("/") { trimmed = String(trimmed.dropLast()) } + if trimmed.isEmpty { + cache.removeValue(forKey: "paperlessURL") + DatabaseService.shared.deleteSetting(key: "paperlessURL") + } else { + cache["paperlessURL"] = trimmed + DatabaseService.shared.setSetting(key: "paperlessURL", value: trimmed) + } + } + } + + var paperlessAPIToken: String? { + get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.paperlessAPIToken) } + set { + if let value = newValue, !value.isEmpty { + try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.paperlessAPIToken, value: value) + } else { + DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.paperlessAPIToken) + } + } + } + + var paperlessConfigured: Bool { + guard !paperlessURL.isEmpty else { return false } + guard let token = paperlessAPIToken else { return false } + return !token.isEmpty + } + // MARK: - Search Settings var searchProvider: Settings.SearchProvider { diff --git a/oAI/Services/UpdateCheckService.swift b/oAI/Services/UpdateCheckService.swift index d47fe3c..d2d3c44 100644 --- a/oAI/Services/UpdateCheckService.swift +++ b/oAI/Services/UpdateCheckService.swift @@ -36,37 +36,44 @@ final class UpdateCheckService { var updateAvailable: Bool = false var latestVersion: String? = nil - private let apiURL = "https://gitlab.pm/api/v4/projects/rune%2Foai-swift/releases" + private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest" private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")! private init() {} /// Kick off a background update check. Silently does nothing on failure. func checkForUpdates() { - Task { - await performCheck() + Task.detached(priority: .background) { + await self.performCheck() } } - @MainActor private func performCheck() async { guard let url = URL(string: apiURL) else { return } var request = URLRequest(url: url) request.timeoutInterval = 10 - guard let (data, _) = try? await URLSession.shared.data(for: request) else { return } - guard let releases = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], - let latest = releases.first, - let tagName = latest["tag_name"] as? String else { return } + guard let (data, _) = try? await URLSession.shared.data(for: request) else { + Log.ui.warning("UpdateCheck: network request failed") + return + } + + guard let release = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tagName = release["tag_name"] as? String else { + Log.ui.warning("UpdateCheck: unexpected API response — \(String(data: data, encoding: .utf8) ?? "")") + return + } // Strip leading "v" from tag (e.g. "v2.3.1" → "2.3.1") let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" if isNewer(latestVer, than: currentVer) { - self.latestVersion = latestVer - self.updateAvailable = true + await MainActor.run { + self.latestVersion = latestVer + self.updateAvailable = true + } } } diff --git a/oAI/Views/Main/FooterView.swift b/oAI/Views/Main/FooterView.swift index f72df8e..32d6bb1 100644 --- a/oAI/Views/Main/FooterView.swift +++ b/oAI/Views/Main/FooterView.swift @@ -221,7 +221,7 @@ struct SyncStatusFooter: View { } private func updateSyncStatus() { - if let error = gitSync.lastSyncError { + if gitSync.lastSyncError != nil { syncText = "Sync Error" syncColor = .red } else if gitSync.isSyncing { diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 9cac34a..43253d5 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -44,6 +44,7 @@ struct SettingsView: View { @State private var googleEngineID = "" @State private var showFolderPicker = false @State private var selectedTab = 0 + @State private var logLevel: LogLevel = FileLogger.shared.minimumLevel // Git Sync state @State private var syncRepoURL = "" @@ -57,6 +58,13 @@ struct SettingsView: View { @State private var syncTestResult: String? @State private var isSyncing = false + // 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? + // Email handler state @State private var showEmailLog = false @State private var showEmailModelSelector = false @@ -96,25 +104,39 @@ It's better to admit "I need more information" or "I cannot do that" than to fak 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) - Text("Sync").tag(4) - Text("Email").tag(5) - Text("Shortcuts").tag(6) - Text("Skills").tag(7) + // 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) } - .pickerStyle(.segmented) - .padding(.horizontal, 24) + .padding(.top, 14) + .padding(.bottom, 4) + + // Icon toolbar — Core | separator | Extras + 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, 8) + + 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") + } + .padding(.horizontal, 16) .padding(.bottom, 12) Divider() @@ -138,6 +160,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak shortcutsTab case 7: agentSkillsTab + case 8: + paperlessTab default: generalTab } @@ -146,21 +170,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .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) + .frame(minWidth: 740, idealWidth: 820, minHeight: 620, idealHeight: 760) .sheet(isPresented: $showEmailLog) { EmailLogView() } @@ -171,143 +182,165 @@ It's better to admit "I need more information" or "I cannot do that" than to fak @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) + 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() } } - .labelsHidden() - .fixedSize() } - divider() - // API Keys - sectionHeader("API Keys") - row("OpenRouter") { - SecureField("sk-or-...", text: $openrouterKey) - .textFieldStyle(.roundedBorder) - .font(.system(size: 13)) - .frame(width: 400) - .onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" } - .onChange(of: openrouterKey) { - settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey - ProviderRegistry.shared.clearCache() + 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() + } } - } - // Anthropic: API key - row("Anthropic") { - 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() + 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() + } } - } - row("OpenAI") { - SecureField("sk-...", text: $openaiKey) - .textFieldStyle(.roundedBorder) - .frame(width: 400) - .onAppear { openaiKey = settingsService.openaiAPIKey ?? "" } - .onChange(of: openaiKey) { - settingsService.openaiAPIKey = openaiKey.isEmpty ? nil : openaiKey - 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") + } + } } - row("Ollama URL") { - TextField("http://localhost:11434", text: $settingsService.ollamaBaseURL) - .textFieldStyle(.roundedBorder) - .frame(width: 400) - .help("Enter your Ollama server URL to enable the Ollama provider") - } - - divider() // Features - sectionHeader("Features") - row("Online Mode (Web Search)") { - Toggle("", isOn: $settingsService.onlineMode) - .toggleStyle(.switch) + 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("MCP (File Access)") { + Toggle("", isOn: $settingsService.mcpEnabled) + .toggleStyle(.switch) + } + } } - row("Conversation Memory") { - Toggle("", isOn: $settingsService.memoryEnabled) - .toggleStyle(.switch) - } - row("MCP (File Access)") { - Toggle("", isOn: $settingsService.mcpEnabled) - .toggleStyle(.switch) - } - - 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) + 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() + } + if settingsService.searchProvider == .google { + 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 + } + } + 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 + } + } } } - .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) + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Model Settings") + formSection { + 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) + .frame(width: 300) } } - .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) + + // 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/oAI.log") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 4) } // MARK: - MCP Tab @@ -328,17 +361,17 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } - .padding(.horizontal, 24) - .padding(.top, 12) - .padding(.bottom, 16) + .padding(.bottom, 8) - divider() - - // Enable toggle with status - sectionHeader("Status") - row("Enable MCP") { - Toggle("", isOn: $settingsService.mcpEnabled) - .toggleStyle(.switch) + // 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") @@ -348,57 +381,60 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .font(.system(size: 13)) .foregroundStyle(.secondary) } + .padding(.horizontal, 4) 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: 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)) + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Allowed Folders") + formSection { + 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) - } - Spacer() - Button { - withAnimation { _ = mcpService.removeFolder(at: index) } - } label: { - Image(systemName: "trash.fill") - .foregroundStyle(.red) + Text("Click 'Add Folder' below to grant AI access to a folder") .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() + } } - .buttonStyle(.plain) } } } - - HStack(spacing: 4) { + HStack { Button { showFolderPicker = true } label: { @@ -411,6 +447,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .buttonStyle(.borderless) Spacer() } + .padding(.horizontal, 4) .fileImporter( isPresented: $showFolderPicker, allowedContentTypes: [.folder], @@ -424,70 +461,70 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } - divider() - // Permissions - sectionHeader("Permissions") - - 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) + 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) + } } - Text("The AI can read and search files in allowed folders") - .font(.system(size: 12)) - .foregroundStyle(.tertiary) - .padding(.leading, 18) } - .padding(.bottom, 12) - - Text("Write Permissions (optional)") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.bottom, 8) - - row("Write & Edit Files") { - Toggle("", isOn: $settingsService.mcpCanWriteFiles) - .toggleStyle(.switch) - } - - row("Delete Files") { - Toggle("", isOn: $settingsService.mcpCanDeleteFiles) - .toggleStyle(.switch) - } - - row("Create Directories") { - Toggle("", isOn: $settingsService.mcpCanCreateDirectories) - .toggleStyle(.switch) - } - - row("Move & Copy Files") { - Toggle("", isOn: $settingsService.mcpCanMoveFiles) - .toggleStyle(.switch) - } - - divider() // Filtering - sectionHeader("Filtering") - row("Respect .gitignore") { - Toggle("", isOn: Binding( - get: { settingsService.mcpRespectGitignore }, - set: { newValue in - settingsService.mcpRespectGitignore = newValue - mcpService.reloadGitignores() + 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) } - )) - .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) @@ -497,327 +534,296 @@ It's better to admit "I need more information" or "I cannot do that" than to fak @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) + 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) + } + } } } - sectionHeader("Toolbar") - row("Icon Size") { - HStack(spacing: 8) { - Slider(value: $settingsService.toolbarIconSize, in: 16...32, step: 2) - .frame(maxWidth: 200) - Text("\(Int(settingsService.toolbarIconSize)) 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...32, 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) + } } } - row("") { - Toggle("Show Icon Labels", isOn: $settingsService.showToolbarLabels) - .toggleStyle(.switch) - } - HStack { - Text("Show text labels below toolbar icons (helpful for new users)") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, labelWidth + 20) - .padding(.bottom, 12) + 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 { - sectionHeader("Response Generation") - row("Enable Streaming") { - Toggle("", isOn: $settingsService.streamEnabled) - .toggleStyle(.switch) + // 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) - 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) + // 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) + } + } } } - 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) - } - } - VStack(alignment: .leading, spacing: 4) { - Text("Controls randomness. Set to 0 to use model default.") + 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("• Lower (0.0-0.7): More focused, deterministic") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - Text("• Higher (0.8-2.0): More creative, random") + Text("Temperature: 0 = model default · 0.0–0.7 = focused · 0.8–2.0 = creative") .font(.system(size: 13)) .foregroundStyle(.secondary) } .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 4) - 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 mode toggle - HStack { - Text("Use Only Your Prompt") - .frame(width: labelWidth, alignment: .trailing) - 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) - .frame(width: 140, alignment: .leading) - - Spacer() - } - HStack { - Spacer().frame(width: labelWidth + 12) - Text(settingsService.customPromptMode == .replace - ? "⚠️ Only your custom prompt will be used. Default prompt and tool guidelines are disabled." - : "Your custom prompt will be added after the default prompt and tool guidelines.") - .font(.system(size: 13)) - .foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .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(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)") + // 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) + } + } } } - 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(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. The default prompt and tool guidelines will be ignored.") - .font(.system(size: 13)) - .foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .secondary) - .fixedSize(horizontal: false, vertical: true) - } } - - divider() - - sectionHeader("Memory & Context") - - row("Smart Context Selection") { - Toggle("", isOn: $settingsService.contextSelectionEnabled) - .toggleStyle(.switch) - } - Text("Automatically select relevant messages instead of sending all history. Reduces token usage and improves response quality for long conversations.") + 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) - if settingsService.contextSelectionEnabled { - 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) + // 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) + } } } - HStack { - Spacer().frame(width: labelWidth + 12) - Text("Maximum context window size. Default is 100,000 tokens. Smart selection will prioritize recent and starred messages within this limit.") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } } - - divider() - - sectionHeader("Semantic Search") - - row("Enable Embeddings") { - Toggle("", isOn: $settingsService.embeddingsEnabled) - .toggleStyle(.switch) - .disabled(!EmbeddingService.shared.isAvailable) - } - - // Show status based on available providers if let provider = EmbeddingService.shared.getBestAvailableProvider() { - Text("Enable AI-powered semantic search across conversations using \(provider.displayName) embeddings.") + Text("Enable AI-powered semantic search using \(provider.displayName) embeddings. Cost: ~$0.02–0.15/1M tokens.") .font(.system(size: 13)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 4) } else { - Text("⚠️ No embedding providers available. Please configure an API key for OpenAI, OpenRouter, or Google in the General tab.") + Text("⚠️ No embedding providers available. Configure an API key for OpenAI, OpenRouter, or Google in the General tab.") .font(.system(size: 13)) .foregroundStyle(.orange) .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 4) } - if settingsService.embeddingsEnabled { - 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) - } HStack { - Spacer().frame(width: labelWidth + 12) - Text("Cost: OpenAI ~$0.02-0.13/1M tokens, OpenRouter similar, Google ~$0.15/1M tokens") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - HStack { - Spacer().frame(width: labelWidth + 12) Button("Embed All Conversations") { Task { if let chatVM = chatViewModel { @@ -826,65 +832,59 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } .help("Generate embeddings for all existing messages (one-time operation)") + Spacer() } - HStack { - Spacer().frame(width: labelWidth + 12) - Text("⚠️ This will generate embeddings for all messages in all conversations. Estimated cost: ~$0.04 for 10,000 messages.") - .font(.system(size: 13)) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } + .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) } - divider() - - sectionHeader("Progressive Summarization") - - row("Enable Summarization") { - Toggle("", isOn: $settingsService.progressiveSummarizationEnabled) - .toggleStyle(.switch) + // 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) - if settingsService.progressiveSummarizationEnabled { - 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) - } - } - HStack { - Spacer().frame(width: labelWidth + 12) - Text("When a conversation exceeds this many messages, older messages will be summarized. Default: 50 messages.") - .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) - } + // 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 @@ -892,21 +892,23 @@ It's better to admit "I need more information" or "I cannot do that" than to fak @ViewBuilder private var syncTab: some View { Group { - sectionHeader("Git Sync") - + // 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: 14)) + .font(.system(size: 13)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - - row("Enable Git Sync") { - Toggle("", isOn: $settingsService.syncEnabled) - .toggleStyle(.switch) - } + .padding(.horizontal, 4) if settingsService.syncEnabled { - VStack(alignment: .leading, spacing: 16) { - // Status indicator HStack(spacing: 8) { Image(systemName: syncStatusIcon) @@ -915,185 +917,229 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .font(.system(size: 14)) .foregroundStyle(syncStatusColor) } - .padding(.leading, labelWidth + 12) + .padding(.horizontal, 4) - divider() - - // Repository URL - sectionHeader("Repository") - - row("URL") { - TextField("https://github.com/user/oai-sync.git", text: $syncRepoURL) - .textFieldStyle(.roundedBorder) - .onChange(of: syncRepoURL) { - settingsService.syncRepoURL = syncRepoURL + // 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") + Text("💡 Use HTTPS URL (e.g., https://gitlab.pm/user/repo.git) — works with all auth methods.") .font(.system(size: 13)) .foregroundStyle(.secondary) - - row("Local Path") { - TextField("~/Library/Application Support/oAI/sync", text: $syncLocalPath) - .textFieldStyle(.roundedBorder) - .onChange(of: syncLocalPath) { - settingsService.syncLocalPath = syncLocalPath - } - } - - divider() + .padding(.horizontal, 4) // Authentication - sectionHeader("Authentication") - - 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: 400) - } - - // SSH info - if settingsService.syncAuthMethod == "ssh" { - VStack(alignment: .leading, spacing: 6) { - Text("ℹ️ SSH Key Authentication") - .font(.system(size: 13, weight: .semibold)) - Text("• Uses your system SSH keys (~/.ssh/id_ed25519)") - Text("• Add public key to your git provider") - Text("• No credentials needed in oAI") - } - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } - - // Username + Password - if settingsService.syncAuthMethod == "password" { - row("Username") { - TextField("username", text: $syncUsername) - .textFieldStyle(.roundedBorder) - .onChange(of: syncUsername) { - settingsService.syncUsername = syncUsername.isEmpty ? nil : syncUsername + 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) + } - row("Password") { - HStack { - if showSyncPassword { - TextField("", text: $syncPassword) - .textFieldStyle(.roundedBorder) - } else { - SecureField("", text: $syncPassword) + 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 oAI") + .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 + } } - Button(action: { showSyncPassword.toggle() }) { - Image(systemName: showSyncPassword ? "eye.slash" : "eye") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .onChange(of: syncPassword) { - settingsService.syncPassword = syncPassword.isEmpty ? nil : syncPassword + 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 || !settingsService.syncConfigured) + 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) } - - // Access Token if settingsService.syncAuthMethod == "token" { - 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 - } - } + if let tokenURL = tokenGenerationURL { + Link("→ Open \(extractProvider()) Settings to generate a token", destination: URL(string: tokenURL)!) + .font(.system(size: 13)) + .padding(.horizontal, 4) } - - VStack(alignment: .leading, spacing: 6) { - Text("💡 Generate Access Token:") - .font(.system(size: 13, weight: .semibold)) - - if let tokenURL = tokenGenerationURL { - Link("→ Open \(extractProvider()) Settings", destination: URL(string: tokenURL)!) - .font(.system(size: 13)) - } else { - Text("• GitHub: Settings > Developer > Personal Access Tokens") - Text("• GitLab: Preferences > Access Tokens") - Text("• Gitea: Settings > Applications > Generate New Token") - } - } - .font(.system(size: 13)) - .foregroundStyle(.secondary) } - // Test connection - row("") { - HStack { - if let result = syncTestResult { - Text(result) - .font(.system(size: 14)) - .foregroundStyle(result.hasPrefix("✓") ? .green : .red) + // Sync Options + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Sync Options") + formSection { + row("Auto-export on save") { + Toggle("", isOn: $settingsService.syncAutoExport) + .toggleStyle(.switch) } - Button(action: { Task { await testSyncConnection() } }) { - HStack { - if isTestingSync { - ProgressView() - .scaleEffect(0.7) - .frame(width: 14, height: 14) - } else { - Image(systemName: "checkmark.circle") + rowDivider() + row("Auto-pull on launch") { + Toggle("", isOn: $settingsService.syncAutoPull) + .toggleStyle(.switch) + } + } + } + + // Auto-Save + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Auto-Save") + formSection { + row("Enable Auto-Save") { + Toggle("", isOn: $settingsService.syncAutoSave) + .toggleStyle(.switch) + } + if settingsService.syncAutoSave { + rowDivider() + row("Min Messages") { + HStack { + Slider(value: Binding( + get: { Double(settingsService.syncAutoSaveMinMessages) }, + set: { settingsService.syncAutoSaveMinMessages = Int($0) } + ), in: 3...20, step: 1) + .frame(width: 200) + Text("\(settingsService.syncAutoSaveMinMessages)") + .font(.system(size: 14)) + .frame(width: 30) + } + } + rowDivider() + row("On model switch") { + Toggle("", isOn: $settingsService.syncAutoSaveOnModelSwitch) + .toggleStyle(.switch) + } + rowDivider() + row("On app quit") { + Toggle("", isOn: $settingsService.syncAutoSaveOnAppQuit) + .toggleStyle(.switch) + } + rowDivider() + row("After idle timeout") { + Toggle("", isOn: $settingsService.syncAutoSaveOnIdle) + .toggleStyle(.switch) + } + if settingsService.syncAutoSaveOnIdle { + rowDivider() + row("Idle Timeout") { + HStack { + Slider(value: Binding( + get: { Double(settingsService.syncAutoSaveIdleMinutes) }, + set: { settingsService.syncAutoSaveIdleMinutes = Int($0) } + ), in: 1...30, step: 1) + .frame(width: 200) + Text("\(settingsService.syncAutoSaveIdleMinutes) min") + .font(.system(size: 14)) + .frame(width: 60) + } } - Text("Test Connection") } } - .disabled(isTestingSync || !settingsService.syncConfigured) } } - - divider() - - // Sync options - sectionHeader("Sync Options") - - row("Auto-export on save") { - Toggle("", isOn: $settingsService.syncAutoExport) - .toggleStyle(.switch) - } - row("Auto-pull on launch") { - Toggle("", isOn: $settingsService.syncAutoPull) - .toggleStyle(.switch) - } - - divider() - - // Auto-Save & Smart Sync - sectionHeader("Auto-Save & Smart Sync") - - row("Enable Auto-Save") { - Toggle("", isOn: $settingsService.syncAutoSave) - .toggleStyle(.switch) - } - if settingsService.syncAutoSave { - // Warning about conflicts HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) @@ -1101,148 +1147,77 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .font(.system(size: 13)) .foregroundStyle(.orange) } - .padding(.vertical, 4) - - // Minimum messages - row("Min Messages") { - HStack { - Slider(value: Binding( - get: { Double(settingsService.syncAutoSaveMinMessages) }, - set: { settingsService.syncAutoSaveMinMessages = Int($0) } - ), in: 3...20, step: 1) - .frame(width: 200) - Text("\(settingsService.syncAutoSaveMinMessages)") - .font(.system(size: 14)) - .frame(width: 30) - } - } - - Text("Only auto-save conversations with at least this many messages") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - - // Trigger options - Text("Triggers") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.top, 8) - - row("On model switch") { - Toggle("", isOn: $settingsService.syncAutoSaveOnModelSwitch) - .toggleStyle(.switch) - } - row("On app quit") { - Toggle("", isOn: $settingsService.syncAutoSaveOnAppQuit) - .toggleStyle(.switch) - } - row("After idle timeout") { - Toggle("", isOn: $settingsService.syncAutoSaveOnIdle) - .toggleStyle(.switch) - } - - // Idle timeout - if settingsService.syncAutoSaveOnIdle { - row("Idle Timeout") { - HStack { - Slider(value: Binding( - get: { Double(settingsService.syncAutoSaveIdleMinutes) }, - set: { settingsService.syncAutoSaveIdleMinutes = Int($0) } - ), in: 1...30, step: 1) - .frame(width: 200) - Text("\(settingsService.syncAutoSaveIdleMinutes) min") - .font(.system(size: 14)) - .frame(width: 60) - } - } - - Text("Auto-save if no messages for this many minutes") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } + .padding(.horizontal, 4) } - divider() - - // Manual actions - sectionHeader("Manual Sync") - - row("") { - HStack(spacing: 12) { - if !gitSync.syncStatus.isCloned { - // Not cloned yet - show initialize button - 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") + // 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("Initialize Repository") } - Text("Initialize Repository") + .frame(minWidth: 160) } - .frame(minWidth: 160) - } - .disabled(!settingsService.syncConfigured || isSyncing) - } else { - // Already cloned - show sync button - 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") + .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") } - Text("Sync Now") + .frame(minWidth: 160) } - .frame(minWidth: 160) + .disabled(isSyncing) } - .disabled(isSyncing) + Spacer() } - - Spacer() + .padding(.horizontal, 16) + .padding(.vertical, 10) } } - - // Status if gitSync.syncStatus.isCloned { - HStack { - Spacer().frame(width: labelWidth + 12) - 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) - } + 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 @@ -1251,7 +1226,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak syncPassword = settingsService.syncPassword ?? "" syncAccessToken = settingsService.syncAccessToken ?? "" - // Update sync status to check if repository is already cloned Task { await gitSync.updateStatus() } @@ -1263,13 +1237,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak @ViewBuilder private var emailTab: some View { Group { - sectionHeader("AI Email Handler") - - Text("Let AI automatically respond to emails sent to your designated email account. Uses IMAP IDLE for real-time monitoring and replies with AI-generated responses.") - .font(.system(size: settingsService.guiTextSize)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - // Security recommendation box VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { @@ -1279,12 +1246,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .font(.system(size: settingsService.guiTextSize, weight: .semibold)) .foregroundColor(.orange) } - - Text("For security, create a dedicated email account specifically for AI handling. Do NOT use your personal email address.") + 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: oai-bot-x7k2m9p3@gmail.com") .font(.system(size: settingsService.guiTextSize - 1, design: .monospaced)) .foregroundColor(.blue) @@ -1295,372 +1260,314 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } .padding(12) .background(Color.orange.opacity(0.05)) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.orange.opacity(0.3), lineWidth: 1) - ) - - divider() + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.orange.opacity(0.3), lineWidth: 0.5)) // Enable toggle - row("Enable Email Handler") { - Toggle("", isOn: $settingsService.emailHandlerEnabled) - .toggleStyle(.switch) + VStack(alignment: .leading, spacing: 6) { + sectionHeader("AI Email Handler") + formSection { + row("Enable Email Handler") { + Toggle("", isOn: $settingsService.emailHandlerEnabled) + .toggleStyle(.switch) + } + } } if settingsService.emailHandlerEnabled { - divider() - // AI Configuration - sectionHeader("AI Configuration") - - // Provider selection - 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() - } - } - } - - // Model selection - 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) - } - } - - Text("Select which AI model handles incoming emails. This runs in parallel to your main chat session.") - .font(.system(size: settingsService.guiTextSize - 1)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - - divider() - - // Email Server Configuration - sectionHeader("Email Server") - - row("IMAP Host") { - TextField("imap.gmail.com", text: Binding( - get: { settingsService.emailImapHost ?? "" }, - set: { settingsService.emailImapHost = $0.isEmpty ? nil : $0 } - )) - .textFieldStyle(.roundedBorder) - .frame(width: 250) - } - - row("SMTP Host") { - TextField("smtp.gmail.com", text: Binding( - get: { settingsService.emailSmtpHost ?? "" }, - set: { settingsService.emailSmtpHost = $0.isEmpty ? nil : $0 } - )) - .textFieldStyle(.roundedBorder) - .frame(width: 250) - } - - row("IMAP Port") { - TextField("993", text: Binding( - get: { String(settingsService.emailImapPort) }, - set: { settingsService.emailImapPort = Int($0) ?? 993 } - )) - .textFieldStyle(.roundedBorder) - .frame(width: 100) - } - - row("SMTP Port") { - TextField("587", text: Binding( - get: { String(settingsService.emailSmtpPort) }, - set: { settingsService.emailSmtpPort = Int($0) ?? 587 } - )) - .textFieldStyle(.roundedBorder) - .frame(width: 100) - } - - row("Username") { - TextField("your-email@gmail.com", text: Binding( - get: { settingsService.emailUsername ?? "" }, - set: { settingsService.emailUsername = $0.isEmpty ? nil : $0 } - )) - .textFieldStyle(.roundedBorder) - .frame(width: 250) - } - - 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: 250) - } - - Text("💡 For Gmail, use an App Password (not your regular password). Go to Google Account > Security > 2-Step Verification > App passwords.") - .font(.system(size: settingsService.guiTextSize - 1)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - - row("") { - HStack { - Button(action: { - Task { await testEmailConnection() } - }) { - HStack { - if isTestingEmailConnection { - ProgressView() - .scaleEffect(0.7) - .frame(width: 14, height: 14) - } else { - Image(systemName: "checkmark.circle") + 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) } - Text("Test Connection") + } + .labelsHidden() + .frame(width: 250) + .onChange(of: settingsService.emailHandlerProvider) { + Task { await loadEmailModels() } } } - .disabled(isTestingEmailConnection || !settingsService.emailServerConfigured) - - if let result = emailConnectionTestResult { - Text(result) - .font(.system(size: settingsService.guiTextSize - 1)) - .foregroundColor(result.hasPrefix("✓") ? .green : .red) - .padding(.leading, 8) + 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) + } } } } - divider() + // 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 || !settingsService.emailServerConfigured) + 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 - sectionHeader("Email Trigger") - - row("Subject Identifier") { - TextField("", text: $settingsService.emailSubjectIdentifier) - .textFieldStyle(.roundedBorder) - .frame(width: 200) + 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) - .padding(.leading, labelWidth + 12) .fixedSize(horizontal: false, vertical: true) - - divider() + .padding(.horizontal, 4) // Rate Limiting - sectionHeader("Rate Limiting") - - row("Enable Rate Limit") { - Toggle("", isOn: $settingsService.emailRateLimitEnabled) - .toggleStyle(.switch) - } - - if settingsService.emailRateLimitEnabled { - 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)") - .font(.system(size: settingsService.guiTextSize)) - .frame(width: 40, alignment: .trailing) - - if settingsService.emailRateLimitPerHour == 100 { - Text("(Unlimited)") - .font(.system(size: settingsService.guiTextSize - 1)) - .foregroundColor(.secondary) + 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) + } } } } - - Text("Prevents abuse by limiting how many emails the AI will process per hour. Set to 100 for unlimited.") - .font(.system(size: settingsService.guiTextSize - 1)) - .foregroundColor(.secondary) - .padding(.leading, labelWidth + 12) - .fixedSize(horizontal: false, vertical: true) } - divider() - // Response Settings - sectionHeader("Response Settings") - - 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) + 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("Limits the length of AI responses to prevent excessive API costs. ~750 tokens = ~500 words.") + Text("~750 tokens ≈ 500 words. Online mode allows web search in responses.") .font(.system(size: settingsService.guiTextSize - 1)) .foregroundColor(.secondary) - .padding(.leading, labelWidth + 12) - .fixedSize(horizontal: false, vertical: true) - - row("Enable Online Mode") { - Toggle("", isOn: $settingsService.emailOnlineMode) - .toggleStyle(.switch) - } - - Text("Allow email handler to search the web for current information. Useful for weather, news, stock prices, or fact-checking. May increase response time and API costs.") - .font(.system(size: settingsService.guiTextSize - 1)) - .foregroundColor(.secondary) - .padding(.leading, labelWidth + 12) - .fixedSize(horizontal: false, vertical: true) - - divider() + .padding(.horizontal, 4) // Custom System Prompt - sectionHeader("Custom System Prompt (Optional)") + 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) + } - VStack(alignment: .leading, spacing: 8) { - // Warning box - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - VStack(alignment: .leading, spacing: 4) { - Text("Prompt Isolation & Override") - .font(.system(size: settingsService.guiTextSize, weight: .semibold)) - .foregroundColor(.orange) - Text("The email handler uses ONLY its own system prompt, completely isolated from your main chat settings. If you provide a custom prompt below, it will override the default email instructions. Your main chat system prompt and any Advanced settings prompts are never used for email handling.") - .font(.system(size: settingsService.guiTextSize - 1)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(10) - .background(Color.orange.opacity(0.1)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.orange.opacity(0.3), lineWidth: 1) - ) - - // Text editor - VStack(alignment: .leading, spacing: 6) { - 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 + 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)) } - .font(.system(size: settingsService.guiTextSize - 1)) - } - } - - TextEditor(text: $emailHandlerSystemPrompt) - .font(.system(size: settingsService.guiTextSize, design: .monospaced)) - .frame(height: 120) - .padding(8) - .background(Color.secondary.opacity(0.05)) - .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. The default prompt instructs the AI to be professional, use proper email etiquette, and format responses in Markdown. This is completely separate from your main chat settings.") - .font(.system(size: settingsService.guiTextSize - 2)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else { - Text("⚠️ Custom email prompt active - Only this prompt will be sent to the model. All other prompts are excluded.") - .font(.system(size: settingsService.guiTextSize - 2)) - .foregroundColor(.orange) - .fixedSize(horizontal: false, vertical: true) + 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) } } - divider() - - // View Email Log - row("Email Activity") { - Button(action: { - showEmailLog = true - }) { - HStack { - Image(systemName: "envelope.badge.fill") - Text("View Email Log") + // 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") + } + } } } } - Text("View history of processed emails, AI responses, and any errors.") - .font(.system(size: settingsService.guiTextSize - 1)) - .foregroundColor(.secondary) - .padding(.leading, labelWidth + 12) - .fixedSize(horizontal: false, vertical: true) - - divider() - // MCP Access Notice VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { @@ -1670,7 +1577,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .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) @@ -1678,11 +1584,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } .padding(12) .background(Color.blue.opacity(0.05)) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.blue.opacity(0.3), lineWidth: 1) - ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.blue.opacity(0.3), lineWidth: 0.5)) } } .onAppear { @@ -1716,26 +1619,185 @@ It's better to admit "I need more information" or "I cannot do that" than to fak AgentSkillsTabContent() } - // MARK: - Layout Helpers + // MARK: - Paperless Tab - private func sectionHeader(_ title: String) -> some View { - Text(title) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - } + @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) + } + } + } - private func row(_ label: String, @ViewBuilder content: () -> Content) -> some View { - HStack(alignment: .center, spacing: 12) { - Text(label) - .font(.system(size: 14)) - Spacer() - content() + 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 || !settingsService.paperlessConfigured) + 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 divider() -> some View { - Divider().padding(.vertical, 2) + private func testPaperlessConnection() async { + isTestingPaperless = true + paperlessTestResult = nil + 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: - Tab Navigation + + private func tabButton(_ tag: Int, icon: String, label: String) -> some View { + Button(action: { selectedTab = tag }) { + VStack(spacing: 3) { + Image(systemName: icon) + .font(.system(size: 22)) + .frame(height: 28) + .foregroundStyle(selectedTab == tag ? .blue : .secondary) + Text(label) + .font(.system(size: 11)) + .foregroundStyle(selectedTab == tag ? .blue : .secondary) + } + .frame(minWidth: 68) + .padding(.vertical, 6) + .padding(.horizontal, 6) + .background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + + private func tabTitle(_ tag: Int) -> String { + 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" + default: return "Settings" + } + } + + // MARK: - Layout Helpers + + private func row(_ label: String, @ViewBuilder content: () -> Content) -> some View { + HStack(alignment: .center, spacing: 12) { + if !label.isEmpty { + Text(label).font(.system(size: 14)) + } + Spacer() + content() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.horizontal, 4) + } + + private func formSection(@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) } private func abbreviatePath(_ path: String) -> String {