diff --git a/.github/screenshots/transcribe-with-prompt-settings.jpg b/.github/screenshots/transcribe-with-prompt-settings.jpg new file mode 100644 index 00000000..07791eae Binary files /dev/null and b/.github/screenshots/transcribe-with-prompt-settings.jpg differ diff --git a/Sources/Fluid/Analytics/AnalyticsService.swift b/Sources/Fluid/Analytics/AnalyticsService.swift index 6ae2242c..6167a799 100644 --- a/Sources/Fluid/Analytics/AnalyticsService.swift +++ b/Sources/Fluid/Analytics/AnalyticsService.swift @@ -82,7 +82,7 @@ final class AnalyticsService { "environment": environment, // Low-cardinality settings snapshot - "ai_processing_enabled": settings.enableAIProcessing, + "ai_processing_enabled": !settings.isDictationPromptOff, "streaming_preview_enabled": settings.enableStreamingPreview, "press_and_hold_mode": settings.pressAndHoldMode, "copy_to_clipboard_enabled": settings.copyTranscriptionToClipboard, diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index cad664ba..f0dac998 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -41,6 +41,7 @@ struct ContentView: View { private enum ActiveRecordingMode: String { case none case dictate + case promptMode case edit case command } @@ -57,11 +58,17 @@ struct ContentView: View { @EnvironmentObject private var menuBarManager: MenuBarManager @ObservedObject private var settings = SettingsStore.shared - // Computed properties to access shared services from AppServices container - // This maintains backward compatibility with the existing code while - // removing the duplicate service instances that cause startup crashes. - private var asr: ASRService { self.appServices.asr } - private var audioObserver: AudioHardwareObserver { self.appServices.audioObserver } + /// Computed properties to access shared services from AppServices container + /// This maintains backward compatibility with the existing code while + /// removing the duplicate service instances that cause startup crashes. + private var asr: ASRService { + self.appServices.asr + } + + private var audioObserver: AudioHardwareObserver { + self.appServices.audioObserver + } + @Environment(\.theme) private var theme @State private var hotkeyManager: GlobalHotkeyManager? = nil @State private var hotkeyManagerInitialized: Bool = false @@ -69,15 +76,19 @@ struct ContentView: View { @State private var appear = false @State private var accessibilityEnabled = false @State private var hotkeyShortcut: HotkeyShortcut = SettingsStore.shared.hotkeyShortcut + @State private var promptModeHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.promptModeHotkeyShortcut @State private var commandModeHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.commandModeHotkeyShortcut @State private var rewriteModeHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.rewriteModeHotkeyShortcut + @State private var isPromptModeShortcutEnabled: Bool = SettingsStore.shared.promptModeShortcutEnabled @State private var isCommandModeShortcutEnabled: Bool = SettingsStore.shared.commandModeShortcutEnabled @State private var aiSettingsExpanded: Bool = true @State private var isRewriteModeShortcutEnabled: Bool = SettingsStore.shared.rewriteModeShortcutEnabled @State private var isRecordingForRewrite: Bool = false // Track if current recording is for rewrite mode @State private var isRecordingForCommand: Bool = false // Track if current recording is for command mode + @State private var promptModeOverrideText: String? // System prompt text to use when in prompt mode @State private var activeRecordingMode: ActiveRecordingMode = .none @State private var isRecordingShortcut = false + @State private var isRecordingPromptModeShortcut = false @State private var isRecordingCommandModeShortcut = false @State private var isRecordingRewriteShortcut = false @State private var pendingModifierFlags: NSEvent.ModifierFlags = [] @@ -356,7 +367,7 @@ struct ContentView: View { let eventModifiers = event.modifierFlags.intersection([.function, .command, .option, .control, .shift]) let shortcutModifiers = self.hotkeyShortcut.modifierFlags.intersection([.function, .command, .option, .control, .shift]) - let isRecordingAnyShortcut = self.isRecordingShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut + let isRecordingAnyShortcut = self.isRecordingShortcut || self.isRecordingPromptModeShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut if event.type == .keyDown { if event.keyCode == self.hotkeyShortcut.keyCode && eventModifiers == shortcutModifiers { @@ -406,6 +417,7 @@ struct ContentView: View { if keyCode == 53 { DebugLogger.shared.debug("NSEvent monitor: Escape pressed, cancelling shortcut recording", source: "ContentView") self.isRecordingShortcut = false + self.isRecordingPromptModeShortcut = false self.isRecordingCommandModeShortcut = false self.isRecordingRewriteShortcut = false self.resetPendingShortcutState() @@ -421,6 +433,11 @@ struct ContentView: View { SettingsStore.shared.rewriteModeHotkeyShortcut = newShortcut self.hotkeyManager?.updateRewriteModeShortcut(newShortcut) self.isRecordingRewriteShortcut = false + } else if self.isRecordingPromptModeShortcut { + self.promptModeHotkeyShortcut = newShortcut + SettingsStore.shared.promptModeHotkeyShortcut = newShortcut + self.hotkeyManager?.updatePromptModeShortcut(newShortcut) + self.isRecordingPromptModeShortcut = false } else if self.isRecordingCommandModeShortcut { self.commandModeHotkeyShortcut = newShortcut SettingsStore.shared.commandModeHotkeyShortcut = newShortcut @@ -459,6 +476,11 @@ struct ContentView: View { SettingsStore.shared.rewriteModeHotkeyShortcut = newShortcut self.hotkeyManager?.updateRewriteModeShortcut(newShortcut) self.isRecordingRewriteShortcut = false + } else if self.isRecordingPromptModeShortcut { + self.promptModeHotkeyShortcut = newShortcut + SettingsStore.shared.promptModeHotkeyShortcut = newShortcut + self.hotkeyManager?.updatePromptModeShortcut(newShortcut) + self.isRecordingPromptModeShortcut = false } else if self.isRecordingCommandModeShortcut { self.commandModeHotkeyShortcut = newShortcut SettingsStore.shared.commandModeHotkeyShortcut = newShortcut @@ -519,6 +541,22 @@ struct ContentView: View { .onChange(of: self.selectedProviderID) { _, newValue in SettingsStore.shared.selectedProviderID = newValue } + .onChange(of: self.isPromptModeShortcutEnabled) { newValue in + SettingsStore.shared.promptModeShortcutEnabled = newValue + self.hotkeyManager?.updatePromptModeShortcutEnabled(newValue) + + if !newValue { + self.isRecordingPromptModeShortcut = false + + if self.activeRecordingMode == .promptMode { + if self.asr.isRunning { + Task { await self.asr.stopWithoutTranscription() } + } + self.clearActiveRecordingMode() + self.menuBarManager.setOverlayMode(.dictation) + } + } + } .onChange(of: self.isCommandModeShortcutEnabled) { newValue in SettingsStore.shared.commandModeShortcutEnabled = newValue self.hotkeyManager?.updateCommandModeShortcutEnabled(newValue) @@ -622,15 +660,11 @@ struct ContentView: View { } } .onDisappear { - NotchContentState.shared.onPromptModeSwitchRequested = nil - NotchContentState.shared.onOverlayModeSwitchRequested = nil - NotchContentState.shared.onReprocessLastRequested = nil - NotchContentState.shared.onCopyLastRequested = nil - NotchContentState.shared.onUndoLastAIRequested = nil - NotchContentState.shared.onToggleAIProcessingRequested = nil - NotchContentState.shared.onOpenPreferencesRequested = nil Task { await self.asr.stopWithoutTranscription() } // Note: Overlay lifecycle is now managed by MenuBarManager + // Note: NotchContentState handlers capture self (a struct value copy) and are + // intentionally kept alive so the overlay remains fully functional when the + // settings window is closed. No retain cycle risk since ContentView is a value type. // Stop accessibility polling self.accessibilityPollingTask?.cancel() @@ -950,7 +984,7 @@ struct ContentView: View { accessibilityEnabled: self.accessibilityEnabled, markAISkipped: { self.settings.onboardingAISkipped = true - self.menuBarManager.setAIProcessingEnabled(false) + self.settings.setDictationPromptSelection(.off) }, markPlaygroundValidated: { self.settings.onboardingPlaygroundValidated = true @@ -1101,6 +1135,9 @@ struct ContentView: View { accessibilityEnabled: self.$accessibilityEnabled, hotkeyShortcut: self.$hotkeyShortcut, isRecordingShortcut: self.$isRecordingShortcut, + promptModeShortcut: self.$promptModeHotkeyShortcut, + isRecordingPromptModeShortcut: self.$isRecordingPromptModeShortcut, + promptModeShortcutEnabled: self.$isPromptModeShortcutEnabled, commandModeShortcut: self.$commandModeHotkeyShortcut, isRecordingCommandModeShortcut: self.$isRecordingCommandModeShortcut, rewriteShortcut: self.$rewriteModeHotkeyShortcut, @@ -1173,7 +1210,9 @@ struct ContentView: View { // MARK: - Model Management Functions - private func saveModels() { SettingsStore.shared.availableModels = self.availableModels } + private func saveModels() { + SettingsStore.shared.availableModels = self.availableModels + } // MARK: - Provider Management Functions @@ -1458,13 +1497,20 @@ struct ContentView: View { DebugLogger.shared.debug("processTextWithAI using provider=\(derivedCurrentProvider), model=\(derivedSelectedModel)", source: "ContentView") + // Resolve the effective system prompt once so every provider path + // honors transient overrides such as "Transcribe with Prompt". + let appInfo = self.recordingAppInfo ?? self.getCurrentAppInfo() + let systemPrompt: String = { + let override = overrideSystemPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !override.isEmpty { return override } + return self.buildSystemPrompt(appInfo: appInfo) + }() + // Route to Apple Intelligence if selected if currentSelectedProviderID == "apple-intelligence" { #if canImport(FoundationModels) if #available(macOS 26.0, *) { let provider = AppleIntelligenceProvider() - let appInfo = self.recordingAppInfo ?? self.getCurrentAppInfo() - let systemPrompt = self.buildSystemPrompt(appInfo: appInfo) if self.shouldTracePromptProcessing { let selectedProfile = SettingsStore.shared.resolvedPromptProfile( for: .dictate, @@ -1507,13 +1553,6 @@ struct ContentView: View { } } - // Get app context captured at start of recording if available - let appInfo = self.recordingAppInfo ?? self.getCurrentAppInfo() - let systemPrompt: String = { - let override = overrideSystemPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !override.isEmpty { return override } - return self.buildSystemPrompt(appInfo: appInfo) - }() DebugLogger.shared.debug("Using app context for AI: app=\(appInfo.name), bundleId=\(appInfo.bundleId), title=\(appInfo.windowTitle)", source: "ContentView") if self.shouldTracePromptProcessing { let selectedProfile = SettingsStore.shared.resolvedPromptProfile( @@ -1629,6 +1668,7 @@ struct ContentView: View { let modeAtStop = self.activeRecordingMode let wasRewriteMode = modeAtStop == .edit || self.isRecordingForRewrite let wasCommandMode = modeAtStop == .command || self.isRecordingForCommand + let promptOverride = self.promptModeOverrideText DebugLogger.shared.info( "Routing decision snapshot | activeMode=\(modeAtStop.rawValue) | rewrite=\(wasRewriteMode) | command=\(wasCommandMode) | overlay=\(NotchContentState.shared.mode.rawValue)", source: "ContentView" @@ -1668,8 +1708,8 @@ struct ContentView: View { promptTest.lastOutputText = "" promptTest.lastError = "" - guard DictationAIPostProcessingGate.isConfigured() else { - promptTest.lastError = "AI post-processing is not configured. Enable AI Enhancement and configure a provider/model (and API key for non-local endpoints)." + guard DictationAIPostProcessingGate.isProviderConfigured() else { + promptTest.lastError = "AI post-processing is not configured. Configure a provider/model (and API key for non-local endpoints) to test prompts." self.menuBarManager.setProcessing(false) return } @@ -1720,8 +1760,10 @@ struct ContentView: View { var finalText: String - // Check if we should use AI processing - let shouldUseAI = DictationAIPostProcessingGate.isConfigured() + // Check if we should use AI processing. + // Prompt mode can still use AI when a provider is configured even if the global gate is off. + let shouldUseAI = DictationAIPostProcessingGate.isConfigured() || + (promptOverride != nil && DictationAIPostProcessingGate.isProviderConfigured()) let transcriptionModelInfo = self.currentTranscriptionModelInfo() if shouldUseAI { @@ -1736,7 +1778,7 @@ struct ContentView: View { // Ensure the status label becomes visible immediately. await Task.yield() - finalText = await self.processTextWithAI(transcribedText) + finalText = await self.processTextWithAI(transcribedText, overrideSystemPrompt: promptOverride) let postProcessingLatencyMs = Int((Date().timeIntervalSince(postProcessingStart) * 1000).rounded()) AnalyticsService.shared.capture( .dictationPostProcessingCompleted, @@ -1883,7 +1925,7 @@ struct ContentView: View { let onboardingPlaygroundStep = 4 let isOnboardingPlayground = !self.settings.onboardingCompleted && self.settings.onboardingCurrentStep == onboardingPlaygroundStep - let isDictationMode = self.activeRecordingMode == .dictate + let isDictationMode = self.activeRecordingMode == .dictate || self.activeRecordingMode == .promptMode if isOnboardingPlayground && isDictationMode { return .onboardingSandbox @@ -2128,9 +2170,15 @@ struct ContentView: View { } private func setActiveRecordingMode(_ mode: ActiveRecordingMode) { + if mode != .promptMode { + self.promptModeOverrideText = nil + NotchContentState.shared.promptModeOverrideProfileName = nil + NotchContentState.shared.promptModeOverrideProfileID = nil + NotchContentState.shared.isPromptModeActive = false + } self.activeRecordingMode = mode switch mode { - case .none, .dictate: + case .none, .dictate, .promptMode: self.isRecordingForCommand = false self.isRecordingForRewrite = false case .edit: @@ -2216,7 +2264,7 @@ struct ContentView: View { DebugLogger.shared.info("Command processed, conversation stored in Command Mode", source: "ContentView") } - // Capture app context at start to avoid mismatches if the user switches apps mid-session + /// Capture app context at start to avoid mismatches if the user switches apps mid-session private func startRecording() { let model = SettingsStore.shared.selectedSpeechModel DebugLogger.shared.info( @@ -2348,146 +2396,6 @@ struct ContentView: View { self.newModelName = "" } - // MARK: - OpenAI-compatible call for playground - - private func callOpenAIChat() async { - guard !self.isCallingAI else { return } - await MainActor.run { self.isCallingAI = true } - defer { Task { await MainActor.run { isCallingAI = false } } } - - let result = await processTextWithAI(aiInputText) - await MainActor.run { self.aiOutputText = result } - } - - private func getModelStatusText() -> String { - if self.asr.isLoadingModel { - return "Loading model into memory... (30-60 sec)" - } else if self.asr.isDownloadingModel { - return "Downloading model... Please wait." - } else if self.asr.isAsrReady { - return "Model is ready to use!" - } else if self.asr.modelsExistOnDisk { - return "Model cached. Will load on first use." - } else { - return "Model will download when needed." - } - } - - private var onboardingVoiceModelReady: Bool { - self.asr.isAsrReady || self.asr.modelsExistOnDisk || SettingsStore.shared.selectedSpeechModel.isInstalled - } - - private var onboardingMicrophoneReady: Bool { - self.asr.micStatus == .authorized - } - - private var onboardingAccessibilityReady: Bool { - self.accessibilityEnabled - } - - private var onboardingAIReady: Bool { - self.settings.onboardingAISkipped || DictationAIPostProcessingGate.isConfigured() - } - - private var onboardingPlaygroundReady: Bool { - self.settings.onboardingPlaygroundValidated - } - - private var canCompleteOnboarding: Bool { - self.onboardingVoiceModelReady && - self.onboardingMicrophoneReady && - self.onboardingAccessibilityReady && - self.onboardingAIReady && - self.onboardingPlaygroundReady - } - - @MainActor - private func completeOnboardingIfPossible() { - guard self.canCompleteOnboarding else { return } - - self.settings.onboardingCompleted = true - - let isOnboarded = self.asr.isAsrReady || self.asr.modelsExistOnDisk - self.selectedSidebarItem = isOnboarded ? .preferences : .welcome - } - - private func labelFor(status: AVAuthorizationStatus) -> String { - switch status { - case .authorized: return "Microphone: Authorized" - case .denied: return "Microphone: Denied" - case .restricted: return "Microphone: Restricted" - case .notDetermined: return "Microphone: Not Determined" - @unknown default: return "Microphone: Unknown" - } - } - - private func checkAccessibilityPermissions() -> Bool { - return AXIsProcessTrusted() - } - - private func openAccessibilitySettings() { - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary - AXIsProcessTrustedWithOptions(options) - self.didOpenAccessibilityPane = true - UserDefaults.standard.set(true, forKey: self.accessibilityRestartFlagKey) - } - - private func restartApp() { - let appPath = Bundle.main.bundlePath - let process = Process() - process.launchPath = "/usr/bin/open" - process.arguments = ["-n", appPath] - // Clear pending flag and hide prompt before restarting - UserDefaults.standard.set(false, forKey: self.accessibilityRestartFlagKey) - self.showRestartPrompt = false - try? process.run() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - NSApp.terminate(nil) - } - } - - private func startAccessibilityPolling() { - // Don't poll if already enabled or if we've already auto-restarted once - guard !self.accessibilityEnabled else { return } - guard !UserDefaults.standard.bool(forKey: self.hasAutoRestartedForAccessibilityKey) else { return } - - // Cancel any existing polling task - self.accessibilityPollingTask?.cancel() - - // Start background polling - self.accessibilityPollingTask = Task { - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 2_000_000_000) // Poll every 2 seconds - - // Check if permission was granted - let nowTrusted = AXIsProcessTrusted() - if nowTrusted && !self.accessibilityEnabled { - await MainActor.run { - DebugLogger.shared.info("Accessibility permission granted! Auto-restarting app...", source: "ContentView") - - // Mark that we've auto-restarted to prevent loops - UserDefaults.standard.set(true, forKey: self.hasAutoRestartedForAccessibilityKey) - - // Give user brief moment to see any UI feedback - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.restartApp() - } - } - break // Stop polling after triggering restart - } - } - } - } - - private func revealAppInFinder() { - let appPath = Bundle.main.bundlePath - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: appPath)]) - } - - private func openApplicationsFolder() { - NSWorkspace.shared.open(URL(fileURLWithPath: "/Applications")) - } - private func initializeHotkeyManagerIfNeeded() { NotchContentState.shared.onPromptModeSwitchRequested = { mode in self.handleLivePromptModeSwitch(mode) @@ -2504,20 +2412,35 @@ struct ContentView: View { NotchContentState.shared.onUndoLastAIRequested = { self.undoLastAIProcessingFromHistory() } - NotchContentState.shared.onToggleAIProcessingRequested = { - _ = self.menuBarManager.toggleAIProcessingEnabled() - } NotchContentState.shared.onOpenPreferencesRequested = { self.menuBarManager.openPreferencesFromUI() } + NotchContentState.shared.onPromptModeProfileChangeRequested = { profile in + if let p = profile { + self.promptModeOverrideText = SettingsStore.combineBasePrompt( + for: .dictate, + with: SettingsStore.stripBasePrompt(for: .dictate, from: p.prompt) + ) + NotchContentState.shared.promptModeOverrideProfileName = p.name + NotchContentState.shared.promptModeOverrideProfileID = p.id + SettingsStore.shared.promptModeSelectedPromptID = p.id + } else { + self.promptModeOverrideText = nil + NotchContentState.shared.promptModeOverrideProfileName = nil + NotchContentState.shared.promptModeOverrideProfileID = nil + SettingsStore.shared.promptModeSelectedPromptID = nil + } + } guard self.hotkeyManager == nil else { return } self.hotkeyManager = GlobalHotkeyManager( asrService: self.asr, shortcut: self.hotkeyShortcut, + promptModeShortcut: self.promptModeHotkeyShortcut, commandModeShortcut: self.commandModeHotkeyShortcut, rewriteModeShortcut: self.rewriteModeHotkeyShortcut, + promptModeShortcutEnabled: self.isPromptModeShortcutEnabled, commandModeShortcutEnabled: self.isCommandModeShortcutEnabled, rewriteModeShortcutEnabled: self.isRewriteModeShortcutEnabled, startRecordingCallback: { @@ -2548,6 +2471,33 @@ struct ContentView: View { DebugLogger.shared.info("Hotkey stop callback using route: \(route.rawValue)", source: "ContentView") await self.stopAndProcessTranscription(route: route) }, + promptModeCallback: { + DebugLogger.shared.info("Prompt mode triggered", source: "ContentView") + self.captureRecordingContext() + + // Resolve the full system prompt for the selected profile + let settings = SettingsStore.shared + if let promptID = settings.promptModeSelectedPromptID, + let profile = settings.dictationPromptProfiles.first(where: { $0.id == promptID }) + { + self.promptModeOverrideText = SettingsStore.combineBasePrompt(for: .dictate, with: SettingsStore.stripBasePrompt(for: .dictate, from: profile.prompt)) + NotchContentState.shared.promptModeOverrideProfileName = profile.name + NotchContentState.shared.promptModeOverrideProfileID = profile.id + } + + NotchContentState.shared.isPromptModeActive = true + self.setActiveRecordingMode(.promptMode) + self.rewriteModeService.clearState() + self.menuBarManager.setOverlayMode(.dictation) + + guard !self.asr.isRunning else { return } + if settings.enableTranscriptionSounds { + TranscriptionSoundPlayer.shared.playStartSound() + } + Task { + await self.asr.start() + } + }, commandModeCallback: { DebugLogger.shared.info("Command mode triggered", source: "ContentView") self.captureRecordingContext() @@ -2608,6 +2558,9 @@ struct ContentView: View { isDictateRecordingProvider: { self.activeRecordingMode == .dictate }, + isPromptModeRecordingProvider: { + self.activeRecordingMode == .promptMode + }, isCommandRecordingProvider: { self.activeRecordingMode == .command }, @@ -2741,6 +2694,152 @@ struct ContentView: View { // AudioDevice and AudioHardwareObserver moved to Services/AudioDeviceService.swift +// MARK: - ContentView Playground & Onboarding Helpers + +extension ContentView { + private func callOpenAIChat() async { + guard !self.isCallingAI else { return } + await MainActor.run { self.isCallingAI = true } + defer { Task { await MainActor.run { isCallingAI = false } } } + + let result = await processTextWithAI(aiInputText) + await MainActor.run { self.aiOutputText = result } + } + + private func getModelStatusText() -> String { + if self.asr.isLoadingModel { + return "Loading model into memory... (30-60 sec)" + } else if self.asr.isDownloadingModel { + return "Downloading model... Please wait." + } else if self.asr.isAsrReady { + return "Model is ready to use!" + } else if self.asr.modelsExistOnDisk { + return "Model cached. Will load on first use." + } else { + return "Model will download when needed." + } + } + + private var onboardingVoiceModelReady: Bool { + self.asr.isAsrReady || self.asr.modelsExistOnDisk || SettingsStore.shared.selectedSpeechModel.isInstalled + } + + private var onboardingMicrophoneReady: Bool { + self.asr.micStatus == .authorized + } + + private var onboardingAccessibilityReady: Bool { + self.accessibilityEnabled + } + + private var onboardingAIReady: Bool { + self.settings.onboardingAISkipped || DictationAIPostProcessingGate.isConfigured() + } + + private var onboardingPlaygroundReady: Bool { + self.settings.onboardingPlaygroundValidated + } + + private var canCompleteOnboarding: Bool { + self.onboardingVoiceModelReady && + self.onboardingMicrophoneReady && + self.onboardingAccessibilityReady && + self.onboardingAIReady && + self.onboardingPlaygroundReady + } + + @MainActor + private func revealAppInFinder() { + let appPath = Bundle.main.bundlePath + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: appPath)]) + } + + private func openApplicationsFolder() { + NSWorkspace.shared.open(URL(fileURLWithPath: "/Applications")) + } +} + +// MARK: - ContentView Accessibility & Lifecycle Helpers + +extension ContentView { + func completeOnboardingIfPossible() { + guard self.canCompleteOnboarding else { return } + + self.settings.onboardingCompleted = true + + let isOnboarded = self.asr.isAsrReady || self.asr.modelsExistOnDisk + self.selectedSidebarItem = isOnboarded ? .preferences : .welcome + } + + func labelFor(status: AVAuthorizationStatus) -> String { + switch status { + case .authorized: return "Microphone: Authorized" + case .denied: return "Microphone: Denied" + case .restricted: return "Microphone: Restricted" + case .notDetermined: return "Microphone: Not Determined" + @unknown default: return "Microphone: Unknown" + } + } + + func checkAccessibilityPermissions() -> Bool { + return AXIsProcessTrusted() + } + + func openAccessibilitySettings() { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + AXIsProcessTrustedWithOptions(options) + self.didOpenAccessibilityPane = true + UserDefaults.standard.set(true, forKey: self.accessibilityRestartFlagKey) + } + + func restartApp() { + let appPath = Bundle.main.bundlePath + let process = Process() + process.launchPath = "/usr/bin/open" + process.arguments = ["-n", appPath] + // Clear pending flag and hide prompt before restarting + UserDefaults.standard.set(false, forKey: self.accessibilityRestartFlagKey) + self.showRestartPrompt = false + try? process.run() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + NSApp.terminate(nil) + } + } + + func startAccessibilityPolling() { + // Don't poll if already enabled or if we've already auto-restarted once + guard !self.accessibilityEnabled else { return } + guard !UserDefaults.standard.bool(forKey: self.hasAutoRestartedForAccessibilityKey) else { return } + + // Cancel any existing polling task + self.accessibilityPollingTask?.cancel() + + // Start background polling + self.accessibilityPollingTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 2_000_000_000) // Poll every 2 seconds + + // Check if permission was granted + let nowTrusted = AXIsProcessTrusted() + if nowTrusted && !self.accessibilityEnabled { + await MainActor.run { + DebugLogger.shared.info("Accessibility permission granted! Auto-restarting app...", source: "ContentView") + + // Mark that we've auto-restarted to prevent loops + UserDefaults.standard.set(true, forKey: self.hasAutoRestartedForAccessibilityKey) + + // Give user brief moment to see any UI feedback + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.restartApp() + } + } + break // Stop polling after triggering restart + } + } + } + } +} + // MARK: - Card Animation Modifier struct CardAppearAnimation: ViewModifier { diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index cfafda6b..ac301b40 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -23,6 +23,7 @@ final class SettingsStore: ObservableObject { self.migrateProviderAPIKeysIfNeeded() self.scrubSavedProviderAPIKeys() self.migrateDictationPromptProfilesIfNeeded() + self.migrateLegacyDictationAIPreferenceIfNeeded() self.normalizePromptSelectionsIfNeeded() self.migrateOverlayBottomOffsetTo50IfNeeded() } @@ -35,9 +36,13 @@ final class SettingsStore: ObservableObject { case write // legacy persisted value (decoded as .edit) case rewrite // legacy persisted value (decoded as .edit) - var id: String { self.rawValue } + var id: String { + self.rawValue + } - static var visiblePromptModes: [PromptMode] { [.dictate, .edit] } + static var visiblePromptModes: [PromptMode] { + [.dictate, .edit] + } var normalized: PromptMode { switch self { @@ -78,6 +83,12 @@ final class SettingsStore: ObservableObject { } } + enum DictationPromptSelection: Equatable { + case off + case `default` + case profile(String) + } + struct DictationPromptProfile: Codable, Identifiable, Hashable { let id: String var name: String @@ -262,6 +273,38 @@ final class SettingsStore: ObservableObject { } } + var isDictationPromptOff: Bool { + get { self.defaults.bool(forKey: Keys.dictationPromptOff) } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.dictationPromptOff) + } + } + + var dictationPromptSelection: DictationPromptSelection { + if self.isDictationPromptOff { + return .off + } + if let promptID = self.selectedDictationPromptID { + return .profile(promptID) + } + return .default + } + + func setDictationPromptSelection(_ selection: DictationPromptSelection) { + switch selection { + case .off: + self.isDictationPromptOff = true + self.selectedDictationPromptID = nil + case .default: + self.isDictationPromptOff = false + self.selectedDictationPromptID = nil + case let .profile(promptID): + self.isDictationPromptOff = false + self.selectedDictationPromptID = promptID + } + } + /// Convenience: currently selected profile, or nil if Default/invalid selection. var selectedDictationPromptProfile: DictationPromptProfile? { self.selectedPromptProfile(for: .dictate) @@ -326,7 +369,11 @@ final class SettingsStore: ObservableObject { func setSelectedPromptID(_ id: String?, for mode: PromptMode) { switch mode.normalized { case .dictate: - self.selectedDictationPromptID = id + if let id { + self.setDictationPromptSelection(.profile(id)) + } else { + self.setDictationPromptSelection(.default) + } case .edit: self.selectedEditPromptID = id case .write, .rewrite: @@ -723,12 +770,11 @@ final class SettingsStore: ObservableObject { } } - let fallback = self.defaultPromptResolution( + return self.defaultPromptResolution( for: normalizedMode, source: .appBindingDefault, appBinding: binding ) - return fallback } if let profile = self.selectedPromptProfile(for: normalizedMode) { @@ -1234,7 +1280,9 @@ final class SettingsStore: ObservableObject { case purple = "Purple" case orange = "Orange" - var id: String { self.rawValue } + var id: String { + self.rawValue + } var hex: String { switch self { @@ -1254,7 +1302,9 @@ final class SettingsStore: ObservableObject { case fluidSfx3 = "fluid_sfx_3" case fluidSfx4 = "fluid_sfx_4" - var id: String { self.rawValue } + var id: String { + self.rawValue + } var displayName: String { switch self { @@ -1591,6 +1641,52 @@ final class SettingsStore: ObservableObject { } } + // MARK: - Prompt Mode Settings (Transcribe with Prompt) + + var promptModeShortcutEnabled: Bool { + get { + let value = self.defaults.object(forKey: Keys.promptModeShortcutEnabled) + return value as? Bool ?? false + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.promptModeShortcutEnabled) + } + } + + var promptModeHotkeyShortcut: HotkeyShortcut { + get { + if let data = defaults.data(forKey: Keys.promptModeHotkeyShortcut), + let shortcut = try? JSONDecoder().decode(HotkeyShortcut.self, from: data) + { + return shortcut + } + // Default to Right Shift key (keyCode: 60, no modifiers) — avoids conflict with Command Mode (Right Command, keyCode 54) + return HotkeyShortcut(keyCode: 60, modifierFlags: []) + } + set { + objectWillChange.send() + if let data = try? JSONEncoder().encode(newValue) { + self.defaults.set(data, forKey: Keys.promptModeHotkeyShortcut) + } + } + } + + var promptModeSelectedPromptID: String? { + get { + let value = self.defaults.string(forKey: Keys.promptModeSelectedPromptID) + return value?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true ? nil : value + } + set { + objectWillChange.send() + if let id = newValue?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty { + self.defaults.set(id, forKey: Keys.promptModeSelectedPromptID) + } else { + self.defaults.removeObject(forKey: Keys.promptModeSelectedPromptID) + } + } + } + var commandModeShortcutEnabled: Bool { get { let value = self.defaults.object(forKey: Keys.commandModeShortcutEnabled) @@ -1942,6 +2038,25 @@ final class SettingsStore: ObservableObject { DebugLogger.shared.info("Migrated legacy custom dictation prompt to a prompt profile", source: "SettingsStore") } + private func migrateLegacyDictationAIPreferenceIfNeeded() { + guard self.defaults.object(forKey: Keys.dictationPromptOff) == nil else { return } + + let hasSelectedCustomDictationPrompt = self.selectedDictationPromptID.flatMap { id in + self.dictationPromptProfiles.first(where: { $0.id == id && $0.mode == .dictate }) + } != nil + + let shouldStartOff: Bool + if hasSelectedCustomDictationPrompt { + shouldStartOff = false + } else if self.defaults.object(forKey: Keys.enableAIProcessing) != nil { + shouldStartOff = !self.defaults.bool(forKey: Keys.enableAIProcessing) + } else { + shouldStartOff = false + } + + self.defaults.set(shouldStartOff, forKey: Keys.dictationPromptOff) + } + private func normalizePromptSelectionsIfNeeded() { // One-time migration to unified edit keys. if self.defaults.object(forKey: Keys.selectedEditPromptID) == nil, @@ -1986,6 +2101,12 @@ final class SettingsStore: ObservableObject { self.selectedEditPromptID = nil } + if let id = self.promptModeSelectedPromptID, + self.dictationPromptProfiles.contains(where: { $0.id == id && $0.mode.normalized == .dictate }) == false + { + self.promptModeSelectedPromptID = nil + } + let validPromptIDsByMode: [PromptMode: Set] = [ .dictate: Set(self.dictationPromptProfiles.filter { $0.mode.normalized == .dictate }.map(\.id)), .edit: Set(self.dictationPromptProfiles.filter { $0.mode.normalized == .edit }.map(\.id)), @@ -2323,8 +2444,8 @@ final class SettingsStore: ObservableObject { /// Unified speech recognition model selection. /// Replaces the old TranscriptionProviderOption + WhisperModelSize dual-setting. enum SpeechModel: String, CaseIterable, Identifiable, Codable { - // Temporarily disabled in UI/runtime while Parakeet word boosting work is prioritized. - // Flip to `true` in a future round to re-enable Qwen without deleting implementation. + /// Temporarily disabled in UI/runtime while Parakeet word boosting work is prioritized. + /// Flip to `true` in a future round to re-enable Qwen without deleting implementation. static let qwenPreviewEnabled = false // MARK: - FluidAudio Models (Apple Silicon Only) @@ -2349,7 +2470,9 @@ final class SettingsStore: ObservableObject { case whisperLargeTurbo = "whisper-large-turbo" // temporarily disabled in UI case whisperLarge = "whisper-large" - var id: String { rawValue } + var id: String { + rawValue + } // MARK: - Display Properties @@ -2861,7 +2984,9 @@ final class SettingsStore: ObservableObject { case fluidAudio case whisper - var id: String { rawValue } + var id: String { + rawValue + } var displayName: String { switch self { @@ -2916,9 +3041,10 @@ final class SettingsStore: ObservableObject { // swiftlint:enable type_body_length private extension SettingsStore { - // Keys + /// Keys enum Keys { static let enableAIProcessing = "EnableAIProcessing" + static let dictationPromptOff = "DictationPromptOff" static let enableDebugLogs = "EnableDebugLogs" static let availableAIModels = "AvailableAIModels" static let availableModelsByProvider = "AvailableModelsByProvider" @@ -2967,6 +3093,11 @@ private extension SettingsStore { static let commandModeLinkedToGlobal = "CommandModeLinkedToGlobal" static let commandModeShortcutEnabled = "CommandModeShortcutEnabled" + // Prompt Mode Keys (Transcribe with Prompt) + static let promptModeHotkeyShortcut = "PromptModeHotkeyShortcut" + static let promptModeShortcutEnabled = "PromptModeShortcutEnabled" + static let promptModeSelectedPromptID = "PromptModeSelectedPromptID" + // Rewrite Mode Keys static let rewriteModeHotkeyShortcut = "RewriteModeHotkeyShortcut" static let rewriteModeSelectedModel = "RewriteModeSelectedModel" @@ -2986,7 +3117,7 @@ private extension SettingsStore { static let fillerWords = "FillerWords" static let removeFillerWordsEnabled = "RemoveFillerWordsEnabled" - // GAAV Mode (removes capitalization and trailing punctuation) + /// GAAV Mode (removes capitalization and trailing punctuation) static let gaavModeEnabled = "GAAVModeEnabled" // Custom Dictionary @@ -2997,7 +3128,7 @@ private extension SettingsStore { static let selectedTranscriptionProvider = "SelectedTranscriptionProvider" static let whisperModelSize = "WhisperModelSize" - // Unified Speech Model (replaces above two) + /// Unified Speech Model (replaces above two) static let selectedSpeechModel = "SelectedSpeechModel" static let selectedCohereLanguage = "SelectedCohereLanguage" static let externalCoreMLArtifactsDirectories = "ExternalCoreMLArtifactsDirectories" @@ -3009,10 +3140,10 @@ private extension SettingsStore { static let overlaySize = "OverlaySize" static let transcriptionPreviewCharLimit = "TranscriptionPreviewCharLimit" - // Media Playback Control + /// Media Playback Control static let pauseMediaDuringTranscription = "PauseMediaDuringTranscription" - // Custom Dictation Prompt + /// Custom Dictation Prompt static let customDictationPrompt = "CustomDictationPrompt" // Dictation Prompt Profiles (multi-prompt system) @@ -3032,7 +3163,7 @@ private extension SettingsStore { static let defaultWritePromptOverride = "DefaultWritePromptOverride" // legacy fallback key static let defaultRewritePromptOverride = "DefaultRewritePromptOverride" // legacy fallback key - // Streak Settings + /// Streak Settings static let weekendsDontBreakStreak = "WeekendsDontBreakStreak" } } @@ -3042,7 +3173,9 @@ extension SettingsStore { case standard case reliablePaste - var id: String { self.rawValue } + var id: String { + self.rawValue + } var displayName: String { switch self { @@ -3099,7 +3232,9 @@ extension SettingsStore { case medium = "ggml-medium.bin" case large = "ggml-large-v3.bin" - var id: String { rawValue } + var id: String { + rawValue + } var displayName: String { switch self { diff --git a/Sources/Fluid/Services/DictationAIPostProcessingGate.swift b/Sources/Fluid/Services/DictationAIPostProcessingGate.swift index 70b8302e..21816754 100644 --- a/Sources/Fluid/Services/DictationAIPostProcessingGate.swift +++ b/Sources/Fluid/Services/DictationAIPostProcessingGate.swift @@ -3,23 +3,26 @@ import Foundation /// Shared gating logic for whether dictation AI post-processing is usable/configured. enum DictationAIPostProcessingGate { /// Returns true if dictation AI post-processing should be allowed, given current settings. - /// - Requires `SettingsStore.shared.enableAIProcessing == true` + /// - Requires dictation prompt selection to not be `Off` /// - For Apple Intelligence: requires `AppleIntelligenceService.isAvailable` /// - For other providers: requires a local endpoint OR a non-empty API key static func isConfigured() -> Bool { let settings = SettingsStore.shared - guard settings.enableAIProcessing else { return false } + guard !settings.isDictationPromptOff else { return false } + return self.isProviderConfigured() + } + + /// Returns true if the selected AI provider is reachable/configured (API key or local endpoint), + /// regardless of the AI toggle or prompt selection. Used to gate prompt-mode hotkey AI processing. + static func isProviderConfigured() -> Bool { + let settings = SettingsStore.shared let providerID = settings.selectedProviderID if providerID == "apple-intelligence" { return AppleIntelligenceService.isAvailable } - let baseURL = self.baseURL(for: providerID, settings: settings) - if self.isLocalEndpoint(baseURL) { - return true - } - + if self.isLocalEndpoint(baseURL) { return true } let apiKey = (settings.getAPIKey(for: providerID) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return !apiKey.isEmpty } diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index b25f6bd4..dc17af3f 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -3,6 +3,7 @@ import Foundation private enum HotkeyHoldModeType { case transcription + case promptMode case commandMode case rewriteMode } @@ -10,6 +11,7 @@ private enum HotkeyHoldModeType { private final class HotkeyState: @unchecked Sendable { private let lock = NSLock() var isKeyPressed = false + var isPromptModeKeyPressed = false var isCommandModeKeyPressed = false var isRewriteKeyPressed = false var modifierOnlyKeyDown = false @@ -32,16 +34,20 @@ final class GlobalHotkeyManager: NSObject { private nonisolated(unsafe) var runLoopSource: CFRunLoopSource? private let asrService: ASRService private var shortcut: HotkeyShortcut + private var promptModeShortcut: HotkeyShortcut private var commandModeShortcut: HotkeyShortcut private var rewriteModeShortcut: HotkeyShortcut + private var promptModeShortcutEnabled: Bool private var commandModeShortcutEnabled: Bool private var rewriteModeShortcutEnabled: Bool private var startRecordingCallback: (() async -> Void)? private var dictationModeCallback: (() async -> Void)? private var stopAndProcessCallback: (() async -> Void)? + private var promptModeCallback: (() async -> Void)? private var commandModeCallback: (() async -> Void)? private var rewriteModeCallback: (() async -> Void)? private var isDictateRecordingProvider: (() -> Bool)? + private var isPromptModeRecordingProvider: (() -> Bool)? private var isCommandRecordingProvider: (() -> Bool)? private var isRewriteRecordingProvider: (() -> Bool)? private var cancelCallback: (() -> Bool)? // Returns true if handled @@ -52,6 +58,11 @@ final class GlobalHotkeyManager: NSObject { set { self.state.withLock { self.state.isKeyPressed = newValue } } } + private nonisolated var isPromptModeKeyPressed: Bool { + get { self.state.withLock { self.state.isPromptModeKeyPressed } } + set { self.state.withLock { self.state.isPromptModeKeyPressed = newValue } } + } + private nonisolated var isCommandModeKeyPressed: Bool { get { self.state.withLock { self.state.isCommandModeKeyPressed } } set { self.state.withLock { self.state.isCommandModeKeyPressed = newValue } } @@ -62,7 +73,7 @@ final class GlobalHotkeyManager: NSObject { set { self.state.withLock { self.state.isRewriteKeyPressed = newValue } } } - // Modifier-only shortcut tracking: detect if another key was pressed during modifier hold + /// Modifier-only shortcut tracking: detect if another key was pressed during modifier hold private nonisolated var modifierOnlyKeyDown: Bool { get { self.state.withLock { self.state.modifierOnlyKeyDown } } set { self.state.withLock { self.state.modifierOnlyKeyDown = newValue } } @@ -73,7 +84,7 @@ final class GlobalHotkeyManager: NSObject { set { self.state.withLock { self.state.otherKeyPressedDuringModifier = newValue } } } - // Reserved for future tap-vs-hold timing detection (e.g., quick tap to toggle vs long hold) + /// Reserved for future tap-vs-hold timing detection (e.g., quick tap to toggle vs long hold) private nonisolated var modifierPressStartTime: Date? { get { self.state.withLock { self.state.modifierPressStartTime } } set { self.state.withLock { self.state.modifierPressStartTime = newValue } } @@ -84,13 +95,13 @@ final class GlobalHotkeyManager: NSObject { set { self.state.withLock { self.state.pendingHoldModeStart = newValue } } } - // Tracks which mode's pending start is active (for cancellation on key combos) + /// Tracks which mode's pending start is active (for cancellation on key combos) private nonisolated var pendingHoldModeType: HotkeyHoldModeType? { get { self.state.withLock { self.state.pendingHoldModeType } } set { self.state.withLock { self.state.pendingHoldModeType = newValue } } } - // Busy flag to prevent race conditions during stop processing + /// Busy flag to prevent race conditions during stop processing private var isProcessingStop = false private var isInitialized = false @@ -103,31 +114,39 @@ final class GlobalHotkeyManager: NSObject { init( asrService: ASRService, shortcut: HotkeyShortcut, + promptModeShortcut: HotkeyShortcut, commandModeShortcut: HotkeyShortcut, rewriteModeShortcut: HotkeyShortcut, + promptModeShortcutEnabled: Bool, commandModeShortcutEnabled: Bool, rewriteModeShortcutEnabled: Bool, startRecordingCallback: (() async -> Void)? = nil, dictationModeCallback: (() async -> Void)? = nil, stopAndProcessCallback: (() async -> Void)? = nil, + promptModeCallback: (() async -> Void)? = nil, commandModeCallback: (() async -> Void)? = nil, rewriteModeCallback: (() async -> Void)? = nil, isDictateRecordingProvider: (() -> Bool)? = nil, + isPromptModeRecordingProvider: (() -> Bool)? = nil, isCommandRecordingProvider: (() -> Bool)? = nil, isRewriteRecordingProvider: (() -> Bool)? = nil ) { self.asrService = asrService self.shortcut = shortcut + self.promptModeShortcut = promptModeShortcut self.commandModeShortcut = commandModeShortcut self.rewriteModeShortcut = rewriteModeShortcut + self.promptModeShortcutEnabled = promptModeShortcutEnabled self.commandModeShortcutEnabled = commandModeShortcutEnabled self.rewriteModeShortcutEnabled = rewriteModeShortcutEnabled self.startRecordingCallback = startRecordingCallback self.dictationModeCallback = dictationModeCallback self.stopAndProcessCallback = stopAndProcessCallback + self.promptModeCallback = promptModeCallback self.commandModeCallback = commandModeCallback self.rewriteModeCallback = rewriteModeCallback self.isDictateRecordingProvider = isDictateRecordingProvider + self.isPromptModeRecordingProvider = isPromptModeRecordingProvider self.isCommandRecordingProvider = isCommandRecordingProvider self.isRewriteRecordingProvider = isRewriteRecordingProvider super.init() @@ -196,6 +215,26 @@ final class GlobalHotkeyManager: NSObject { ) } + func setPromptModeCallback(_ callback: @escaping () async -> Void) { + self.promptModeCallback = callback + } + + func updatePromptModeShortcut(_ newShortcut: HotkeyShortcut) { + self.promptModeShortcut = newShortcut + DebugLogger.shared.info("Updated prompt mode hotkey", source: "GlobalHotkeyManager") + } + + func updatePromptModeShortcutEnabled(_ enabled: Bool) { + self.promptModeShortcutEnabled = enabled + if !enabled { + self.isPromptModeKeyPressed = false + } + DebugLogger.shared.info( + "Prompt mode shortcut \(enabled ? "enabled" : "disabled")", + source: "GlobalHotkeyManager" + ) + } + func setCancelCallback(_ callback: @escaping () -> Bool) { self.cancelCallback = callback } @@ -366,6 +405,9 @@ final class GlobalHotkeyManager: NSObject { } } + // Check prompt mode hotkey + if self.handlePromptModeKeyDown(keyCode: keyCode, modifiers: eventModifiers) { return nil } + // Check command mode hotkey first if self.commandModeShortcutEnabled, self.matchesCommandModeShortcut(keyCode: keyCode, modifiers: eventModifiers) { if self.pressAndHoldMode { @@ -476,6 +518,9 @@ final class GlobalHotkeyManager: NSObject { } case .keyUp: + // Prompt mode key up (press and hold mode) + if self.handlePromptModeKeyUp(keyCode: keyCode) { return nil } + // Command mode key up (press and hold mode) // Note: Only check keyCode, not modifiers - user may release modifier before/with main key if self.commandModeShortcutEnabled, self.pressAndHoldMode, self.isCommandModeKeyPressed, keyCode == self.commandModeShortcut.keyCode { @@ -509,6 +554,9 @@ final class GlobalHotkeyManager: NSObject { || flags.contains(.maskControl) || flags.contains(.maskShift) + // Check prompt mode shortcut (if it's a modifier-only shortcut) + if self.handlePromptModeFlagsChanged(keyCode: keyCode, isModifierPressed: isModifierPressed) { return nil } + // Check command mode shortcut (if it's a modifier-only shortcut) if self.commandModeShortcutEnabled, self.commandModeShortcut.modifierFlags.isEmpty, keyCode == self.commandModeShortcut.keyCode { if isModifierPressed { @@ -741,6 +789,104 @@ final class GlobalHotkeyManager: NSObject { return Unmanaged.passUnretained(event) } + private func handlePromptModeKeyDown(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { + guard self.promptModeShortcutEnabled, self.matchesPromptModeShortcut(keyCode: keyCode, modifiers: modifiers) else { return false } + if self.pressAndHoldMode { + if !self.isPromptModeKeyPressed { + self.isPromptModeKeyPressed = true + DebugLogger.shared.info("Prompt mode shortcut pressed (hold mode) - starting", source: "GlobalHotkeyManager") + self.triggerPromptMode() + } + } else { + if self.asrService.isRunning { + if self.isPromptModeRecordingProvider?() ?? false { + DebugLogger.shared.info("Prompt mode shortcut pressed in Prompt mode - stopping", source: "GlobalHotkeyManager") + self.stopRecordingIfNeeded() + } else { + DebugLogger.shared.info("Prompt mode shortcut pressed while recording - switching mode", source: "GlobalHotkeyManager") + self.triggerPromptMode() + } + } else { + DebugLogger.shared.info("Prompt mode shortcut triggered - starting", source: "GlobalHotkeyManager") + self.triggerPromptMode() + } + } + return true + } + + private func handlePromptModeKeyUp(keyCode: UInt16) -> Bool { + guard self.promptModeShortcutEnabled, self.pressAndHoldMode, + self.isPromptModeKeyPressed, keyCode == self.promptModeShortcut.keyCode else { return false } + self.isPromptModeKeyPressed = false + DebugLogger.shared.info("Prompt mode shortcut released (hold mode) - stopping", source: "GlobalHotkeyManager") + self.stopRecordingIfNeeded() + return true + } + + private func handlePromptModeFlagsChanged(keyCode: UInt16, isModifierPressed: Bool) -> Bool { + guard self.promptModeShortcutEnabled, self.promptModeShortcut.modifierFlags.isEmpty, + keyCode == self.promptModeShortcut.keyCode else { return false } + if isModifierPressed { + self.modifierOnlyKeyDown = true + self.otherKeyPressedDuringModifier = false + self.modifierPressStartTime = Date() + if self.pressAndHoldMode, !self.isPromptModeKeyPressed { + self.isPromptModeKeyPressed = true + self.pendingHoldModeStart?.cancel() + self.pendingHoldModeType = .promptMode + self.pendingHoldModeStart = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 150_000_000) + guard let self = self, !Task.isCancelled else { return } + guard self.isPromptModeKeyPressed, !self.otherKeyPressedDuringModifier else { + DebugLogger.shared.debug("Prompt mode hold start cancelled - key combo detected", source: "GlobalHotkeyManager") + return + } + DebugLogger.shared.info("Prompt mode modifier held (hold mode) - starting after delay", source: "GlobalHotkeyManager") + self.triggerPromptMode() + } + } + } else { + let wasCleanPress = !self.otherKeyPressedDuringModifier + self.modifierOnlyKeyDown = false + self.otherKeyPressedDuringModifier = false + self.modifierPressStartTime = nil + if self.pressAndHoldMode { + self.pendingHoldModeStart?.cancel() + self.pendingHoldModeStart = nil + self.pendingHoldModeType = nil + if self.isPromptModeKeyPressed { + self.isPromptModeKeyPressed = false + if self.asrService.isRunning { + DebugLogger.shared.info("Prompt mode modifier released (hold mode) - stopping", source: "GlobalHotkeyManager") + self.stopRecordingIfNeeded() + } + } + } else if wasCleanPress { + if self.asrService.isRunning { + if self.isPromptModeRecordingProvider?() ?? false { + DebugLogger.shared.info("Prompt mode modifier released (toggle, same mode) - stopping", source: "GlobalHotkeyManager") + self.stopRecordingIfNeeded() + } else { + DebugLogger.shared.info("Prompt mode modifier released (toggle, switch mode) - switching", source: "GlobalHotkeyManager") + self.triggerPromptMode() + } + } else { + DebugLogger.shared.info("Prompt mode modifier released (toggle) - starting", source: "GlobalHotkeyManager") + self.triggerPromptMode() + } + } + } + return true + } + + private func triggerPromptMode() { + Task { @MainActor [weak self] in + guard let self = self else { return } + DebugLogger.shared.info("Prompt mode hotkey triggered", source: "GlobalHotkeyManager") + await self.promptModeCallback?() + } + } + private func triggerCommandMode() { Task { @MainActor [weak self] in guard let self = self else { return } @@ -899,6 +1045,12 @@ final class GlobalHotkeyManager: NSObject { return keyCode == self.shortcut.keyCode && relevantModifiers == shortcutModifiers } + private func matchesPromptModeShortcut(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { + let relevantModifiers: NSEvent.ModifierFlags = modifiers.intersection([.function, .command, .option, .control, .shift]) + let shortcutModifiers = self.promptModeShortcut.modifierFlags.intersection([.function, .command, .option, .control, .shift]) + return keyCode == self.promptModeShortcut.keyCode && relevantModifiers == shortcutModifiers + } + private func matchesCommandModeShortcut(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { let relevantModifiers: NSEvent.ModifierFlags = modifiers.intersection([.function, .command, .option, .control, .shift]) let shortcutModifiers = self.commandModeShortcut.modifierFlags.intersection([.function, .command, .option, .control, .shift]) diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index 8c05de55..37dbf7e0 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -16,7 +16,6 @@ final class MenuBarManager: ObservableObject { // Cached menu items to avoid rebuilding entire menu private var statusMenuItem: NSMenuItem? - private var aiMenuItem: NSMenuItem? private var rollbackMenuItem: NSMenuItem? // References to app state @@ -32,7 +31,6 @@ final class MenuBarManager: ObservableObject { private var isProcessingActive: Bool = false @Published var isRecording: Bool = false - @Published var aiProcessingEnabled: Bool = false /// One-shot navigation requests from the menu bar into the main window UI. /// `ContentView` consumes this and clears it. @@ -50,15 +48,6 @@ final class MenuBarManager: ObservableObject { init() { // Don't setup menu bar immediately - defer until app is ready - // Initialize from persisted setting - self.aiProcessingEnabled = SettingsStore.shared.enableAIProcessing - // Reflect changes to menu when toggled from elsewhere (e.g., General tab) - self.$aiProcessingEnabled - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateMenu() - } - .store(in: &self.cancellables) } func initializeMenuBar() { @@ -102,9 +91,6 @@ final class MenuBarManager: ObservableObject { } } .store(in: &self.cancellables) - - // Subscribe to AI processing state - self.aiProcessingEnabled = SettingsStore.shared.enableAIProcessing } private func handleOverlayState(isRunning: Bool, asrService: ASRService) { @@ -322,15 +308,6 @@ final class MenuBarManager: ObservableObject { menu.addItem(.separator()) - // AI Processing Toggle - self.aiMenuItem = NSMenuItem(title: "", action: #selector(self.toggleAIProcessing), keyEquivalent: "") - self.aiMenuItem?.target = self - if let aiItem = aiMenuItem { - menu.addItem(aiItem) - } - - menu.addItem(.separator()) - // Open Main Window let openItem = NSMenuItem(title: "Open Fluid Voice", action: #selector(openMainWindow), keyEquivalent: "") openItem.target = self @@ -395,39 +372,10 @@ final class MenuBarManager: ObservableObject { let statusTitle = self.isRecording ? "Recording...\(hotkeyInfo)" : "Ready to Record\(hotkeyInfo)" self.statusMenuItem?.title = statusTitle - // Update AI toggle text - let aiTitle = self.aiProcessingEnabled ? "Disable AI Processing" : "Enable AI Processing" - self.aiMenuItem?.title = aiTitle - // Update rollback availability text self.rollbackMenuItem?.isEnabled = SimpleUpdater.shared.hasRollbackBackup() } - /// Centralized entry point to update AI post-processing enablement. - /// Use this instead of writing `aiProcessingEnabled` directly so all state stays in sync. - func setAIProcessingEnabled(_ enabled: Bool) { - guard self.aiProcessingEnabled != enabled else { - // Ensure menu text stays correct even if caller repeats the same value. - self.updateMenu() - return - } - self.aiProcessingEnabled = enabled - SettingsStore.shared.enableAIProcessing = enabled - self.updateMenu() - } - - /// Toggle AI post-processing and return the new value. - @discardableResult - func toggleAIProcessingEnabled() -> Bool { - let next = !self.aiProcessingEnabled - self.setAIProcessingEnabled(next) - return next - } - - @objc private func toggleAIProcessing() { - _ = self.toggleAIProcessingEnabled() - } - @objc private func checkForUpdates(_ sender: Any?) { DebugLogger.shared.info("🔎 Menu action: Check for Updates…", source: "MenuBarManager") diff --git a/Sources/Fluid/Services/NotchOverlayManager.swift b/Sources/Fluid/Services/NotchOverlayManager.swift index 5f0c355d..68dabe99 100644 --- a/Sources/Fluid/Services/NotchOverlayManager.swift +++ b/Sources/Fluid/Services/NotchOverlayManager.swift @@ -32,13 +32,13 @@ final class NotchOverlayManager { >? private var currentMode: OverlayMode = .dictation - // Store last audio publisher for re-showing during processing + /// Store last audio publisher for re-showing during processing private var lastAudioPublisher: AnyPublisher? - // Current audio publisher (can be updated for expanded notch recording) + /// Current audio publisher (can be updated for expanded notch recording) @Published private(set) var currentAudioPublisher: AnyPublisher? - // State machine to prevent race conditions + /// State machine to prevent race conditions private enum State { case idle case showing @@ -49,10 +49,10 @@ final class NotchOverlayManager { private var state: State = .idle private var commandOutputState: State = .idle - // Track if expanded command output is showing + /// Track if expanded command output is showing private(set) var isCommandOutputExpanded: Bool = false - // Track if bottom overlay is visible + /// Track if bottom overlay is visible private(set) var isBottomOverlayVisible: Bool = false // Callbacks for command output interaction @@ -70,7 +70,7 @@ final class NotchOverlayManager { private var generation: UInt64 = 0 private var commandOutputGeneration: UInt64 = 0 - // Track pending retry task for cancellation + /// Track pending retry task for cancellation private var pendingRetryTask: Task? // Escape key monitors for dismissing notch diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 6b84d60e..44a8d00a 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -13,14 +13,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { @Published var appear: Bool = false @Published var openAIBaseURL: String - @Published var enableAIProcessing: Bool { - didSet { - guard self.enableAIProcessing != oldValue else { return } - // Route through MenuBarManager so all UI surfaces (menu, settings, future overlay) - // share one callable code path. - self.menuBarManager.setAIProcessingEnabled(self.enableAIProcessing) - } - } + @Published var isDictationPromptOff: Bool = false // Model Management @Published var availableModelsByProvider: [String: [String]] = [:] @@ -129,7 +122,6 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.menuBarManager = menuBarManager self.promptTest = promptTest self.openAIBaseURL = ModelRepository.shared.defaultBaseURL(for: "openai") - self.enableAIProcessing = settings.enableAIProcessing self.selectedProviderID = settings.selectedProviderID } @@ -143,7 +135,6 @@ final class AIEnhancementSettingsViewModel: ObservableObject { func loadSettings() { self.selectedProviderID = self.settings.selectedProviderID - self.enableAIProcessing = self.settings.enableAIProcessing self.availableModelsByProvider = self.settings.availableModelsByProvider self.selectedModelByProvider = self.settings.selectedModelByProvider self.appleIntelligenceAvailable = AppleIntelligenceService.isAvailable @@ -153,6 +144,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.appPromptBindings = self.settings.appPromptBindings self.selectedDictationPromptID = self.settings.selectedDictationPromptID self.selectedEditPromptID = self.settings.selectedEditPromptID + self.isDictationPromptOff = self.settings.isDictationPromptOff // Normalize provider keys var normalized: [String: [String]] = [:] @@ -1276,7 +1268,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } func isAIPostProcessingConfiguredForDictation() -> Bool { - DictationAIPostProcessingGate.isConfigured() + DictationAIPostProcessingGate.isProviderConfigured() } func openDefaultPromptViewer(for mode: SettingsStore.PromptMode) { @@ -1502,6 +1494,17 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return trimmed.isEmpty ? "Untitled Prompt" : trimmed } + func isPromptSelectionOff(for mode: SettingsStore.PromptMode) -> Bool { + mode.normalized == .dictate && self.settings.isDictationPromptOff + } + + func selectPromptOff(for mode: SettingsStore.PromptMode) { + guard mode.normalized == .dictate else { return } + self.settings.setDictationPromptSelection(.off) + self.selectedDictationPromptID = self.settings.selectedDictationPromptID + self.isDictationPromptOff = self.settings.isDictationPromptOff + } + private func resolveBindingTargetApp() -> (name: String, bundleID: String)? { if let pid = NotchContentState.shared.recordingTargetPID, let app = NSRunningApplication(processIdentifier: pid), @@ -1582,9 +1585,18 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } func setSelectedPromptID(_ id: String?, for mode: SettingsStore.PromptMode) { - self.settings.setSelectedPromptID(id, for: mode.normalized) + if mode.normalized == .dictate { + if let id { + self.settings.setDictationPromptSelection(.profile(id)) + } else { + self.settings.setDictationPromptSelection(.default) + } + } else { + self.settings.setSelectedPromptID(id, for: mode.normalized) + } self.selectedDictationPromptID = self.settings.selectedDictationPromptID self.selectedEditPromptID = self.settings.selectedEditPromptID + self.isDictationPromptOff = self.settings.isDictationPromptOff } func hasDefaultPromptOverride(for mode: SettingsStore.PromptMode) -> Bool { diff --git a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift index 2a262c01..8859d76d 100644 --- a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift +++ b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift @@ -67,76 +67,68 @@ extension AIEnhancementSettingsView { Image(systemName: "brain") .font(.title3) .foregroundStyle(self.theme.palette.accent) - Text("AI Enhancement") + Text("AI Setup") .font(.title3) .fontWeight(.semibold) } Spacer() - Toggle("", isOn: self.$viewModel.enableAIProcessing) - .toggleStyle(.switch) - .labelsHidden() - .tint(self.theme.palette.accent) } - if self.viewModel.enableAIProcessing { - Divider() - .background(self.theme.palette.separator.opacity(0.5)) + Divider() + .background(self.theme.palette.separator.opacity(0.5)) + + VStack(alignment: .leading, spacing: 8) { + Text("Choose a provider, model, and dictation prompt. Select `Off` in Dictate prompts for raw transcription.") + .font(.caption) + .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 8) { - Text("FluidVoice can clean up or rewrite your transcription using AI.") + self.aiOnboardingInfoRow("Local models run on your Mac. Examples: Ollama and LM Studio.") + self.aiOnboardingInfoRow("Cloud models use a provider of your choice. Examples: OpenAI, Anthropic, Groq, and OpenRouter.") + self.aiOnboardingInfoRow("Dictation uses AI when Dictate prompt is `Default` or a custom prompt.") + } + + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Providers") + .font(.system(size: 14, weight: .semibold)) + Text("Configure your AI provider") .font(.caption) .foregroundStyle(.secondary) - - self.aiOnboardingInfoRow("Local models run on your Mac. Examples: Ollama and LM Studio.") - self.aiOnboardingInfoRow("Cloud models use a provider of your choice. Examples: OpenAI, Anthropic, Groq, and OpenRouter.") - self.aiOnboardingInfoRow("If you do not want to choose right now, FluidVoice still works without AI.") } - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text("Providers") - .font(.system(size: 14, weight: .semibold)) - Text("Configure your AI provider") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() + Spacer() - Button(action: { self.viewModel.showHelp.toggle() }) { - HStack(spacing: 5) { - Image(systemName: self.viewModel.showHelp ? "questionmark.circle.fill" : "questionmark.circle") - .font(.system(size: 14)) - Text("Help") - .font(.caption) - .fontWeight(.medium) - } - .foregroundStyle(self.viewModel.showHelp ? self.theme.palette.accent : .secondary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - Capsule() - .fill(self.viewModel.showHelp ? self.theme.palette.accent.opacity(0.12) : self.theme.palette.cardBackground.opacity(0.8)) - .overlay( - Capsule() - .stroke(self.viewModel.showHelp ? self.theme.palette.accent.opacity(0.3) : self.theme.palette.cardBorder.opacity(0.4), lineWidth: 1) - ) - ) + Button(action: { self.viewModel.showHelp.toggle() }) { + HStack(spacing: 5) { + Image(systemName: self.viewModel.showHelp ? "questionmark.circle.fill" : "questionmark.circle") + .font(.system(size: 14)) + Text("Help") + .font(.caption) + .fontWeight(.medium) } - .buttonStyle(.plain) + .foregroundStyle(self.viewModel.showHelp ? self.theme.palette.accent : .secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .fill(self.viewModel.showHelp ? self.theme.palette.accent.opacity(0.12) : self.theme.palette.cardBackground.opacity(0.8)) + .overlay( + Capsule() + .stroke(self.viewModel.showHelp ? self.theme.palette.accent.opacity(0.3) : self.theme.palette.cardBorder.opacity(0.4), lineWidth: 1) + ) + ) } + .buttonStyle(.plain) + } - if self.viewModel.showHelp { self.helpSectionView } + if self.viewModel.showHelp { self.helpSectionView } - self.providerStepContent + self.providerStepContent - Divider() - .background(self.theme.palette.separator.opacity(0.5)) + Divider() + .background(self.theme.palette.separator.opacity(0.5)) - self.promptsStepContent - } else { - self.setupDisabledInfo - } + self.promptsStepContent } .padding(16) } @@ -171,12 +163,11 @@ extension AIEnhancementSettingsView { } VStack(alignment: .leading, spacing: 8) { - self.helpStep("1", "Enable AI enhancement", "power") - self.helpStep("2", "Choose a provider", "building.2") - self.helpStep("3", "Add an API key if needed", "key") - self.helpStep("4", "Pick the model you want", "cpu") - self.helpStep("5", "Verify the connection", "checkmark.shield") - self.helpStep("6", "Customize your prompt", "text.bubble") + self.helpStep("1", "Choose a provider", "building.2") + self.helpStep("2", "Add an API key if needed", "key") + self.helpStep("3", "Pick the model you want", "cpu") + self.helpStep("4", "Verify the connection", "checkmark.shield") + self.helpStep("5", "Set Dictate to Off, Default, or a custom prompt", "text.bubble") } } .padding(14) @@ -212,35 +203,6 @@ extension AIEnhancementSettingsView { } } - var setupDisabledInfo: some View { - HStack(spacing: 12) { - Image(systemName: "sparkles") - .font(.system(size: 24)) - .foregroundStyle(self.theme.palette.accent.opacity(0.5)) - - VStack(alignment: .leading, spacing: 4) { - Text("AI Enhancement Disabled") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - Text("Turn this on to enable AI-powered cleanup. We'll guide you through setup in a few short steps.") - .font(.caption) - .foregroundStyle(.tertiary) - .lineLimit(2) - } - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(self.theme.palette.contentBackground.opacity(0.5)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(self.theme.palette.cardBorder.opacity(0.25), lineWidth: 1) - ) - ) - .padding(.top, 4) - } - var providerStepContent: some View { VStack(alignment: .leading, spacing: 16) { self.verifiedProvidersSection diff --git a/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift b/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift index e6f731fc..5ccb2350 100644 --- a/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift +++ b/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift @@ -65,7 +65,7 @@ extension AIEnhancementSettingsView { mode: SettingsStore.PromptMode, isSelected: Bool, onUse: @escaping () -> Void, - onManage: @escaping () -> Void, + onManage: (() -> Void)? = nil, onResetDefault: (() -> Void)? = nil, canResetDefault: Bool = false, onDelete: (() -> Void)? = nil @@ -116,29 +116,33 @@ extension AIEnhancementSettingsView { .foregroundStyle(isSelected ? tone : self.theme.palette.secondaryText.opacity(0.35)) .frame(width: 18, height: 18) - Menu { - Button("Edit Prompt") { onManage() } - if mode == .edit { - Divider() - Text("Selected text context is added automatically when text is selected.") - } - if let onDelete { - Divider() - Button(role: .destructive, action: { onDelete() }) { - Label("Delete Prompt", systemImage: "trash") + if onManage != nil || onResetDefault != nil || onDelete != nil { + Menu { + if let onManage { + Button("Edit Prompt") { onManage() } } - } else if let onResetDefault { - Divider() - Button("Reset to Built-in Default", role: .destructive) { onResetDefault() } - .disabled(!canResetDefault) + if mode == .edit { + Divider() + Text("Selected text context is added automatically when text is selected.") + } + if let onDelete { + Divider() + Button(role: .destructive, action: { onDelete() }) { + Label("Delete Prompt", systemImage: "trash") + } + } else if let onResetDefault { + Divider() + Button("Reset to Built-in Default", role: .destructive) { onResetDefault() } + .disabled(!canResetDefault) + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 14, weight: .semibold)) + .frame(width: AISettingsLayout.controlHeight, height: AISettingsLayout.controlHeight) } - } label: { - Image(systemName: "ellipsis.circle") - .font(.system(size: 14, weight: .semibold)) - .frame(width: AISettingsLayout.controlHeight, height: AISettingsLayout.controlHeight) + .buttonStyle(.plain) + .foregroundStyle(self.theme.palette.secondaryText) } - .buttonStyle(.plain) - .foregroundStyle(self.theme.palette.secondaryText) } } .padding(12) @@ -194,12 +198,25 @@ extension AIEnhancementSettingsView { self.editModeProviderModelRow } + if mode.normalized == .dictate { + self.promptProfileCard( + cardKey: "\(mode.normalized.rawValue)-off", + title: "Off", + subtitle: "Use raw transcription for normal dictation with no AI post-processing.", + mode: mode, + isSelected: self.viewModel.isPromptSelectionOff(for: mode), + onUse: { + self.viewModel.selectPromptOff(for: mode) + } + ) + } + self.promptProfileCard( cardKey: "\(mode.normalized.rawValue)-default", title: "Default \(self.friendlyModeName(mode))", subtitle: self.viewModel.promptPreview(self.viewModel.defaultPromptBodyPreview(for: mode)), mode: mode, - isSelected: self.viewModel.selectedPromptID(for: mode) == nil, + isSelected: !self.viewModel.isPromptSelectionOff(for: mode) && self.viewModel.selectedPromptID(for: mode) == nil, onUse: { self.viewModel.setSelectedPromptID(nil, for: mode) }, @@ -901,9 +918,6 @@ extension AIEnhancementSettingsView { .onDisappear { self.promptTest.deactivate() } - .onChange(of: self.viewModel.enableAIProcessing) { _, _ in - self.autoDisablePromptTestIfNeeded() - } .onChange(of: self.viewModel.selectedProviderID) { _, _ in self.autoDisablePromptTestIfNeeded() } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 163134ef..124d25fe 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -11,7 +11,10 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var appServices: AppServices - private var asr: ASRService { self.appServices.asr } + private var asr: ASRService { + self.appServices.asr + } + @Environment(\.theme) private var theme @ObservedObject private var settings = SettingsStore.shared @Binding var appear: Bool @@ -23,6 +26,9 @@ struct SettingsView: View { @Binding var accessibilityEnabled: Bool @Binding var hotkeyShortcut: HotkeyShortcut @Binding var isRecordingShortcut: Bool + @Binding var promptModeShortcut: HotkeyShortcut + @Binding var isRecordingPromptModeShortcut: Bool + @Binding var promptModeShortcutEnabled: Bool @Binding var commandModeShortcut: HotkeyShortcut @Binding var isRecordingCommandModeShortcut: Bool @Binding var rewriteShortcut: HotkeyShortcut @@ -449,11 +455,13 @@ struct SettingsView: View { .frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 2) { - Text(self.asr.micStatus == .authorized ? "Microphone access granted" : - self.asr.micStatus == .denied ? "Microphone access denied" : - "Microphone access not determined") - .font(.body) - .foregroundStyle(self.asr.micStatus == .authorized ? .primary : self.theme.palette.warning) + Text( + self.asr.micStatus == .authorized ? "Microphone access granted" : + self.asr.micStatus == .denied ? "Microphone access denied" : + "Microphone access not determined" + ) + .font(.body) + .foregroundStyle(self.asr.micStatus == .authorized ? .primary : self.theme.palette.warning) if self.asr.micStatus != .authorized { Text("Microphone access is required for voice recording") @@ -570,6 +578,54 @@ struct SettingsView: View { ) Divider().opacity(0.2).padding(.vertical, 4) + self.shortcutRow( + icon: "text.bubble.fill", + iconColor: .secondary, + title: "Transcribe with Prompt", + description: "Dictate with a specific AI prompt", + shortcut: self.promptModeShortcut, + isRecording: self.isRecordingPromptModeShortcut, + isEnabled: self.$promptModeShortcutEnabled, + onChangePressed: { + DebugLogger.shared.debug("Starting to record new prompt mode shortcut", source: "SettingsView") + self.isRecordingPromptModeShortcut = true + } + ) + + if self.promptModeShortcutEnabled { + let profiles = self.settings.promptProfiles(for: .dictate) + if !profiles.isEmpty { + HStack { + Text("Prompt") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.leading, 30) + Spacer() + Picker("", selection: Binding( + get: { self.settings.promptModeSelectedPromptID ?? "" }, + set: { newValue in + self.settings.promptModeSelectedPromptID = newValue.isEmpty ? nil : newValue + } + )) { + Text("Select a prompt...").tag("") + ForEach(profiles) { profile in + Text(profile.name.isEmpty ? "Untitled" : profile.name).tag(profile.id) + } + } + .frame(width: 170) + } + .padding(.bottom, 4) + } else { + Text("Add prompts in AI Enhancements → Prompt Profiles") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 30) + .padding(.bottom, 4) + } + } + + Divider().opacity(0.2).padding(.vertical, 4) + self.shortcutRow( icon: "terminal.fill", iconColor: .secondary, @@ -1346,7 +1402,6 @@ struct SettingsView: View { // MARK: - Helper Views - @ViewBuilder private func settingsToggleRow( title: String, description: String, @@ -1379,7 +1434,6 @@ struct SettingsView: View { } } - @ViewBuilder private func optionToggleRow( title: String, description: String, @@ -1403,7 +1457,6 @@ struct SettingsView: View { } } - @ViewBuilder private func instructionsBox( title: String, steps: [String], @@ -1489,17 +1542,23 @@ struct SettingsView: View { .foregroundStyle(.orange) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(.orange.opacity(0.2))) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(.orange.opacity(0.2)) + ) } else { Text(shortcut.displayString) .font(.caption.monospaced().weight(.medium)) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(.quaternary.opacity(0.5)) - .overlay(RoundedRectangle(cornerRadius: 5, style: .continuous) - .stroke(.primary.opacity(0.15), lineWidth: 1))) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(.quaternary.opacity(0.5)) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(.primary.opacity(0.15), lineWidth: 1) + ) + ) } Button("Change") { @@ -1543,8 +1602,10 @@ struct FillerWordsEditor: View { } .padding(.horizontal, 8) .padding(.vertical, 4) - .background(RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(.quaternary)) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(.quaternary) + ) } } diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index 89b10b36..c211902f 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -1197,6 +1197,7 @@ private struct BottomOverlayModeMenuView: View { private struct BottomOverlayPromptMenuView: View { @ObservedObject private var settings = SettingsStore.shared + @ObservedObject private var contentState = NotchContentState.shared let promptMode: SettingsStore.PromptMode let maxWidth: CGFloat @@ -1232,11 +1233,47 @@ private struct BottomOverlayPromptMenuView: View { ) } + @ViewBuilder + private func offRow() -> some View { + let isSelected = self.settings.isDictationPromptOff + Button(action: { + if self.contentState.isPromptModeActive, self.promptMode.normalized == .dictate { + self.contentState.onPromptModeProfileChangeRequested?(nil) + } else { + self.settings.setDictationPromptSelection(.off) + } + self.restoreTypingTargetApp() + self.onDismissRequested() + }) { + HStack { + Text("Off") + Spacer() + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .semibold)) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(self.rowBackground(isSelected: isSelected, rowID: "off")) + } + .buttonStyle(.plain) + .onHover { hovering in + self.hoveredRowID = hovering ? "off" : nil + } + } + @ViewBuilder private func defaultRow(selectedID: String?) -> some View { - let isSelected = selectedID == nil + let isSelected = !self.settings.isDictationPromptOff && selectedID == nil Button(action: { - self.settings.setSelectedPromptID(nil, for: self.promptMode) + if self.contentState.isPromptModeActive, self.promptMode.normalized == .dictate { + self.contentState.onPromptModeProfileChangeRequested?(nil) + } else if self.promptMode.normalized == .dictate { + self.settings.setDictationPromptSelection(.default) + } else { + self.settings.setSelectedPromptID(nil, for: self.promptMode) + } self.restoreTypingTargetApp() self.onDismissRequested() }) { @@ -1262,7 +1299,11 @@ private struct BottomOverlayPromptMenuView: View { private func profileRow(_ profile: SettingsStore.DictationPromptProfile, selectedID: String?) -> some View { let isSelected = selectedID == profile.id Button(action: { - self.settings.setSelectedPromptID(profile.id, for: self.promptMode) + if self.contentState.isPromptModeActive, self.promptMode.normalized == .dictate { + self.contentState.onPromptModeProfileChangeRequested?(profile) + } else { + self.settings.setSelectedPromptID(profile.id, for: self.promptMode) + } self.restoreTypingTargetApp() self.onDismissRequested() }) { @@ -1289,6 +1330,13 @@ private struct BottomOverlayPromptMenuView: View { let profiles = self.settings.promptProfiles(for: self.promptMode) VStack(alignment: .leading, spacing: 0) { + if self.promptMode.normalized == .dictate { + self.offRow() + + Divider() + .padding(.vertical, 4) + } + self.defaultRow(selectedID: selectedID) if !profiles.isEmpty { @@ -1384,7 +1432,6 @@ private struct BottomOverlayActionsMenuView: View { ) } - @ViewBuilder private func actionRow( title: String, icon: String, @@ -1573,7 +1620,6 @@ struct BottomOverlayView: View { @Environment(\.theme) private var theme @State private var isHoveringModeChip = false @State private var isHoveringPromptChip = false - @State private var isHoveringAIToggleChip = false @State private var isHoveringActionsChip = false @State private var isHoveringSettingsChip = false @State private var modeSelectorFrameInScreen: CGRect = .zero @@ -1715,8 +1761,8 @@ struct BottomOverlayView: View { "Working...", ] - // ContentView writes transient status strings into transcriptionText while processing - // (e.g. "Transcribing...", "Refining..."). Prefer that when present. + /// ContentView writes transient status strings into transcriptionText while processing + /// (e.g. "Transcribing...", "Refining..."). Prefer that when present. private var processingStatusText: String { let t = self.contentState.transcriptionText.trimmingCharacters(in: .whitespacesAndNewlines) return t.isEmpty ? self.processingLabel : t @@ -1758,6 +1804,12 @@ struct BottomOverlayView: View { private var isAppPromptOverrideActive: Bool { guard let activePromptMode else { return false } + if activePromptMode.normalized == .dictate && + self.settings.isDictationPromptOff && + !self.contentState.isPromptModeActive + { + return false + } return self.settings.hasAppPromptBinding( for: activePromptMode, appBundleID: self.promptResolutionBundleID @@ -1765,7 +1817,16 @@ struct BottomOverlayView: View { } private var selectedPromptLabel: String { + if let overrideName = self.contentState.promptModeOverrideProfileName { + return overrideName + } guard let activePromptMode else { return "N/A" } + if activePromptMode.normalized == .dictate && + self.settings.isDictationPromptOff && + !self.contentState.isPromptModeActive + { + return "Off" + } if let profile = self.settings.resolvedPromptProfile( for: activePromptMode, appBundleID: self.promptResolutionBundleID @@ -2122,53 +2183,6 @@ struct BottomOverlayView: View { ) } - private var aiToggleChip: some View { - let disabled = self.contentState.isProcessing - let isEnabled = self.settings.enableAIProcessing - return HStack(spacing: self.isCompactControls ? 0 : 5) { - if self.isCompactControls { - Text(isEnabled ? "AI On" : "AI Off") - .font(.system(size: self.promptSelectorFontSize, weight: .semibold)) - .foregroundStyle(isEnabled ? .white.opacity(0.82) : .white.opacity(0.7)) - .lineLimit(1) - } else { - Text("AI:") - .font(.system(size: self.promptSelectorFontSize, weight: .medium)) - .foregroundStyle(.white.opacity(0.5)) - .lineLimit(1) - Text(isEnabled ? "On" : "Off") - .font(.system(size: self.promptSelectorFontSize, weight: .semibold)) - .foregroundStyle(isEnabled ? .white.opacity(0.82) : .white.opacity(0.7)) - .lineLimit(1) - Image(systemName: isEnabled ? "brain.fill" : "brain") - .font(.system(size: max(self.promptSelectorFontSize - 1, 8), weight: .semibold)) - .foregroundStyle(isEnabled ? .white.opacity(0.65) : .white.opacity(0.45)) - } - } - .fixedSize(horizontal: true, vertical: false) - .padding(.horizontal, 8) - .padding(.vertical, self.promptSelectorVerticalPadding) - .background( - self.chipBackground( - isHovered: self.isHoveringAIToggleChip, - disabled: disabled - ) - ) - .contentShape(Rectangle()) - .onHover { hovering in - self.isHoveringAIToggleChip = hovering && !disabled - } - .onTapGesture { - guard !disabled else { return } - self.closePromptMenu() - self.closeModeMenu() - self.closeActionsMenu() - self.contentState.onToggleAIProcessingRequested?() - } - .help("Toggle AI enhancement for dictation") - .opacity(disabled ? 0.65 : 1) - } - private var actionsSelectorView: some View { let actionsDisabled = self.historyStore.entries.isEmpty || self.contentState.isProcessing return self.actionsSelectorTrigger @@ -2237,7 +2251,6 @@ struct BottomOverlayView: View { self.modeSelectorView self.promptSelectorView Spacer(minLength: 4) - self.aiToggleChip self.actionsSelectorView if !self.isCompactControls { self.settingsChip @@ -2454,7 +2467,6 @@ struct BottomOverlayView: View { self.closeActionsMenu() self.isHoveringModeChip = false self.isHoveringPromptChip = false - self.isHoveringAIToggleChip = false self.isHoveringActionsChip = false self.isHoveringSettingsChip = false switch self.contentState.mode { @@ -2474,7 +2486,6 @@ struct BottomOverlayView: View { } self.isHoveringModeChip = false self.isHoveringPromptChip = false - self.isHoveringAIToggleChip = false self.isHoveringActionsChip = false self.isHoveringSettingsChip = false if !self.layout.usesFixedCanvas { @@ -2487,7 +2498,6 @@ struct BottomOverlayView: View { self.closeActionsMenu() self.isHoveringModeChip = false self.isHoveringPromptChip = false - self.isHoveringAIToggleChip = false self.isHoveringActionsChip = false self.isHoveringSettingsChip = false } @@ -2515,11 +2525,25 @@ struct BottomWaveformView: View { @State private var barHeights: [CGFloat] = Array(repeating: 6, count: 11) @State private var noiseThreshold: CGFloat = .init(SettingsStore.shared.visualizerNoiseThreshold) - private var barCount: Int { self.layout.barCount } - private var barWidth: CGFloat { self.layout.barWidth } - private var barSpacing: CGFloat { self.layout.barSpacing } - private var minHeight: CGFloat { self.layout.minBarHeight } - private var maxHeight: CGFloat { self.layout.maxBarHeight } + private var barCount: Int { + self.layout.barCount + } + + private var barWidth: CGFloat { + self.layout.barWidth + } + + private var barSpacing: CGFloat { + self.layout.barSpacing + } + + private var minHeight: CGFloat { + self.layout.minBarHeight + } + + private var maxHeight: CGFloat { + self.layout.maxBarHeight + } private var currentGlowIntensity: CGFloat { self.contentState.isProcessing ? 0.0 : 0.5 diff --git a/Sources/Fluid/Views/NotchContentViews.swift b/Sources/Fluid/Views/NotchContentViews.swift index 34b04ff2..e097ad2c 100644 --- a/Sources/Fluid/Views/NotchContentViews.swift +++ b/Sources/Fluid/Views/NotchContentViews.swift @@ -13,22 +13,28 @@ import SwiftUI @MainActor class NotchContentState: ObservableObject { static let shared = NotchContentState() - // Keep overlay state bounded even during very long recordings. + /// Keep overlay state bounded even during very long recordings. private static let maxStoredTranscriptionCharacters = SettingsStore.transcriptionPreviewCharLimitRange.upperBound @Published var transcriptionText: String = "" @Published var mode: OverlayMode = .dictation @Published var promptPickerMode: SettingsStore.PromptMode = .dictate @Published var isProcessing: Bool = false // AI processing state + @Published var promptModeOverrideProfileName: String? = nil // Name shown in overlay when prompt mode hotkey is active + @Published var promptModeOverrideProfileID: String? = nil // ID of the active override profile (for checkmark in menu) + @Published var isPromptModeActive: Bool = false // True for the entire prompt-mode session, even when no profile is selected - // Icon of the target app (where text will be typed) + /// Called when the user picks a different prompt from the overlay during prompt mode recording. + var onPromptModeProfileChangeRequested: ((SettingsStore.DictationPromptProfile?) -> Void)? + + /// Icon of the target app (where text will be typed) @Published var targetAppIcon: NSImage? /// The PID of the app we should restore focus to after interacting with overlays. /// Captured at recording start to keep the target stable for the session. @Published var recordingTargetPID: pid_t? = nil - // Cached transcription preview text to avoid recomputing on every render + /// Cached transcription preview text to avoid recomputing on every render @Published private(set) var cachedPreviewText: String = "" // MARK: - Expanded Command Output State @@ -45,7 +51,7 @@ class NotchContentState: ObservableObject { @Published var recentChats: [ChatSession] = [] @Published var currentChatTitle: String = "New Chat" - // Command output message model + /// Command output message model struct CommandOutputMessage: Identifiable, Equatable { let id = UUID() let role: Role @@ -59,7 +65,7 @@ class NotchContentState: ObservableObject { } } - // Callback for submitting follow-up commands from the notch + /// Callback for submitting follow-up commands from the notch var onSubmitFollowUp: ((String) async -> Void)? private var cancellables = Set() @@ -135,8 +141,6 @@ class NotchContentState: ObservableObject { var onCopyLastRequested: (() -> Void)? /// Called when the user requests undoing AI processing for the latest entry. var onUndoLastAIRequested: (() -> Void)? - /// Called when the user requests toggling dictation AI enhancement. - var onToggleAIProcessingRequested: (() -> Void)? /// Called when the user requests opening Preferences. var onOpenPreferencesRequested: (() -> Void)? @@ -295,8 +299,8 @@ struct NotchExpandedView: View { } } - // ContentView writes transient status strings into transcriptionText while processing - // (e.g. "Transcribing...", "Refining..."). Prefer that when present. + /// ContentView writes transient status strings into transcriptionText while processing + /// (e.g. "Transcribing...", "Refining..."). Prefer that when present. private var processingStatusText: String { let t = self.contentState.transcriptionText.trimmingCharacters(in: .whitespacesAndNewlines) return t.isEmpty ? self.processingLabel : t @@ -306,7 +310,7 @@ struct NotchExpandedView: View { !self.contentState.transcriptionText.isEmpty } - // Check if there's command history that can be expanded + /// Check if there's command history that can be expanded private var canExpandCommandHistory: Bool { self.contentState.mode == .command && !self.contentState.commandConversationHistory.isEmpty } @@ -343,6 +347,12 @@ struct NotchExpandedView: View { private var isAppPromptOverrideActive: Bool { guard let activePromptMode else { return false } + if activePromptMode.normalized == .dictate && + self.settings.isDictationPromptOff && + !self.contentState.isPromptModeActive + { + return false + } return self.settings.hasAppPromptBinding( for: activePromptMode, appBundleID: self.promptResolutionBundleID @@ -350,7 +360,16 @@ struct NotchExpandedView: View { } private var selectedPromptLabel: String { + if let overrideName = self.contentState.promptModeOverrideProfileName { + return overrideName + } guard let activePromptMode else { return "N/A" } + if activePromptMode.normalized == .dictate && + self.settings.isDictationPromptOff && + !self.contentState.isPromptModeActive + { + return "Off" + } if let profile = self.settings.resolvedPromptProfile( for: activePromptMode, appBundleID: self.promptResolutionBundleID @@ -384,10 +403,46 @@ struct NotchExpandedView: View { private func promptMenuContent() -> some View { let promptMode = self.activePromptMode ?? .dictate + // During prompt mode recording, selections update the live override instead of the global prompt. + let isInPromptMode = self.contentState.isPromptModeActive return VStack(alignment: .leading, spacing: 0) { + if promptMode.normalized == .dictate && !isInPromptMode { + Button(action: { + self.settings.setDictationPromptSelection(.off) + let pid = NotchContentState.shared.recordingTargetPID + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + if let pid { _ = TypingService.activateApp(pid: pid) } + } + self.showPromptHoverMenu = false + }) { + HStack { + Text("Off") + Spacer() + let isSelected = !isInPromptMode && self.settings.isDictationPromptOff + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .semibold)) + } + } + } + .buttonStyle(.plain) + .padding(.vertical, 4) + + Divider() + .padding(.vertical, 4) + } + Button(action: { - self.settings.setSelectedPromptID(nil, for: promptMode) + if isInPromptMode { + self.contentState.onPromptModeProfileChangeRequested?(nil) + } else { + if promptMode.normalized == .dictate { + self.settings.setDictationPromptSelection(.default) + } else { + self.settings.setSelectedPromptID(nil, for: promptMode) + } + } let pid = NotchContentState.shared.recordingTargetPID DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { if let pid { _ = TypingService.activateApp(pid: pid) } @@ -397,7 +452,10 @@ struct NotchExpandedView: View { HStack { Text("Default") Spacer() - if self.settings.selectedPromptID(for: promptMode) == nil { + let isSelected = isInPromptMode + ? (self.contentState.promptModeOverrideProfileID == nil) + : (!self.settings.isDictationPromptOff && self.settings.selectedPromptID(for: promptMode) == nil) + if isSelected { Image(systemName: "checkmark") .font(.system(size: 10, weight: .semibold)) } @@ -412,7 +470,11 @@ struct NotchExpandedView: View { ForEach(self.settings.promptProfiles(for: promptMode)) { profile in Button(action: { - self.settings.setSelectedPromptID(profile.id, for: promptMode) + if isInPromptMode { + self.contentState.onPromptModeProfileChangeRequested?(profile) + } else { + self.settings.setSelectedPromptID(profile.id, for: promptMode) + } let pid = NotchContentState.shared.recordingTargetPID DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { if let pid { _ = TypingService.activateApp(pid: pid) } @@ -422,7 +484,10 @@ struct NotchExpandedView: View { HStack { Text(profile.name.isEmpty ? "Untitled" : profile.name) Spacer() - if self.settings.selectedPromptID(for: promptMode) == profile.id { + let isSelected = isInPromptMode + ? (self.contentState.promptModeOverrideProfileID == profile.id) + : (self.settings.selectedPromptID(for: promptMode) == profile.id) + if isSelected { Image(systemName: "checkmark") .font(.system(size: 10, weight: .semibold)) } @@ -433,8 +498,10 @@ struct NotchExpandedView: View { } } } + .font(.system(size: 9, weight: .medium)) .padding(.horizontal, 8) .padding(.vertical, 6) + .foregroundStyle(.white) .background(Color.black) .cornerRadius(8) .overlay( @@ -510,8 +577,9 @@ struct NotchExpandedView: View { .background(Color.white.opacity(0.00)) .cornerRadius(6) .opacity(self.isPromptSelectableMode ? 1.0 : 0.6) - .onHover { hovering in - self.handlePromptHover(hovering) + .onTapGesture { + guard self.isPromptSelectableMode, !self.contentState.isProcessing else { return } + self.showPromptHoverMenu.toggle() } if self.showPromptHoverMenu { @@ -558,6 +626,7 @@ struct NotchExpandedView: View { } } } + .frame(width: 216) // Fixed width prevents notch from resizing and causing edge artifacts .padding(.horizontal, 8) .padding(.vertical, 6) .background(Color.black) // Must be pure black to blend with macOS notch @@ -752,7 +821,7 @@ struct NotchCommandOutputExpandedView: View { 70 } - // Dynamic height based on content (max half screen) + /// Dynamic height based on content (max half screen) private var dynamicHeight: CGFloat { let baseHeight: CGFloat = 120 // Minimum height let contentHeight = self.estimateContentHeight()