diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index e6b8da2..7d7570a 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -959,7 +959,7 @@ struct ContentView: View { .listRowBackground(self.sidebarRowBackground(for: .voiceEngine)) NavigationLink(value: SidebarItem.aiEnhancements) { - Label("AI Enhancements", systemImage: "brain") + Label("AI Enhancement", systemImage: "brain") .font(.system(size: 15, weight: .medium)) .padding(.leading, 18) } @@ -1578,7 +1578,7 @@ struct ContentView: View { return self.buildSystemPrompt(appInfo: appInfo, dictationSlot: dictationSlot) }() - // Dictation cleanup folds the prompt + transcript into a single user + // Dictation enhancement folds the prompt + transcript into a single user // turn (substituting `${transcript}` when present, otherwise appending // the transcript after a blank line). Non-dictation callers — the AI // chat tab specifically — keep the legacy two-message layout where @@ -1631,7 +1631,7 @@ struct ContentView: View { } self.logDictationPromptTrace("Selected context text", value: "") } - DebugLogger.shared.debug("Using Apple Intelligence for transcription cleanup", source: "ContentView") + DebugLogger.shared.debug("Using Apple Intelligence for transcription enhancement", source: "ContentView") let output = try await provider.process(systemPrompt: systemPrompt, userText: userMessageContent) if self.shouldTracePromptProcessing { self.logDictationPromptTrace("Model answer (A)", value: output) @@ -1710,7 +1710,7 @@ struct ContentView: View { ) } - // Build messages array. For dictation cleanup the whole prompt + + // Build messages array. For dictation enhancement the whole prompt + // transcript is folded into a single user message, so we omit the // (empty) system role. Non-dictation callers keep the legacy // system + user shape. @@ -1722,7 +1722,7 @@ struct ContentView: View { // NOTE: Transcription doesn't need streaming - the full result appears at once // Streaming is only useful for Command/Rewrite modes where real-time display helps - // Using non-streaming is simpler and more reliable for transcription cleanup + // Using non-streaming is simpler and more reliable for transcription enhancement let enableStreaming = false // Hardcoded off for transcription // Build LLMClient configuration @@ -1881,9 +1881,11 @@ struct ContentView: View { var finalText: String var aiFallbackReason: String? + let appInfo = self.recordingAppInfo ?? self.getCurrentAppInfo() - let shouldUseAI = activeDictationSlot.map { DictationAIPostProcessingGate.isConfigured(for: $0) } ?? - DictationAIPostProcessingGate.isConfigured() + let shouldUseAI = activeDictationSlot.map { + DictationAIPostProcessingGate.isConfigured(for: $0, appBundleID: appInfo.bundleId) + } ?? DictationAIPostProcessingGate.isConfigured(for: .primary, appBundleID: appInfo.bundleId) let transcriptionModelInfo = self.currentTranscriptionModelInfo() if shouldUseAI { @@ -1973,7 +1975,6 @@ struct ContentView: View { // Save to transcription history (transcription mode only, if enabled) if shouldPersistOutputs, SettingsStore.shared.saveTranscriptionHistory { - let appInfo = self.recordingAppInfo ?? self.getCurrentAppInfo() TranscriptionHistoryStore.shared.addEntry( rawText: transcribedText, processedText: finalText, @@ -2194,7 +2195,8 @@ struct ContentView: View { var finalText = transcribedText var aiFallbackReason: String? - let shouldUseAI = DictationAIPostProcessingGate.isConfigured() + let appInfo = self.getCurrentAppInfo() + let shouldUseAI = DictationAIPostProcessingGate.isConfigured(for: .primary, appBundleID: appInfo.bundleId) if shouldUseAI { do { finalText = try await self.processTextWithAI(transcribedText) @@ -2213,7 +2215,6 @@ struct ContentView: View { self.menuBarManager.setProcessing(false) finalText = ASRService.applyGAAVFormatting(finalText) - let appInfo = self.getCurrentAppInfo() if SettingsStore.shared.saveTranscriptionHistory { TranscriptionHistoryStore.shared.addEntry( diff --git a/Sources/Fluid/Networking/AppleIntelligenceProvider.swift b/Sources/Fluid/Networking/AppleIntelligenceProvider.swift index 59cebfe..978a654 100644 --- a/Sources/Fluid/Networking/AppleIntelligenceProvider.swift +++ b/Sources/Fluid/Networking/AppleIntelligenceProvider.swift @@ -43,7 +43,7 @@ enum AppleIntelligenceService { #if canImport(FoundationModels) @available(macOS 26.0, *) final class AppleIntelligenceProvider { - /// Process text with a system prompt (for transcription cleanup) + /// Process text with a system prompt (for transcription enhancement) func process(systemPrompt: String, userText: String) async throws -> String { let session = LanguageModelSession() diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index deab020..c8fd6e1 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -65,7 +65,9 @@ struct SettingsBackupPayload: Codable, Equatable { let customDictionaryEntries: [SettingsStore.CustomDictionaryEntry] let selectedDictationPromptID: String? let dictationPromptOff: Bool? + let dictationPromptRoutingScope: SettingsStore.PromptRoutingScope? let selectedEditPromptID: String? + let editPromptRoutingScope: SettingsStore.PromptRoutingScope? let defaultDictationPromptOverride: String? let defaultEditPromptOverride: String? } diff --git a/Sources/Fluid/Persistence/SettingsStore+PromptRouting.swift b/Sources/Fluid/Persistence/SettingsStore+PromptRouting.swift new file mode 100644 index 0000000..46fd8dc --- /dev/null +++ b/Sources/Fluid/Persistence/SettingsStore+PromptRouting.swift @@ -0,0 +1,64 @@ +import Combine +import Foundation + +extension SettingsStore { + enum PromptRoutingScope: String, Codable, CaseIterable, Identifiable { + case allApps + case selectedAppsOnly + + var id: String { self.rawValue } + } + + var dictationPromptRoutingScope: PromptRoutingScope { + get { + guard let rawValue = UserDefaults.standard.string(forKey: PromptRoutingKeys.dictation), + let scope = PromptRoutingScope(rawValue: rawValue) + else { + return .allApps + } + return scope + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue.rawValue, forKey: PromptRoutingKeys.dictation) + } + } + + var editPromptRoutingScope: PromptRoutingScope { + get { + guard let rawValue = UserDefaults.standard.string(forKey: PromptRoutingKeys.edit), + let scope = PromptRoutingScope(rawValue: rawValue) + else { + return .allApps + } + return scope + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue.rawValue, forKey: PromptRoutingKeys.edit) + } + } + + func promptRoutingScope(for mode: PromptMode) -> PromptRoutingScope { + switch mode.normalized { + case .dictate: + return self.dictationPromptRoutingScope + case .edit, .write, .rewrite: + return self.editPromptRoutingScope + } + } + + func setPromptRoutingScope(_ scope: PromptRoutingScope, for mode: PromptMode) { + switch mode.normalized { + case .dictate: + self.dictationPromptRoutingScope = scope + case .edit, .write, .rewrite: + self.editPromptRoutingScope = scope + } + } +} + +private enum PromptRoutingKeys { + static let dictation = "DictationPromptRoutingScope" + static let edit = "EditPromptRoutingScope" +} diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 3541038..429c585 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -235,7 +235,7 @@ final class SettingsStore: ObservableObject { let systemPrompt: String } - /// User-defined dictation prompt profiles (named system prompts for dictation cleanup). + /// User-defined dictation prompt profiles (named system prompts for dictation enhancement). /// The built-in default prompt is not stored here. var dictationPromptProfiles: [DictationPromptProfile] { get { @@ -886,6 +886,15 @@ final class SettingsStore: ObservableObject { ) } + if self.promptRoutingScope(for: normalizedMode) == .selectedAppsOnly { + return self.defaultPromptResolution( + for: normalizedMode, + source: .builtInDefault, + appBinding: nil, + allowDefaultOverride: false + ) + } + if let profile = self.selectedPromptProfile(for: normalizedMode) { let body = Self.stripBasePrompt(for: normalizedMode, from: profile.prompt) if !body.isEmpty { @@ -907,6 +916,11 @@ final class SettingsStore: ObservableObject { } func effectiveDictationPromptBody(for slot: DictationShortcutSlot, appBundleID: String? = nil) -> String { + if self.promptRoutingScope(for: .dictate) == .selectedAppsOnly { + guard self.dictationPromptSelection(for: slot) != .off else { return "" } + return self.effectivePromptBody(for: .dictate, appBundleID: appBundleID) + } + switch self.dictationPromptSelection(for: slot) { case .off: return "" @@ -925,6 +939,11 @@ final class SettingsStore: ObservableObject { } func effectiveDictationSystemPrompt(for slot: DictationShortcutSlot, appBundleID: String? = nil) -> String { + if self.promptRoutingScope(for: .dictate) == .selectedAppsOnly { + guard self.dictationPromptSelection(for: slot) != .off else { return "" } + return self.effectiveSystemPrompt(for: .dictate, appBundleID: appBundleID) + } + switch self.dictationPromptSelection(for: slot) { case .off, .default: return self.effectiveSystemPrompt(for: .dictate, appBundleID: appBundleID) @@ -953,10 +972,10 @@ final class SettingsStore: ObservableObject { } /// Literal placeholder that gets substituted with the raw transcription - /// when composing the user message for a dictation cleanup call. + /// when composing the user message for a dictation enhancement call. static let transcriptPlaceholder = "${transcript}" - /// Compose the user-turn string for a dictation cleanup call by folding + /// Compose the user-turn string for a dictation enhancement call by folding /// the transcript into the prompt template. If the template contains the /// `${transcript}` placeholder, the placeholder is replaced; otherwise /// the transcript is appended after a blank line, matching the pre-PR @@ -973,9 +992,10 @@ final class SettingsStore: ObservableObject { private func defaultPromptResolution( for mode: PromptMode, source: PromptResolutionSource, - appBinding: AppPromptBinding? + appBinding: AppPromptBinding?, + allowDefaultOverride: Bool = true ) -> PromptResolution { - if let override = self.defaultPromptOverride(for: mode) { + if allowDefaultOverride, let override = self.defaultPromptOverride(for: mode) { let trimmedOverride = override.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedOverride.isEmpty { return PromptResolution( @@ -2265,7 +2285,9 @@ final class SettingsStore: ObservableObject { customDictionaryEntries: self.customDictionaryEntries, selectedDictationPromptID: self.selectedDictationPromptID, dictationPromptOff: self.isDictationPromptOff, + dictationPromptRoutingScope: self.dictationPromptRoutingScope, selectedEditPromptID: self.selectedEditPromptID, + editPromptRoutingScope: self.editPromptRoutingScope, defaultDictationPromptOverride: self.defaultDictationPromptOverride, defaultEditPromptOverride: self.defaultEditPromptOverride ) @@ -2340,6 +2362,8 @@ final class SettingsStore: ObservableObject { self.appPromptBindings = appPromptBindings self.selectedDictationPromptID = payload.selectedDictationPromptID self.isDictationPromptOff = payload.dictationPromptOff ?? self.isDictationPromptOff + self.dictationPromptRoutingScope = payload.dictationPromptRoutingScope ?? .allApps + self.editPromptRoutingScope = payload.editPromptRoutingScope ?? .allApps self.selectedEditPromptID = payload.selectedEditPromptID self.defaultDictationPromptOverride = payload.defaultDictationPromptOverride self.defaultEditPromptOverride = payload.defaultEditPromptOverride diff --git a/Sources/Fluid/Services/DictationAIPostProcessingGate.swift b/Sources/Fluid/Services/DictationAIPostProcessingGate.swift index a8bfd4d..6269712 100644 --- a/Sources/Fluid/Services/DictationAIPostProcessingGate.swift +++ b/Sources/Fluid/Services/DictationAIPostProcessingGate.swift @@ -7,12 +7,18 @@ enum DictationAIPostProcessingGate { /// - Requires dictation prompt selection to not be `Off` /// - Requires the selected provider connection to still be verified static func isConfigured() -> Bool { - self.isConfigured(for: .primary) + self.isConfigured(for: .primary, appBundleID: nil) } - static func isConfigured(for slot: SettingsStore.DictationShortcutSlot) -> Bool { + static func isConfigured(for slot: SettingsStore.DictationShortcutSlot, appBundleID: String? = nil) -> Bool { let settings = SettingsStore.shared guard settings.dictationPromptSelection(for: slot) != .off else { return false } + if let appBundleID, + settings.promptRoutingScope(for: .dictate) == .selectedAppsOnly, + !settings.hasAppPromptBinding(for: .dictate, appBundleID: appBundleID) + { + return false + } return self.isProviderConfigured() } diff --git a/Sources/Fluid/Services/NotificationService.swift b/Sources/Fluid/Services/NotificationService.swift index 8beee2e..8436348 100644 --- a/Sources/Fluid/Services/NotificationService.swift +++ b/Sources/Fluid/Services/NotificationService.swift @@ -42,7 +42,7 @@ enum NotificationService { private static func deliverAIProcessingFallback(error: String, using center: UNUserNotificationCenter) { let content = UNMutableNotificationContent() - content.title = "AI cleanup failed" + content.title = "AI Enhancement failed" content.body = "Typed raw transcription instead." content.subtitle = error content.sound = nil diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsView.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsView.swift index fa1ca36..d1a77b3 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsView.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsView.swift @@ -14,6 +14,7 @@ struct AIEnhancementSettingsView: View { @State var selectedPromptMode: SettingsStore.PromptMode = .dictate @State var hoveredPromptModeKey: String? = nil @State var hoveredCleanupControlKey: String? = nil + @State var hoveredPromptScopeKey: String? = nil var body: some View { self.aiConfigurationCard diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 89cd2f8..6aa1306 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -1429,7 +1429,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { let trimmedName = appName.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedName = trimmedName.isEmpty ? normalizedBundleID : trimmedName let existingPromptID = self.settings.appPromptBinding(for: mode, appBundleID: normalizedBundleID)?.promptID - let resolvedPromptID = existingPromptID ?? self.selectedPromptID(for: mode) + let resolvedPromptID: String? + if self.settings.promptRoutingScope(for: mode) == .selectedAppsOnly { + resolvedPromptID = existingPromptID + } else { + resolvedPromptID = existingPromptID ?? self.selectedPromptID(for: mode) + } self.appPromptBindingErrorMessage = "" self.settings.upsertAppPromptBinding( @@ -1497,13 +1502,24 @@ final class AIEnhancementSettingsViewModel: ObservableObject { $0.mode.normalized == mode.normalized }) else { - return "Default" + return "Built-in Default" } let trimmed = profile.name.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Untitled Prompt" : trimmed } + func promptRoutingScope(for mode: SettingsStore.PromptMode) -> SettingsStore.PromptRoutingScope { + self.settings.promptRoutingScope(for: mode) + } + + func setPromptRoutingScope(_ scope: SettingsStore.PromptRoutingScope, for mode: SettingsStore.PromptMode) { + self.settings.setPromptRoutingScope(scope, for: mode) + self.selectedDictationPromptID = self.settings.selectedDictationPromptID + self.selectedEditPromptID = self.settings.selectedEditPromptID + self.isDictationPromptOff = self.settings.isDictationPromptOff + } + func isPrimaryDictationPromptSelectionOff() -> Bool { self.settings.isDictationPromptOff } diff --git a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift index 2a3d15b..8e8bb6c 100644 --- a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift +++ b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift @@ -126,11 +126,11 @@ extension AIEnhancementSettingsView { .frame(width: 34, height: 34) VStack(alignment: .leading, spacing: 2) { - Text("AI Enhancements") + Text("AI Enhancement") .font(.title3) .fontWeight(.semibold) .foregroundStyle(self.theme.palette.primaryText) - Text("Choose the model used for AI Cleanup.") + Text("Choose the model used for AI Enhancement.") .font(.caption) .foregroundStyle(self.theme.palette.secondaryText) } @@ -146,13 +146,13 @@ extension AIEnhancementSettingsView { self.aiSetupSummaryDivider self.aiSetupSummaryItem(icon: "cloud", text: "Cloud models use provider APIs") self.aiSetupSummaryDivider - self.aiSetupSummaryItem(icon: "slider.horizontal.3", text: "AI Cleanup enables dictation prompts") + self.aiSetupSummaryItem(icon: "slider.horizontal.3", text: "AI Enhancement enables dictation prompts") } VStack(alignment: .leading, spacing: 7) { self.aiSetupSummaryItem(icon: "cpu", text: "Local models run on Mac") self.aiSetupSummaryItem(icon: "cloud", text: "Cloud models use provider APIs") - self.aiSetupSummaryItem(icon: "slider.horizontal.3", text: "AI Cleanup enables dictation prompts") + self.aiSetupSummaryItem(icon: "slider.horizontal.3", text: "AI Enhancement enables dictation prompts") } } .padding(.horizontal, 2) diff --git a/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift b/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift index 6d8ed54..120697f 100644 --- a/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift +++ b/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift @@ -29,7 +29,7 @@ extension AIEnhancementSettingsView { } self.promptControlsRow - self.promptModeSection(mode: self.selectedPromptMode) + self.promptModeViewport(mode: self.selectedPromptMode) } .padding(.horizontal, 4) } @@ -40,6 +40,18 @@ extension AIEnhancementSettingsView { } } + private func promptModeViewport(mode: SettingsStore.PromptMode) -> some View { + self.promptModeSection(mode: mode) + .frame( + maxWidth: .infinity, + minHeight: AISettingsLayout.promptModeMinHeight, + alignment: .topLeading + ) + .transaction { transaction in + transaction.animation = nil + } + } + func promptProfileCard( cardKey: String, title: String, @@ -173,14 +185,14 @@ extension AIEnhancementSettingsView { let isOff = self.viewModel.isPrimaryDictationPromptSelectionOff() return HStack(alignment: .center, spacing: 7) { - Text("AI Cleanup") + Text("AI Enhancement") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(self.theme.palette.secondaryText) .lineLimit(1) self.cleanupSegmentedControl(isOff: isOff, mode: .dictate) } - .help(isOff ? "Off: dictation types the raw transcript. Prompts and app overrides are paused." : "On: dictation uses the default prompt, then app overrides when matched.") + .help(isOff ? "Off: dictation types the raw transcript. Prompts and app overrides are paused." : "On: dictation follows the selected prompt scope.") } private var promptModeTabSelector: some View { @@ -245,6 +257,8 @@ extension AIEnhancementSettingsView { let customProfiles = self.viewModel.dictationPromptProfiles .filter { $0.mode.normalized == mode } let tone = self.modeAccentColor(mode) + let isSelectedAppsOnly = self.viewModel.promptRoutingScope(for: mode) == .selectedAppsOnly + let isPromptRoutingPaused = mode.normalized == .dictate && self.viewModel.isPrimaryDictationPromptSelectionOff() VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { @@ -266,66 +280,61 @@ extension AIEnhancementSettingsView { } .padding(.horizontal, 2) - if mode.normalized == .dictate { - Text("Selection indicators here preview the primary dictation shortcut. Assign each shortcut in Keyboard Shortcuts.") - .font(.caption2) - .foregroundStyle(self.theme.palette.secondaryText) - .padding(.horizontal, 4) - } - - if mode.normalized == .edit { - self.editModeProviderModelRow - } - - if mode.normalized == .dictate && self.viewModel.isPrimaryDictationPromptSelectionOff() { - self.promptRoutingDisabledRow(mode: mode) - } else { - if mode.normalized == .dictate { - self.promptRoutingHeader("Default Prompt") - } + self.promptModeHintRow(mode: mode) - self.promptProfileCard( - cardKey: "\(mode.normalized.rawValue)-default", - title: mode.normalized == .dictate ? "Built-in Default" : "Default \(self.friendlyModeName(mode))", - subtitle: self.viewModel.promptPreview(self.viewModel.defaultPromptBodyPreview(for: mode)), - mode: mode, - isSelected: mode.normalized == .dictate - ? (!self.viewModel.isPrimaryDictationPromptSelectionOff() && self.viewModel.selectedPromptID(for: mode) == nil) - : self.viewModel.selectedPromptID(for: mode) == nil, - onUse: { - self.viewModel.setSelectedPromptID(nil, for: mode) - }, - onManage: { self.viewModel.openDefaultPromptViewer(for: mode) }, - onResetDefault: { self.viewModel.resetDefaultPromptOverride(for: mode) }, - canResetDefault: self.viewModel.hasDefaultPromptOverride(for: mode) - ) + VStack(alignment: .leading, spacing: 8) { + self.promptRoutingScopeRow(mode: mode) - if customProfiles.isEmpty { - Text("No custom \(self.friendlyModeName(mode).lowercased()) prompts yet.") - .font(.caption2) - .foregroundStyle(.secondary) - .padding(.horizontal, 4) + if isSelectedAppsOnly { + self.selectedAppsOnlySummary(mode: mode) + self.appPromptBindingsSection(mode: mode, isEmphasized: true) } else { - ForEach(customProfiles) { profile in - self.promptProfileCard( - cardKey: "\(profile.mode.normalized.rawValue)-\(profile.id)", - title: profile.name.isEmpty ? "Untitled Prompt" : profile.name, - subtitle: SettingsStore.stripBasePrompt(for: profile.mode, from: profile.prompt).isEmpty - ? "Empty prompt (uses Default)" - : self.viewModel.promptPreview(SettingsStore.stripBasePrompt(for: profile.mode, from: profile.prompt)), - mode: profile.mode, - isSelected: self.viewModel.selectedPromptID(for: profile.mode) == profile.id, - onUse: { - self.viewModel.setSelectedPromptID(profile.id, for: profile.mode) - }, - onManage: { self.viewModel.openEditor(for: profile) }, - onDelete: { self.viewModel.requestDeletePrompt(profile) } - ) + self.promptProfileCard( + cardKey: "\(mode.normalized.rawValue)-default", + title: mode.normalized == .dictate ? "Built-in Default" : "Default \(self.friendlyModeName(mode))", + subtitle: self.viewModel.promptPreview(self.viewModel.defaultPromptBodyPreview(for: mode)), + mode: mode, + isSelected: mode.normalized == .dictate + ? (!self.viewModel.isPrimaryDictationPromptSelectionOff() && self.viewModel.selectedPromptID(for: mode) == nil) + : self.viewModel.selectedPromptID(for: mode) == nil, + onUse: { + self.viewModel.setSelectedPromptID(nil, for: mode) + }, + onManage: { self.viewModel.openDefaultPromptViewer(for: mode) }, + onResetDefault: { self.viewModel.resetDefaultPromptOverride(for: mode) }, + canResetDefault: self.viewModel.hasDefaultPromptOverride(for: mode) + ) + + if customProfiles.isEmpty { + Text("No custom \(self.friendlyModeName(mode).lowercased()) prompts yet.") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + } else { + ForEach(customProfiles) { profile in + self.promptProfileCard( + cardKey: "\(profile.mode.normalized.rawValue)-\(profile.id)", + title: profile.name.isEmpty ? "Untitled Prompt" : profile.name, + subtitle: SettingsStore.stripBasePrompt(for: profile.mode, from: profile.prompt).isEmpty + ? "Empty prompt (uses Default)" + : self.viewModel.promptPreview(SettingsStore.stripBasePrompt(for: profile.mode, from: profile.prompt)), + mode: profile.mode, + isSelected: self.viewModel.selectedPromptID(for: profile.mode) == profile.id, + onUse: { + self.viewModel.setSelectedPromptID(profile.id, for: profile.mode) + }, + onManage: { self.viewModel.openEditor(for: profile) }, + onDelete: { self.viewModel.requestDeletePrompt(profile) } + ) + } } - } - self.appPromptBindingsSection(mode: mode) + self.appPromptBindingsSection(mode: mode) + } } + .opacity(isPromptRoutingPaused ? 0.34 : 1) + .grayscale(isPromptRoutingPaused ? 0.75 : 0) + .allowsHitTesting(!isPromptRoutingPaused) } .padding(12) .background( @@ -338,6 +347,20 @@ extension AIEnhancementSettingsView { ) } + private func promptModeHintRow(mode: SettingsStore.PromptMode) -> some View { + HStack { + if mode.normalized == .dictate { + Text("Shortcut preview only. Assign shortcuts in Keyboard Shortcuts.") + .font(.caption2) + .foregroundStyle(self.theme.palette.secondaryText) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .frame(height: AISettingsLayout.promptModeHintHeight, alignment: .topLeading) + .padding(.horizontal, 4) + } + private func cleanupSegmentedControl(isOff: Bool, mode: SettingsStore.PromptMode) -> some View { let tone = self.modeAccentColor(mode) @@ -398,34 +421,101 @@ extension AIEnhancementSettingsView { } } - private func promptRoutingHeader(_ title: String) -> some View { - Text(title) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(self.theme.palette.secondaryText) - .padding(.horizontal, 4) - .padding(.top, 4) - } - - private func promptRoutingDisabledRow(mode: SettingsStore.PromptMode) -> some View { - HStack(spacing: 10) { - Image(systemName: "lock") - .font(.system(size: 13, weight: .semibold)) + private func promptRoutingScopeRow(mode: SettingsStore.PromptMode) -> some View { + HStack(alignment: .center, spacing: 10) { + Text(mode.normalized == .dictate ? "Use AI" : "Use prompts") + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(self.theme.palette.secondaryText) - .frame(width: 20, height: 20) + .frame(width: AISettingsLayout.promptScopeLabelWidth, alignment: .leading) - VStack(alignment: .leading, spacing: 2) { - Text("AI Cleanup is Off") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(self.theme.palette.primaryText) - Text("Dictation will type raw transcript. Prompts and app overrides won't run.") - .font(.caption2) - .foregroundStyle(self.theme.palette.secondaryText) - .lineLimit(1) + HStack(spacing: 4) { + self.promptRoutingScopeButton( + title: "All apps", + scope: .allApps, + mode: mode + ) + self.promptRoutingScopeButton( + title: "Selected apps only", + scope: .selectedAppsOnly, + mode: mode + ) } + .padding(3) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(self.theme.palette.contentBackground.opacity(0.78)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.22), lineWidth: 1) + ) + ) + + Spacer(minLength: 12) + + if mode.normalized == .edit { + self.editModeInlineModelControls + } else { + Color.clear + .frame(height: AISettingsLayout.controlHeight) + } + } + .frame(minHeight: AISettingsLayout.controlHeight) + .padding(.top, 2) + .padding(.horizontal, 4) + } + + private func promptRoutingScopeButton( + title: String, + scope: SettingsStore.PromptRoutingScope, + mode: SettingsStore.PromptMode + ) -> some View { + let selectedScope = self.viewModel.promptRoutingScope(for: mode) + let isSelected = selectedScope == scope + let key = "\(mode.normalized.rawValue)-\(scope.rawValue)" + let isHovering = self.hoveredPromptScopeKey == key + let tone = self.modeAccentColor(mode) + let cornerRadius: CGFloat = 9 + + return Button { + self.viewModel.setPromptRoutingScope(scope, for: mode) + } label: { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(isSelected ? tone : (isHovering ? self.theme.palette.primaryText : self.theme.palette.secondaryText)) + .frame(width: scope == .allApps ? 72 : 132, height: 26) + .contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .fluidControlSurface( + isSelected: isSelected, + isHovered: isHovering, + tone: tone, + cornerRadius: cornerRadius + ) + } + .buttonStyle(.plain) + .onHover { hovering in + self.hoveredPromptScopeKey = hovering ? key : nil + } + } + + private func selectedAppsOnlySummary(mode: SettingsStore.PromptMode) -> some View { + HStack(spacing: 8) { + Image(systemName: "target") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(self.theme.palette.accent) + .frame(width: 18, height: 18) + + Text(mode.normalized == .dictate + ? "No default enhancement. Add app overrides to use prompts in selected apps." + : "Default edit stays built-in. App overrides can use custom prompts." + ) + .font(.caption2) + .foregroundStyle(self.theme.palette.secondaryText) + .lineLimit(1) Spacer(minLength: 8) } - .padding(10) + .padding(.horizontal, 10) + .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(self.theme.palette.cardBackground.opacity(0.34)) @@ -436,42 +526,29 @@ extension AIEnhancementSettingsView { ) } - private var editModeProviderModelRow: some View { + private var editModeInlineModelControls: some View { let verified = self.editModeVerifiedProviders - return VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 12) { - Text("Edit mode model selection (optional)") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(self.theme.palette.secondaryText) - .lineLimit(1) - .layoutPriority(1) - .frame(maxWidth: .infinity, alignment: .leading) - } + return HStack(alignment: .center, spacing: 10) { + Text("Edit model") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(self.theme.palette.secondaryText) if verified.isEmpty { HStack(spacing: 8) { Image(systemName: "info.circle") .font(.system(size: 12)) .foregroundStyle(.secondary) - Text("No verified providers yet. Verify a provider above to enable Edit Text model selection.") + Text("No verified provider") .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(self.theme.palette.cardBackground.opacity(0.45)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(self.theme.palette.cardBorder.opacity(0.25), lineWidth: 1) - ) - ) } else { let providerID = self.activeEditModeProviderID let models = self.viewModel.models(for: providerID) - HStack(alignment: .center, spacing: 12) { - Toggle("Sync with AI Enhancement model", isOn: self.editModeLinkedToGlobalBinding) + Group { + Toggle("Sync", isOn: self.editModeLinkedToGlobalBinding) .toggleStyle(.checkbox) .font(.caption) .foregroundStyle(.secondary) @@ -484,102 +561,94 @@ extension AIEnhancementSettingsView { } } - HStack(alignment: .center, spacing: 10) { - Text("Provider") - .font(.caption) - .foregroundStyle(.secondary) + Text("Provider") + .font(.caption) + .foregroundStyle(.secondary) - Picker("", selection: self.editModeProviderBinding) { - ForEach(verified) { provider in - Text(provider.name).tag(provider.id) - } + Picker("", selection: self.editModeProviderBinding) { + ForEach(verified) { provider in + Text(provider.name).tag(provider.id) } - .pickerStyle(.menu) - .labelsHidden() - .frame(width: 170) - .disabled(self.settings.rewriteModeLinkedToGlobal) + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: AISettingsLayout.promptInlinePickerWidth) + .disabled(self.settings.rewriteModeLinkedToGlobal) - Text("Model") - .font(.caption) - .foregroundStyle(.secondary) + Text("Model") + .font(.caption) + .foregroundStyle(.secondary) - SearchableModelPicker( - models: models, - selectedModel: self.editModeModelBinding(for: providerID), - onRefresh: { await self.viewModel.fetchModels(for: providerID) }, - isRefreshing: self.viewModel.refreshingProviderID == providerID, - refreshEnabled: !self.settings.rewriteModeLinkedToGlobal && self.canFetchModels(for: providerID), - selectionEnabled: !self.settings.rewriteModeLinkedToGlobal && !models.isEmpty, - controlWidth: 190, - controlHeight: 28 - ) - .disabled(self.settings.rewriteModeLinkedToGlobal) - } - .frame(maxWidth: .infinity, alignment: .trailing) - .opacity(self.settings.rewriteModeLinkedToGlobal ? 0.65 : 1) - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(self.theme.palette.cardBackground.opacity(0.45)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(self.theme.palette.cardBorder.opacity(0.25), lineWidth: 1) - ) - ) - .onAppear { - self.normalizeEditModeProviderSelection() + SearchableModelPicker( + models: models, + selectedModel: self.editModeModelBinding(for: providerID), + onRefresh: { await self.viewModel.fetchModels(for: providerID) }, + isRefreshing: self.viewModel.refreshingProviderID == providerID, + refreshEnabled: !self.settings.rewriteModeLinkedToGlobal && self.canFetchModels(for: providerID), + selectionEnabled: !self.settings.rewriteModeLinkedToGlobal && !models.isEmpty, + controlWidth: AISettingsLayout.promptInlineModelWidth, + controlHeight: 26 + ) + .disabled(self.settings.rewriteModeLinkedToGlobal) } + .opacity(self.settings.rewriteModeLinkedToGlobal ? 0.65 : 1) } } - .padding(.horizontal, 2) + .frame(maxWidth: .infinity, alignment: .trailing) .onAppear { self.ensureDefaultEditModeSyncState() + if !verified.isEmpty { + self.normalizeEditModeProviderSelection() + } } } @ViewBuilder - private func appPromptBindingsSection(mode: SettingsStore.PromptMode) -> some View { + private func appPromptBindingsSection(mode: SettingsStore.PromptMode, isEmphasized: Bool = false) -> some View { let bindings = self.viewModel.appBindings(for: mode) let appTargets = self.viewModel.appBindingTargets(for: mode) let modeProfiles = self.viewModel.dictationPromptProfiles .filter { $0.mode.normalized == mode.normalized } VStack(alignment: .leading, spacing: 8) { - Text("App Overrides") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(self.theme.palette.secondaryText) - - Text("Use a different prompt only in selected apps.") - .font(.caption2) - .foregroundStyle(.secondary) - .padding(.horizontal, 4) + HStack(alignment: .center, spacing: 10) { + Text("App Overrides") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(self.theme.palette.secondaryText) - Menu { - if appTargets.isEmpty { - Text("No unassigned running apps") - } else { - ForEach(appTargets) { target in - Button(self.appBindingTargetMenuTitle(target)) { - self.viewModel.addAppPromptBinding( - for: mode, - appBundleID: target.bundleID, - appName: target.name - ) + Menu { + if appTargets.isEmpty { + Text("No unassigned running apps") + } else { + ForEach(appTargets) { target in + Button(self.appBindingTargetMenuTitle(target)) { + self.viewModel.addAppPromptBinding( + for: mode, + appBundleID: target.bundleID, + appName: target.name + ) + } } } - } - Divider() + Divider() - Button("Choose App…") { - self.viewModel.addAppPromptBindingFromFilePicker(for: mode) + Button("Choose App…") { + self.viewModel.addAppPromptBindingFromFilePicker(for: mode) + } + } label: { + Text("+ Add App") } - } label: { - Text("+ Add App") + .buttonStyle(CompactButtonStyle(isReady: true)) + .frame(minHeight: 26) + + Spacer(minLength: 8) } - .buttonStyle(CompactButtonStyle(isReady: true)) - .frame(minHeight: 26) + + Text(isEmphasized ? "Use prompts only in selected apps." : "Use a different prompt only in selected apps.") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) if bindings.isEmpty { Text("No app overrides yet. Add one to use a different prompt for a specific app.") @@ -596,7 +665,7 @@ extension AIEnhancementSettingsView { } } } - .padding(.top, 6) + .padding(.top, isEmphasized ? 2 : 6) } @ViewBuilder @@ -606,6 +675,8 @@ extension AIEnhancementSettingsView { modeProfiles: [SettingsStore.DictationPromptProfile] ) -> some View { HStack(spacing: 10) { + self.appIconView(bundleID: binding.appBundleID) + VStack(alignment: .leading, spacing: 2) { Text(binding.appName) .font(.system(size: 12, weight: .semibold)) @@ -680,6 +751,26 @@ extension AIEnhancementSettingsView { ) } + @ViewBuilder + private func appIconView(bundleID: String) -> some View { + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + Image(nsImage: NSWorkspace.shared.icon(forFile: url.path)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + } else { + Image(systemName: "app.dashed") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(self.theme.palette.secondaryText) + .frame(width: 24, height: 24) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(self.theme.palette.cardBackground.opacity(0.55)) + ) + } + } + private var editModeVerifiedProviders: [AIEnhancementSettingsViewModel.ProviderItemData] { self.viewModel.cachedVerifiedProviderItems.sorted { lhs, rhs in lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending diff --git a/Sources/Fluid/UI/AISettingsView.swift b/Sources/Fluid/UI/AISettingsView.swift index 273a447..632b3fe 100644 --- a/Sources/Fluid/UI/AISettingsView.swift +++ b/Sources/Fluid/UI/AISettingsView.swift @@ -73,6 +73,11 @@ enum AISettingsLayout { static let wideActionMinWidth: CGFloat = 140 static let primaryActionMinWidth: CGFloat = 150 static let promptActionMinWidth: CGFloat = 90 + static let promptModeMinHeight: CGFloat = 430 + static let promptModeHintHeight: CGFloat = 18 + static let promptInlinePickerWidth: CGFloat = 145 + static let promptInlineModelWidth: CGFloat = 180 + static let promptScopeLabelWidth: CGFloat = 110 static let rowLeadingIndent: CGFloat = labelWidth + 12 } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 10c6384..d4eb523 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -660,7 +660,7 @@ struct SettingsView: View { icon: "text.bubble.fill", iconColor: .secondary, title: "Secondary Dictation Shortcut", - description: "Defaults to Default cleanup, but can use Off, Default, or any custom prompt." + description: "Defaults to AI Enhancement, but can use Off, Default, or any custom prompt." ), shortcut: self.promptModeShortcut, isRecording: self.isRecording(.secondaryDictation), @@ -807,8 +807,8 @@ struct SettingsView: View { Divider().opacity(0.2) self.optionToggleRow( - title: "Notify AI Cleanup Failures", - description: "Show a macOS notification when AI cleanup fails and raw transcription is typed.", + title: "Notify AI Enhancement Failures", + description: "Show a macOS notification when AI Enhancement fails and raw transcription is typed.", isOn: Binding( get: { SettingsStore.shared.notifyAIProcessingFailures }, set: { SettingsStore.shared.notifyAIProcessingFailures = $0 } diff --git a/Sources/Fluid/UI/TranscriptionHistoryView.swift b/Sources/Fluid/UI/TranscriptionHistoryView.swift index e273844..014fb7f 100644 --- a/Sources/Fluid/UI/TranscriptionHistoryView.swift +++ b/Sources/Fluid/UI/TranscriptionHistoryView.swift @@ -328,7 +328,7 @@ struct TranscriptionHistoryView: View { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Color.orange) VStack(alignment: .leading, spacing: 2) { - Text("AI cleanup failed - raw transcription was typed instead") + Text("AI Enhancement failed - raw transcription was typed instead") .font(.system(size: 12, weight: .semibold)) Text(aiError) .font(.system(size: 11))