diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 3da55c8c..a79f5fc3 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -135,7 +135,6 @@ A147C5EC3F2214A670F7556E /* FocusPollBackoffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 273B4DC844F79B4BE2C8910F /* FocusPollBackoffTests.swift */; }; A2B3F4D38BCB0FED452B2A3F /* FocusTrackingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D42CD456B4B3C988B148A6 /* FocusTrackingModel.swift */; }; A36481222BB5B2A67349D389 /* ApplicationBundleMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A168A7B6A7AD11559B60C56B /* ApplicationBundleMetadataTests.swift */; }; - A440C596EFD9CD1E44F2579B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E0F6AFF229D20B73CF14C6C /* SettingsView.swift */; }; A5A6CE0EF01CA6A9AFA7A400 /* RequestID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC693E00430F46E41CB56E6 /* RequestID.swift */; }; A87978083EBE1AC294377F7C /* HuggingFaceSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F426127917FCB1096134732 /* HuggingFaceSearchService.swift */; }; A93A741C8C3973687D28F0B6 /* SuggestionEngineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBE3E6CC585C1683787C877 /* SuggestionEngineModels.swift */; }; @@ -217,7 +216,6 @@ 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInserter.swift; sourceTree = ""; }; 0C383AE85B971A9605787358 /* FocusModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusModels.swift; sourceTree = ""; }; 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptContextSanitizerTests.swift; sourceTree = ""; }; - 0E0F6AFF229D20B73CF14C6C /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 0F5E263AB69029D5E13D5EE8 /* FocusDebugOverlayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusDebugOverlayController.swift; sourceTree = ""; }; 110CB0B53016644EF7840301 /* HuggingFaceAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceAPIClient.swift; sourceTree = ""; }; 12DD19BCE610808F1E38702D /* PermissionOverlayTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayTrackerTests.swift; sourceTree = ""; }; @@ -644,7 +642,6 @@ 9AA0117B322C625F6D4BBEAB /* MenuBarView.swift */, 656F58E56FE9BC087B6F1D33 /* PermissionReminderView.swift */, 5484C8A04B9C00CF79D589EB /* ScreenFrameReader.swift */, - 0E0F6AFF229D20B73CF14C6C /* SettingsView.swift */, FB317C82CE2CBC69056BA4B8 /* TagChip.swift */, 58C0F017699EE44C81C095CA /* TagsInputView.swift */, 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */, @@ -961,7 +958,6 @@ 644EEF959D07D54CC779BBF6 /* SettingsCoordinator.swift in Sources */, 4B93D26BACEEA932E92B1A19 /* SettingsPaneScaffold.swift in Sources */, 27D4F5CACADE171F142178B4 /* SettingsSidebarView.swift in Sources */, - A440C596EFD9CD1E44F2579B /* SettingsView.swift in Sources */, 12995E5DDB11E3395E6AF82F /* ShortcutsPaneView.swift in Sources */, 4F369F5284DDCEABF082E59B /* SuggestionAvailabilityEvaluator.swift in Sources */, A0657CE0488F69F0BD559CBC /* SuggestionCoordinator+Acceptance.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SettingsCoordinator.swift b/Cotabby/App/Coordinators/SettingsCoordinator.swift index 31ccfbc7..8d3492d0 100644 --- a/Cotabby/App/Coordinators/SettingsCoordinator.swift +++ b/Cotabby/App/Coordinators/SettingsCoordinator.swift @@ -45,24 +45,6 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { self.onShowWelcome = onShowWelcome } - /// UserDefaults key that toggles between the legacy single-form Settings and the redesigned - /// sidebar Settings. Defaults to `true` now that the redesigned panes cover every legacy - /// control; the legacy view is retained as a one-`defaults write` rollback for one release - /// cycle before it is deleted. The key is intentionally narrow so a regression can be - /// reverted with `defaults write com.jacobfu.tabby cotabbySettingsRedesignEnabled -bool NO` - /// without redeploying the app. - static let redesignEnabledDefaultsKey = "cotabbySettingsRedesignEnabled" - - private var isRedesignEnabled: Bool { - // `bool(forKey:)` cannot distinguish "user explicitly set false" from "key absent". Read - // the raw object so the absent case can default to true without overwriting an explicit - // opt-out from someone who set the key to false. - guard let stored = UserDefaults.standard.object(forKey: Self.redesignEnabledDefaultsKey) as? Bool else { - return true - } - return stored - } - /// Shows the settings window, reusing the existing instance if it is already open. /// Reusing one window avoids subtle state duplication and matches standard macOS settings /// behavior where there is a single shared preferences surface for the app. @@ -73,56 +55,30 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { return } - let hostingController: NSHostingController - let initialFrame: CGRect - let minSize: NSSize - let autosaveName: String - - if isRedesignEnabled { - hostingController = NSHostingController( - rootView: AnyView( - SettingsContainerView( - appUpdateManager: appUpdateManager, - launchAtLoginService: launchAtLoginService, - permissionManager: permissionManager, - suggestionSettings: suggestionSettings, - foundationModelAvailabilityService: foundationModelAvailabilityService, - runtimeModel: runtimeModel, - modelDownloadManager: modelDownloadManager, - huggingFaceSearchService: huggingFaceSearchService, - onShowWelcome: onShowWelcome - ) + let hostingController = NSHostingController( + rootView: AnyView( + SettingsContainerView( + appUpdateManager: appUpdateManager, + launchAtLoginService: launchAtLoginService, + permissionManager: permissionManager, + suggestionSettings: suggestionSettings, + foundationModelAvailabilityService: foundationModelAvailabilityService, + runtimeModel: runtimeModel, + modelDownloadManager: modelDownloadManager, + huggingFaceSearchService: huggingFaceSearchService, + onShowWelcome: onShowWelcome ) ) - // Sized to fit the actual content: a fixed 260pt sidebar (see `SettingsSidebarView`) - // plus a ~600pt detail column for the grouped form. The previous 1320x820 default with - // a 1180 minimum was far wider than any pane's content, which is exactly what left the - // detail area looking stretched and the window feeling empty. - initialFrame = CGRect(x: 0, y: 0, width: 860, height: 700) - minSize = NSSize(width: 820, height: 560) - // Bump the autosave name so anyone holding a saved 1320-wide V3 frame gets the new - // right-sized default once, instead of restoring the oversized window. - autosaveName = "CotabbySettingsWindowV4" - } else { - hostingController = NSHostingController( - rootView: AnyView( - SettingsView( - appUpdateManager: appUpdateManager, - launchAtLoginService: launchAtLoginService, - permissionManager: permissionManager, - suggestionSettings: suggestionSettings, - foundationModelAvailabilityService: foundationModelAvailabilityService, - runtimeModel: runtimeModel, - modelDownloadManager: modelDownloadManager, - huggingFaceSearchService: huggingFaceSearchService, - onShowWelcome: onShowWelcome - ) - ) - ) - initialFrame = CGRect(x: 0, y: 0, width: 700, height: 620) - minSize = NSSize(width: 640, height: 520) - autosaveName = "CotabbySettingsWindow" - } + ) + // Sized to fit the actual content: a fixed 260pt sidebar (see `SettingsSidebarView`) + // plus a ~600pt detail column for the grouped form. The previous 1320x820 default with + // a 1180 minimum was far wider than any pane's content, which is exactly what left the + // detail area looking stretched and the window feeling empty. + let initialFrame = CGRect(x: 0, y: 0, width: 860, height: 700) + let minSize = NSSize(width: 820, height: 560) + // Bump the autosave name so anyone holding a saved 1320-wide V3 frame gets the new + // right-sized default once, instead of restoring the oversized window. + let autosaveName = "CotabbySettingsWindowV4" let window = NSWindow( contentRect: initialFrame, diff --git a/Cotabby/UI/Settings/Components/AcceptanceModePickerView.swift b/Cotabby/UI/Settings/Components/AcceptanceModePickerView.swift index d68ac761..7a5867ef 100644 --- a/Cotabby/UI/Settings/Components/AcceptanceModePickerView.swift +++ b/Cotabby/UI/Settings/Components/AcceptanceModePickerView.swift @@ -1,10 +1,7 @@ import SwiftUI /// File overview: -/// Shared "Acceptance Mode" picker for the primary accept key. Used by both the legacy -/// `SettingsView.shortcutsSection` and the redesigned `ShortcutsPaneView`, so the granularity -/// labels and cases live in one place — adding or renaming a mode can't drift between the two -/// settings shells. +/// Shared "Acceptance Mode" picker for the primary accept key, used by `ShortcutsPaneView`. struct AcceptanceModePickerView: View { @ObservedObject var suggestionSettings: SuggestionSettingsModel diff --git a/Cotabby/UI/Settings/Panes/AppsPaneView.swift b/Cotabby/UI/Settings/Panes/AppsPaneView.swift index 7e7a839a..9090bf1e 100644 --- a/Cotabby/UI/Settings/Panes/AppsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppsPaneView.swift @@ -6,8 +6,7 @@ import UniformTypeIdentifiers /// "Apps" detail pane of the redesigned Settings window. Lists every app where Cotabby is /// disabled, lets the user remove individual rules, and offers a file-picker entry point for apps /// that can't be reached from the menu-bar toggle (launchers like Raycast or Spotlight that -/// dismiss themselves the moment the menu bar is clicked). Lifted from the legacy -/// `SettingsView.appsSection` so behavior is preserved. +/// dismiss themselves the moment the menu bar is clicked). struct AppsPaneView: View { @ObservedObject var suggestionSettings: SuggestionSettingsModel diff --git a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift index 3f5f1c80..2c17ded5 100644 --- a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift @@ -2,9 +2,7 @@ import SwiftUI /// File overview: /// "Shortcuts" detail pane of the redesigned Settings window. Surfaces the two keybindings that -/// drive suggestion acceptance: word-by-word and full-suggestion. Lifted from the legacy -/// `SettingsView.shortcutsSection` so binding capture, conflict resolution, and reset / clear -/// semantics are preserved exactly. +/// drive suggestion acceptance: word-by-word and full-suggestion. struct ShortcutsPaneView: View { @ObservedObject var suggestionSettings: SuggestionSettingsModel diff --git a/Cotabby/UI/Settings/Panes/WritingPaneView.swift b/Cotabby/UI/Settings/Panes/WritingPaneView.swift index 1465740d..0b5b82c9 100644 --- a/Cotabby/UI/Settings/Panes/WritingPaneView.swift +++ b/Cotabby/UI/Settings/Panes/WritingPaneView.swift @@ -3,8 +3,7 @@ import SwiftUI /// File overview: /// "Writing" detail pane of the redesigned Settings window. Owns how the completion reads: /// preferred length, profile (display name), preferred response languages, and the user's custom -/// style rules. Lifted from the legacy `SettingsView.writingSection` so the controls inside the -/// pane behave identically; only the wrapping form scaffold is new. +/// style rules. struct WritingPaneView: View { @ObservedObject var suggestionSettings: SuggestionSettingsModel diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index 0192bbbd..155b6321 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -10,9 +10,6 @@ import SwiftUI /// Selection is persisted via `@AppStorage` so reopening Settings lands on the last-used pane. /// `.id(selection)` on the detail body is the documented workaround for the macOS 14 split-view /// selection bug where the first sidebar pick doesn't always re-render the detail column. -/// -/// The view takes the same observable graph as the legacy `SettingsView` so the coordinator can -/// swap hosting controllers behind a feature flag without rewiring dependencies. struct SettingsContainerView: View { let appUpdateManager: AppUpdateManager diff --git a/Cotabby/UI/SettingsView.swift b/Cotabby/UI/SettingsView.swift deleted file mode 100644 index c7651e1c..00000000 --- a/Cotabby/UI/SettingsView.swift +++ /dev/null @@ -1,955 +0,0 @@ -import AppKit -import SwiftUI -import UniformTypeIdentifiers - -/// File overview: -/// Renders Cotabby's settings window as the canonical home for durable user preferences. -/// -/// Why this file exists: -/// the menu bar panel is a quick-control surface, but durable settings should also exist in a -/// stable window the user can revisit later. This view intentionally does not own persistence or -/// side effects; it reads and mutates long-lived services created by the app environment. -struct SettingsView: View { - let appUpdateManager: AppUpdateManager - - @ObservedObject var launchAtLoginService: LaunchAtLoginService - @ObservedObject var permissionManager: PermissionManager - @ObservedObject var suggestionSettings: SuggestionSettingsModel - @ObservedObject var foundationModelAvailabilityService: FoundationModelAvailabilityService - @ObservedObject var runtimeModel: RuntimeBootstrapModel - @ObservedObject var modelDownloadManager: ModelDownloadManager - @ObservedObject var huggingFaceSearchService: HuggingFaceSearchService - - let onShowWelcome: () -> Void - - @Environment(\.colorScheme) private var colorScheme - - @State private var pendingDeletionModel: RuntimeModelOption? - @State private var isRecordingKeybind = false - @State private var isRecordingFullAcceptKeybind = false - var body: some View { - Form { - // Header keeps the app identity and the (important, frequently-checked) update - // control pinned at the very top; Support stays directly beneath it. - settingsHeader - supportSection - // Surfaces broken state (missing permission / unavailable engine) high up so the - // user doesn't have to scroll to discover why autocomplete isn't working. - attentionBanner - generalSection - modelEngineSection - writingSection - shortcutsSection - // performanceSection — hidden until these controls are productized. - // Both suggestion delay and focus poll interval are developer-facing - // tuning knobs that invite misconfiguration for end users. - appsSection - permissionsSection - uninstallSection - } - .formStyle(.grouped) - .frame(minWidth: 620, minHeight: 560) - .onAppear { - launchAtLoginService.refresh() - permissionManager.refresh() - } - .onChange(of: suggestionSettings.selectedEngine) { _, _ in - pendingDeletionModel = nil - } - .alert( - "Delete Model?", - isPresented: pendingDeletionAlertBinding, - presenting: pendingDeletionModel - ) { model in - Button("Delete") { - deleteModel(model) - } - - Button("Cancel", role: .cancel) {} - } message: { model in - Text("Remove \(model.displayName) from Cotabby's local models folder?") - } - } - - @ViewBuilder - private var settingsHeader: some View { - Section { - HStack(spacing: 10) { - Image("CotabbyLogo") - .resizable() - .scaledToFit() - .frame(width: 32, height: 32) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - - VStack(alignment: .leading, spacing: 1) { - Text("Cotabby") - .font(.system(size: 15, weight: .semibold, design: .rounded)) - - Text("Local macOS AI Autocomplete") - .font(.system(size: 11, design: .rounded)) - .foregroundStyle(.secondary) - } - - Spacer(minLength: 12) - - // Updates are important enough to stay visible without scrolling, but compact - // enough to ride along in the title bar instead of owning a whole section. - VStack(alignment: .trailing, spacing: 4) { - Text(appVersionText) - .font(.system(size: 11, design: .rounded)) - .foregroundStyle(.secondary) - - Button("Check for Updates") { - appUpdateManager.checkForUpdates() - } - .controlSize(.small) - } - } - } - } - - /// Conditional banner that only renders when something is preventing autocomplete from - /// working. Detailed remediation still lives in the Permissions / Model sections below; - /// this just makes the problem impossible to miss. - @ViewBuilder - private var attentionBanner: some View { - if !permissionManager.requiredPermissionsGranted { - attentionRow("Cotabby needs more access to run. See Permissions below to grant it.") - } else if suggestionSettings.selectedEngine == .appleIntelligence, - !foundationModelAvailabilityService.isAvailable { - attentionRow(foundationModelAvailabilityService.userVisibleMessage) - } else if suggestionSettings.selectedEngine == .llamaOpenSource, - case .failed(let detail) = runtimeModel.state { - attentionRow("\(detail) See Model & Engine below.") - } - } - - @ViewBuilder - private func attentionRow(_ message: String) -> some View { - Section { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - - Text(message) - .font(.callout) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - /// Everyday on/off behavior the user reaches for most often, plus the onboarding re-entry. - @ViewBuilder - private var generalSection: some View { - Section("General") { - Toggle("Enable Globally", isOn: globallyEnabledBinding) - - Toggle("Show Indicator", isOn: showIndicatorBinding) - - Toggle("Show Word Count in Menu Bar", isOn: menuBarWordCountVisibleBinding) - - Toggle(isOn: showAcceptanceHintBinding) { - HStack(spacing: 4) { - Text("Show") - Text(suggestionSettings.acceptanceKeyLabel) - .font(.system(.body, design: .rounded).weight(.semibold)) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(.quaternary) - ) - Text("Key Hint") - } - } - - Picker("Suggestion Display", selection: mirrorPreferenceBinding) { - ForEach(MirrorPreference.allCases) { preference in - Text(preference.displayLabel).tag(preference) - } - } - .pickerStyle(.menu) - - Toggle("Allow Multi-line Suggestions", isOn: multiLineEnabledBinding) - - Toggle("Accept Punctuation With Word", isOn: autoAcceptTrailingPunctuationBinding) - - Toggle("Include Clipboard Context", isOn: clipboardContextEnabledBinding) - - Toggle("Fast Mode", isOn: fastModeEnabledBinding) - - LabeledContent("Ghost Text Color") { - HStack(spacing: 8) { - ForEach(GhostTextColorPreset.all) { preset in - ghostColorSwatch(for: preset) - } - } - } - - LabeledContent("Ghost Text Opacity") { - HStack(spacing: 10) { - TickMarkSlider( - value: ghostTextOpacityBinding, - range: SuggestionSettingsModel.minimumGhostTextOpacity - ... SuggestionSettingsModel.maximumGhostTextOpacity, - step: SuggestionSettingsModel.ghostTextOpacityStep - ) - .frame(width: 180) - - Text(ghostTextOpacityLabel) - .font(.callout) - .monospacedDigit() - .foregroundStyle(.secondary) - .frame(width: 42, alignment: .trailing) - } - } - - // Open at Login is hidden until the quarantine/SMAppService issue is resolved. - // The toggle reports .notFound for quarantined apps and apps outside /Applications, - // making it appear broken for most users. See LaunchAtLoginService for details. - - LabeledContent("Onboarding") { - Button("Open Welcome Guide") { - onShowWelcome() - } - } - } - } - - /// Which brain runs (engine + availability) and the local model files it uses. Merged so the - /// engine choice and the models that back it are no longer separated by unrelated sections. - @ViewBuilder - private var modelEngineSection: some View { - Section("Model & Engine") { - Picker("Engine", selection: selectedEngineBinding) { - ForEach(SuggestionEngineKind.allCases) { engine in - Text(engine.displayLabel) - .tag(engine) - } - } - - switch suggestionSettings.selectedEngine { - case .appleIntelligence: - LabeledContent("Availability") { - Text(foundationModelAvailabilityService.userVisibleMessage) - .foregroundStyle(.secondary) - } - case .llamaOpenSource: - LabeledContent("Runtime") { - Text(runtimeModel.state.summary) - .foregroundStyle(.secondary) - } - } - - if suggestionSettings.selectedEngine.supportsLocalModelManagement { - localModelControls - } - } - } - - /// How the completion reads: length, language, custom directives, and who the user is. - @ViewBuilder - private var writingSection: some View { - Section("Writing") { - Picker("Length", selection: selectedWordCountPresetBinding) { - ForEach(SuggestionWordCountPreset.allCases) { preset in - Text(preset.displayLabel) - .tag(preset) - } - } - - VStack(alignment: .leading, spacing: 24) { - Text("This information is passed to the AI to help personalize your completions.") - .font(.caption) - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: 8) { - Text("Name") - .font(.system(size: 13, weight: .medium)) - - TextField("What should Cotabby call you?", text: Binding( - get: { suggestionSettings.userName }, - set: { suggestionSettings.setUserName($0) } - )) - .textFieldStyle(.roundedBorder) - } - - LanguageTagsEditor(suggestionSettings: suggestionSettings) - - CustomRulesEditor(suggestionSettings: suggestionSettings) - } - .padding(.vertical, 10) - } - } - - @ViewBuilder - private var shortcutsSection: some View { - Section("Shortcuts") { - AcceptanceModePickerView(suggestionSettings: suggestionSettings) - - LabeledContent("Accept Word") { - HStack(spacing: 8) { - Text(suggestionSettings.acceptanceKeyLabel) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(.quaternary) - ) - - if isRecordingKeybind { - KeyRecorderView( - onKeyRecorded: { keyCode, modifiers, label in - suggestionSettings.setAcceptanceKey( - keyCode: keyCode, - modifiers: modifiers, - label: label - ) - isRecordingKeybind = false - }, - onCancelled: { - isRecordingKeybind = false - } - ) - } else { - Button("Change") { - isRecordingKeybind = true - } - } - - if suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.defaultAcceptanceKeyCode - || !suggestionSettings.acceptanceKeyModifiers.isEmpty { - Button("Reset") { - suggestionSettings.setAcceptanceKey( - keyCode: SuggestionSettingsModel.defaultAcceptanceKeyCode, - modifiers: [], - label: SuggestionSettingsModel.defaultAcceptanceKeyLabel - ) - isRecordingKeybind = false - } - } - - if suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode { - Button("Clear") { - suggestionSettings.clearAcceptanceKey() - isRecordingKeybind = false - } - } - } - } - - LabeledContent("Accept Entire Suggestion") { - HStack(spacing: 8) { - Text(suggestionSettings.fullAcceptanceKeyLabel) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(.quaternary) - ) - - if isRecordingFullAcceptKeybind { - KeyRecorderView( - onKeyRecorded: { keyCode, modifiers, label in - suggestionSettings.setFullAcceptanceKey( - keyCode: keyCode, - modifiers: modifiers, - label: label - ) - isRecordingFullAcceptKeybind = false - }, - onCancelled: { - isRecordingFullAcceptKeybind = false - } - ) - } else { - Button("Change") { - isRecordingFullAcceptKeybind = true - } - } - - if suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.defaultFullAcceptanceKeyCode - || !suggestionSettings.fullAcceptanceKeyModifiers.isEmpty { - Button("Reset") { - suggestionSettings.setFullAcceptanceKey( - keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, - modifiers: [], - label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel - ) - isRecordingFullAcceptKeybind = false - } - } - - if suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode { - Button("Clear") { - suggestionSettings.clearFullAcceptanceKey() - isRecordingFullAcceptKeybind = false - } - } - } - } - } - } - - @ViewBuilder - private var performanceSection: some View { - Section("Performance") { - Stepper( - "Suggestion Delay: \(suggestionSettings.debounceMilliseconds)ms", - value: debounceMillisecondsBinding, - in: 10...500, - step: 10 - ) - - // Focus poll interval is intentionally hidden from the UI. The default (50 ms) - // is tuned for the best balance of responsiveness and CPU usage. Exposing it - // invites misconfiguration without meaningful benefit. The backing setting and - // UserDefaults plumbing remain so we can re-surface it later if needed. - } - } - - @ViewBuilder - private var appsSection: some View { - Section("Apps") { - Text("Cotabby won't autocomplete in these apps. Add an app you can't disable from the " - + "menu bar — like a launcher that closes the moment it loses focus.") - .font(.caption) - .foregroundStyle(.secondary) - - ForEach(suggestionSettings.disabledAppRules) { rule in - disabledAppRuleRow(rule) - } - - Button("Add App…") { - presentDisabledAppPicker() - } - } - } - - @ViewBuilder - private var permissionsSection: some View { - Section("Permissions") { - Text("Cotabby needs Accessibility, Input Monitoring, and Screen Recording for autocomplete.") - .font(.caption) - .foregroundStyle(.secondary) - - settingsPermissionRow( - title: "Accessibility", - granted: permissionManager.accessibilityGranted, - action: permissionManager.openAccessibilitySettings - ) - - settingsPermissionRow( - title: "Input Monitoring", - granted: permissionManager.inputMonitoringGranted, - action: permissionManager.openInputMonitoringSettings - ) - - settingsPermissionRow( - title: "Screen Recording", - granted: permissionManager.screenRecordingGranted, - action: permissionManager.openScreenRecordingSettings - ) - } - } - - /// Local model rows nested inside the Model & Engine section (no `Section` wrapper of its - /// own). Only shown for engines that manage local GGUF files. - @ViewBuilder - private var localModelControls: some View { - Group { - Text(localModelsDescription) - .font(.caption) - .foregroundStyle(.secondary) - - if runtimeModel.availableModels.isEmpty { - Text("No local GGUF models found. Download one below or add your own model file.") - .foregroundStyle(.secondary) - } else { - Picker("Selected Model", selection: selectedModelBinding) { - ForEach(runtimeModel.availableModels) { model in - Text(model.displayName) - .tag(model.filename) - } - } - } - - DownloadableModelCatalogView( - modelDownloadManager: modelDownloadManager, - onRefreshModels: refreshModels - ) - - HuggingFaceModelBrowserView( - searchService: huggingFaceSearchService, - modelDownloadManager: modelDownloadManager, - onRefreshModels: refreshModels - ) - - LabeledContent("Folder") { - VStack(alignment: .trailing, spacing: 8) { - Text(modelDownloadManager.modelsDirectoryPath) - .font(.callout.monospaced()) - .textSelection(.enabled) - .multilineTextAlignment(.trailing) - - HStack(spacing: 8) { - let lmStudioURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".lmstudio/models") - let lmStudioAvailable = FileManager.default.fileExists(atPath: lmStudioURL.path) - let isUsingCustomPath = BundledRuntimeLocator.customModelDirectoryURL() != nil - Button("Use LM Studio") { - BundledRuntimeLocator.setCustomModelDirectory(lmStudioURL) - modelDownloadManager.refreshSearchDirectories() - refreshModels() - } - .disabled(!lmStudioAvailable) - - Button("Reset Path") { - BundledRuntimeLocator.setCustomModelDirectory(nil) - modelDownloadManager.refreshSearchDirectories() - refreshModels() - } - .disabled(!isUsingCustomPath) - - Button("Open Folder") { - modelDownloadManager.openModelsDirectory() - } - - Button("Refresh") { - refreshModels() - } - } - } - } - - if !runtimeModel.availableModels.isEmpty { - Text("Installed") - .font(.caption) - .foregroundStyle(.secondary) - - ForEach(runtimeModel.availableModels) { model in - installedModelRow(model) - } - } - } - } - - @ViewBuilder - private var supportSection: some View { - Section("Support") { - LabeledContent { - Link(destination: URL(string: "https://ko-fi.com/cotabby")!) { - Label("Support", systemImage: "heart.fill") - } - .buttonStyle(.borderedProminent) - .tint(.blue) - } label: { - VStack(alignment: .leading, spacing: 8) { - Text( - "Cotabby started from a simple belief: AI should run on your device, respect your privacy, " - + "and remain open to everyone. That's why Cotabby is open source, local-first, and built " - + "to give users full control over their own hardware and data." - ) - - Text( - "We're building Cotabby in our spare time, one release at a time. Every feature, bug fix, " - + "and support reply takes real time and care from two university students trying to keep " - + "this project alive. If Cotabby has been useful to you, please consider supporting our " - + "mission." - ) - } - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - @ViewBuilder - private var uninstallSection: some View { - Section("Uninstall") { - Text( - "Drag Cotabby.app from Applications to the Trash. " - + "To remove leftover data, also delete ~/Library/Application Support/Cotabby. " - + "Privacy permissions can only be revoked in System Settings → Privacy & Security." - ) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder - private func installedModelRow(_ model: RuntimeModelOption) -> some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(model.displayName) - - if model.displayName != model.actualModelName { - Text(model.actualModelName) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer(minLength: 0) - - if model.filename == runtimeModel.selectedModelFilename { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.tint) - } else if modelDownloadManager.canDeleteModel(filename: model.filename) { - Button { - pendingDeletionModel = model - } label: { - Image(systemName: "trash") - .foregroundStyle(.secondary) - } - .buttonStyle(.borderless) - } - } - } - - @ViewBuilder - private func disabledAppRuleRow(_ rule: DisabledApplicationRule) -> some View { - HStack(spacing: 12) { - Image(nsImage: icon(for: rule)) - .resizable() - .frame(width: 28, height: 28) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 2) { - Text(rule.displayName) - - Text(rule.bundleIdentifier) - .font(.caption) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - - Spacer(minLength: 0) - - Button { - suggestionSettings.removeDisabledApplication( - bundleIdentifier: rule.bundleIdentifier - ) - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.borderless) - } - } - - private func icon(for rule: DisabledApplicationRule) -> NSImage { - // Bundle IDs are durable; app paths are not. Resolve the current app URL at render time so - // Settings naturally picks up app updates, moves, or reinstalls without persisting UI cache. - guard let appURL = NSWorkspace.shared.urlForApplication( - withBundleIdentifier: rule.bundleIdentifier - ) else { - return NSWorkspace.shared.icon(for: .applicationBundle) - } - - return NSWorkspace.shared.icon(forFile: appURL.path) - } - - @ViewBuilder - private func settingsPermissionRow( - title: String, - granted: Bool, - action: @escaping () -> Void - ) -> some View { - HStack(spacing: 10) { - Text(title) - - Spacer(minLength: 0) - - Text(granted ? "Granted" : "Needs Access") - .font(.caption.weight(.medium)) - .foregroundStyle(granted ? .green : .orange) - - if !granted { - Button("Open Settings") { - action() - } - .controlSize(.small) - } - } - } - - private var launchAtLoginBinding: Binding { - Binding( - get: { launchAtLoginService.state.isEnabled }, - set: { enabled in - launchAtLoginService.setEnabled(enabled) - } - ) - } - - private var globallyEnabledBinding: Binding { - Binding( - get: { suggestionSettings.isGloballyEnabled }, - set: { suggestionSettings.setGloballyEnabled($0) } - ) - } - - private var clipboardContextEnabledBinding: Binding { - Binding( - get: { suggestionSettings.isClipboardContextEnabled }, - set: { suggestionSettings.setClipboardContextEnabled($0) } - ) - } - - private var fastModeEnabledBinding: Binding { - Binding( - get: { suggestionSettings.isFastModeEnabled }, - set: { suggestionSettings.setFastModeEnabled($0) } - ) - } - - private var menuBarWordCountVisibleBinding: Binding { - Binding( - get: { suggestionSettings.isMenuBarWordCountVisible }, - set: { suggestionSettings.setMenuBarWordCountVisible($0) } - ) - } - - private var mirrorPreferenceBinding: Binding { - Binding( - get: { suggestionSettings.mirrorPreference }, - set: { suggestionSettings.setMirrorPreference($0) } - ) - } - - private var showIndicatorBinding: Binding { - Binding( - get: { suggestionSettings.showIndicator }, - set: { suggestionSettings.setShowIndicator($0) } - ) - } - - private var showAcceptanceHintBinding: Binding { - Binding( - get: { suggestionSettings.showAcceptanceHint }, - set: { suggestionSettings.setShowAcceptanceHint($0) } - ) - } - - private var multiLineEnabledBinding: Binding { - Binding( - get: { suggestionSettings.isMultiLineEnabled }, - set: { suggestionSettings.setMultiLineEnabled($0) } - ) - } - - private var autoAcceptTrailingPunctuationBinding: Binding { - Binding( - get: { suggestionSettings.autoAcceptTrailingPunctuation }, - set: { suggestionSettings.setAutoAcceptTrailingPunctuation($0) } - ) - } - - private var debounceMillisecondsBinding: Binding { - Binding( - get: { suggestionSettings.debounceMilliseconds }, - set: { suggestionSettings.setDebounceMilliseconds($0) } - ) - } - - // focusPollIntervalMillisecondsBinding removed — the poll interval stepper is hidden from - // the UI (see performanceSection). Binding kept commented for easy restoration: - // - // private var focusPollIntervalMillisecondsBinding: Binding { - // Binding( - // get: { suggestionSettings.focusPollIntervalMilliseconds }, - // set: { suggestionSettings.setFocusPollIntervalMilliseconds($0) } - // ) - // } - - private var ghostTextOpacityBinding: Binding { - Binding( - get: { suggestionSettings.ghostTextOpacity }, - set: { suggestionSettings.setGhostTextOpacity($0) } - ) - } - - private var selectedEngineBinding: Binding { - Binding( - get: { suggestionSettings.selectedEngine }, - set: { engine in - suggestionSettings.selectEngine(engine) - } - ) - } - - private var selectedWordCountPresetBinding: Binding { - Binding( - get: { suggestionSettings.selectedWordCountPreset }, - set: { preset in - suggestionSettings.selectWordCountPreset(preset) - } - ) - } - - private var selectedModelBinding: Binding { - Binding( - get: { - runtimeModel.selectedModelFilename - ?? runtimeModel.availableModels.first?.filename - ?? "" - }, - set: { filename in - Task { - await runtimeModel.selectModel(filename) - } - } - ) - } - - /// Mirrors the overlay's automatic fallback (`GhostSuggestionView.ghostColor`) so the Automatic - /// swatch previews the same gray the user will actually see. - private var automaticGhostTextColor: Color { - colorScheme == .dark - ? Color(red: 0.65, green: 0.65, blue: 0.65) - : Color(red: 0.45, green: 0.45, blue: 0.45) - } - - private var ghostTextOpacityLabel: String { - "\(Int((suggestionSettings.ghostTextOpacity * 100).rounded()))%" - } - - /// A tappable color chip. `nil` hex selects the adaptive Automatic gray. The active preset gets a - /// heavier ring so the current choice reads at a glance. - @ViewBuilder - private func ghostColorSwatch(for preset: GhostTextColorPreset) -> some View { - let isSelected = GhostTextColorPreset.matching( - hex: suggestionSettings.customSuggestionTextColorHex - ) == preset - - Button { - suggestionSettings.setCustomSuggestionTextColorHex(preset.hex) - } label: { - Circle() - .fill(swatchFill(for: preset)) - .frame(width: 18, height: 18) - .overlay( - Circle() - .strokeBorder( - Color.primary.opacity(isSelected ? 0.9 : 0.18), - lineWidth: isSelected ? 2 : 1 - ) - ) - } - .buttonStyle(.plain) - } - - private func swatchFill(for preset: GhostTextColorPreset) -> Color { - guard let hex = preset.hex, - let color = SuggestionTextColorCodec.color(fromHex: hex) - else { - return automaticGhostTextColor - } - - return color - } - - private var localModelsDescription: String { - switch suggestionSettings.selectedEngine { - case .llamaOpenSource: - return "Download a model or add your own below. Models are stored locally on your Mac." - case .appleIntelligence: - return "These models are used when Engine is set to Open Source." - } - } - - private var launchAtLoginMessage: String? { - if let lastErrorMessage = launchAtLoginService.lastErrorMessage { - return lastErrorMessage - } - - return launchAtLoginService.state.detail - } - - private var launchAtLoginMessageColor: Color { - if launchAtLoginService.lastErrorMessage != nil { - return .red - } - - if case .requiresApproval = launchAtLoginService.state { - return .orange - } - - return .secondary - } - - /// The app bundle is the canonical source for human-facing version text. - private var appVersionText: String { - let shortVersion = - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String - - switch (shortVersion, buildNumber) { - case (let shortVersion?, let buildNumber?) where shortVersion != buildNumber: - return "\(shortVersion) (\(buildNumber))" - case (let shortVersion?, _): - return shortVersion - case (_, let buildNumber?): - return buildNumber - default: - return "Unknown" - } - } - - /// SwiftUI's alert API wants a Boolean binding, while the view naturally tracks the model the - /// user intends to delete. This adapter keeps the real source of truth expressive and still - /// allows the standard confirmation alert API to drive presentation. - private var pendingDeletionAlertBinding: Binding { - Binding( - get: { pendingDeletionModel != nil }, - set: { isPresented in - if !isPresented { - pendingDeletionModel = nil - } - } - ) - } - - private func deleteModel(_ model: RuntimeModelOption) { - modelDownloadManager.deleteModel(filename: model.filename) - runtimeModel.refreshAvailableModels() - pendingDeletionModel = nil - } - - private func refreshModels() { - modelDownloadManager.refreshModelStates() - runtimeModel.refreshAvailableModels() - } - - /// Lets the user disable Cotabby in an app they can't reach from the menu bar. The menu-bar - /// "Enable in " switch only targets the frontmost app, so a launcher like Raycast or - /// Spotlight — which dismisses itself the instant the menu bar is clicked — can never be turned - /// off that way. An open panel names any installed app whether or not it is running. - private func presentDisabledAppPicker() { - let panel = NSOpenPanel() - panel.allowedContentTypes = [.application] - panel.allowsMultipleSelection = true - panel.canChooseDirectories = false - panel.canChooseFiles = true - panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) - panel.prompt = "Disable" - panel.message = "Choose apps where Cotabby should not autocomplete." - - guard panel.runModal() == .OK else { - return - } - - for url in panel.urls { - guard let metadata = ApplicationBundleMetadata(appURL: url) else { - continue - } - - suggestionSettings.disableApplication( - bundleIdentifier: metadata.bundleIdentifier, - displayName: metadata.displayName - ) - } - } - -}