diff --git a/.github/screenshots/transcribe-with-prompt-settings.jpg b/.github/screenshots/transcribe-with-prompt-settings.jpg new file mode 100644 index 0000000..07791ea Binary files /dev/null and b/.github/screenshots/transcribe-with-prompt-settings.jpg differ diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index ce60630..18e4e63 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() @@ -1093,6 +1127,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, @@ -1165,7 +1202,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 @@ -1450,13 +1489,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, @@ -1499,13 +1545,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( @@ -1621,6 +1660,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" @@ -1713,7 +1753,7 @@ struct ContentView: View { var finalText: String // Check if we should use AI processing - let shouldUseAI = DictationAIPostProcessingGate.isConfigured() + let shouldUseAI = DictationAIPostProcessingGate.isConfigured() || (promptOverride != nil && DictationAIPostProcessingGate.isProviderConfigured()) if shouldUseAI { DebugLogger.shared.debug("Routing transcription through AI post-processing", source: "ContentView") @@ -1724,7 +1764,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) // Clear transient status text before leaving processing state to avoid // a brief non-shimmer "Refining..." preview flash. @@ -1857,7 +1897,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 @@ -2102,9 +2142,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: @@ -2190,7 +2236,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( @@ -2376,83 +2422,6 @@ struct ContentView: View { } @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)]) @@ -2484,14 +2453,32 @@ struct ContentView: View { 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: { @@ -2522,6 +2509,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() @@ -2582,6 +2596,9 @@ struct ContentView: View { isDictateRecordingProvider: { self.activeRecordingMode == .dictate }, + isPromptModeRecordingProvider: { + self.activeRecordingMode == .promptMode + }, isCommandRecordingProvider: { self.activeRecordingMode == .command }, @@ -2715,6 +2732,87 @@ struct ContentView: View { // AudioDevice and AudioHardwareObserver moved to Services/AudioDeviceService.swift +// 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 bc0f1a6..dddc425 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -35,9 +35,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 { @@ -723,12 +727,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 +1237,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 +1259,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 +1598,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) @@ -1986,6 +2039,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 +2382,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) @@ -2347,7 +2406,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 @@ -2789,7 +2850,9 @@ final class SettingsStore: ObservableObject { case fluidAudio case whisper - var id: String { rawValue } + var id: String { + rawValue + } var displayName: String { switch self { @@ -2844,7 +2907,7 @@ final class SettingsStore: ObservableObject { // swiftlint:enable type_body_length private extension SettingsStore { - // Keys + /// Keys enum Keys { static let enableAIProcessing = "EnableAIProcessing" static let enableDebugLogs = "EnableDebugLogs" @@ -2895,6 +2958,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" @@ -2914,7 +2982,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 @@ -2925,7 +2993,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" // Overlay Position @@ -2935,10 +3003,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) @@ -2958,7 +3026,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" } } @@ -2968,7 +3036,9 @@ extension SettingsStore { case standard case reliablePaste - var id: String { self.rawValue } + var id: String { + self.rawValue + } var displayName: String { switch self { @@ -3025,7 +3095,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 70b8302..15b30e3 100644 --- a/Sources/Fluid/Services/DictationAIPostProcessingGate.swift +++ b/Sources/Fluid/Services/DictationAIPostProcessingGate.swift @@ -8,7 +8,8 @@ enum DictationAIPostProcessingGate { /// - 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 } + let hasCustomPrompt = settings.selectedPromptID(for: .dictate) != nil + guard settings.enableAIProcessing || hasCustomPrompt else { return false } let providerID = settings.selectedProviderID if providerID == "apple-intelligence" { @@ -24,6 +25,20 @@ enum DictationAIPostProcessingGate { return !apiKey.isEmpty } + /// 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 } + let apiKey = (settings.getAPIKey(for: providerID) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return !apiKey.isEmpty + } + static func baseURL(for providerID: String, settings: SettingsStore) -> String { if let saved = settings.savedProviders.first(where: { $0.id == providerID }) { return saved.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index b25f6bd..dc17af3 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/NotchOverlayManager.swift b/Sources/Fluid/Services/NotchOverlayManager.swift index 5f0c355..68dabe9 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/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 163134e..124d25f 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 89b10b3..09598d9 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -1384,7 +1384,6 @@ private struct BottomOverlayActionsMenuView: View { ) } - @ViewBuilder private func actionRow( title: String, icon: String, @@ -1715,8 +1714,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 @@ -1765,6 +1764,9 @@ struct BottomOverlayView: View { } private var selectedPromptLabel: String { + if let overrideName = self.contentState.promptModeOverrideProfileName { + return overrideName + } guard let activePromptMode else { return "N/A" } if let profile = self.settings.resolvedPromptProfile( for: activePromptMode, @@ -2515,11 +2517,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 34b04ff..1e2feec 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() @@ -271,9 +277,12 @@ struct NotchExpandedView: View { @ObservedObject private var contentState = NotchContentState.shared @ObservedObject private var settings = SettingsStore.shared @ObservedObject private var activeAppMonitor = ActiveAppMonitor.shared + @ObservedObject private var historyStore = TranscriptionHistoryStore.shared @Environment(\.theme) private var theme @State private var showPromptHoverMenu = false @State private var promptHoverWorkItem: DispatchWorkItem? + @State private var showActionsMenu = false + @State private var showModeMenu = false private var modeColor: Color { self.contentState.mode.notchColor @@ -295,8 +304,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 +315,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 } @@ -350,6 +359,9 @@ struct NotchExpandedView: View { } private var selectedPromptLabel: String { + if let overrideName = self.contentState.promptModeOverrideProfileName { + return overrideName + } guard let activePromptMode else { return "N/A" } if let profile = self.settings.resolvedPromptProfile( for: activePromptMode, @@ -369,6 +381,119 @@ struct NotchExpandedView: View { 180 } + private var hasHistory: Bool { + !self.historyStore.entries.isEmpty + } + + private var canReprocess: Bool { + self.hasHistory && !self.contentState.isProcessing + } + + private var actionsMenuContent: some View { + VStack(alignment: .leading, spacing: 0) { + Button(action: { + self.showActionsMenu = false + self.contentState.onReprocessLastRequested?() + }) { + HStack(spacing: 6) { + Text("Reprocess Last") + Spacer() + Image(systemName: "arrow.clockwise") + .font(.system(size: 9, weight: .semibold)) + } + } + .buttonStyle(.plain) + .padding(.vertical, 4) + .disabled(!self.canReprocess) + .opacity(self.canReprocess ? 1 : 0.4) + + Button(action: { + self.showActionsMenu = false + self.contentState.onCopyLastRequested?() + }) { + HStack(spacing: 6) { + Text("Copy Last") + Spacer() + Image(systemName: "doc.on.doc") + .font(.system(size: 9, weight: .semibold)) + } + } + .buttonStyle(.plain) + .padding(.vertical, 4) + .disabled(!self.hasHistory) + .opacity(self.hasHistory ? 1 : 0.4) + + Divider().padding(.vertical, 2) + + Button(action: { + self.showActionsMenu = false + self.contentState.onUndoLastAIRequested?() + }) { + HStack(spacing: 6) { + Text("Undo AI") + Spacer() + Image(systemName: "arrow.uturn.backward") + .font(.system(size: 9, weight: .semibold)) + } + } + .buttonStyle(.plain) + .padding(.vertical, 4) + .disabled(!self.hasHistory) + .opacity(self.hasHistory ? 1 : 0.4) + } + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.black) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + } + + private var normalizedMode: OverlayMode { + switch self.contentState.mode { + case .dictation: return .dictation + case .edit, .write, .rewrite: return .edit + case .command: return .command + } + } + + private var modeModeContent: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach([OverlayMode.dictation, .edit], id: \.self) { mode in + let isSelected = self.normalizedMode == mode + Button(action: { + self.showModeMenu = false + self.contentState.onOverlayModeSwitchRequested?(mode) + }) { + HStack(spacing: 6) { + Text(mode == .dictation ? "Dictate" : "Edit") + Spacer() + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .semibold)) + } + } + } + .buttonStyle(.plain) + .padding(.vertical, 4) + } + } + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.black) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + } + private func handlePromptHover(_ hovering: Bool) { guard self.isPromptSelectableMode, !self.contentState.isProcessing else { self.showPromptHoverMenu = false @@ -384,10 +509,16 @@ 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) { Button(action: { - self.settings.setSelectedPromptID(nil, for: promptMode) + if isInPromptMode { + self.contentState.onPromptModeProfileChangeRequested?(nil) + } 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 +528,10 @@ struct NotchExpandedView: View { HStack { Text("Default") Spacer() - if self.settings.selectedPromptID(for: promptMode) == nil { + let isSelected = isInPromptMode + ? (self.contentState.promptModeOverrideProfileID == nil) + : (self.settings.selectedPromptID(for: promptMode) == nil) + if isSelected { Image(systemName: "checkmark") .font(.system(size: 10, weight: .semibold)) } @@ -412,7 +546,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 +560,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 +574,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 +653,11 @@ 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.showActionsMenu = false + self.showModeMenu = false + self.showPromptHoverMenu.toggle() } if self.showPromptHoverMenu { @@ -525,6 +671,97 @@ struct NotchExpandedView: View { .transition(.opacity) } + // Mode + AI + Actions chips row + if !self.contentState.isProcessing { + HStack(spacing: 6) { + // Mode chip + HStack(spacing: 4) { + Text(self.normalizedMode == .dictation ? "Dictate" : "Edit") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white.opacity(0.75)) + Image(systemName: "chevron.up") + .font(.system(size: 7, weight: .semibold)) + .foregroundStyle(.white.opacity(0.45)) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.white.opacity(0.07)) + .cornerRadius(5) + .onTapGesture { + guard !self.contentState.isProcessing, self.normalizedMode != .command else { return } + self.showPromptHoverMenu = false + self.showActionsMenu = false + self.showModeMenu.toggle() + } + + // AI toggle chip + let aiEnabled = self.settings.enableAIProcessing + HStack(spacing: 4) { + Text("AI:") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.white.opacity(0.5)) + Text(aiEnabled ? "On" : "Off") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(aiEnabled ? .white.opacity(0.82) : .white.opacity(0.55)) + Image(systemName: aiEnabled ? "brain.fill" : "brain") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(aiEnabled ? .white.opacity(0.65) : .white.opacity(0.4)) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.white.opacity(0.07)) + .cornerRadius(5) + .onTapGesture { + guard !self.contentState.isProcessing else { return } + self.showActionsMenu = false + self.showPromptHoverMenu = false + self.showModeMenu = false + self.contentState.onToggleAIProcessingRequested?() + } + + // Actions chip + HStack(spacing: 4) { + Text("Actions") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.white.opacity(self.hasHistory ? 0.75 : 0.35)) + Image(systemName: "chevron.up") + .font(.system(size: 7, weight: .semibold)) + .foregroundStyle(.white.opacity(self.hasHistory ? 0.45 : 0.25)) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.white.opacity(0.07)) + .cornerRadius(5) + .opacity(self.hasHistory ? 1 : 0.6) + .onTapGesture { + guard self.hasHistory, !self.contentState.isProcessing else { return } + self.showPromptHoverMenu = false + self.showModeMenu = false + self.showActionsMenu.toggle() + } + } + // Menus float above as overlays — don't affect VStack height/width so no layout reflow + .overlay(alignment: .bottomLeading) { + if self.showModeMenu { + self.modeModeContent + .fixedSize() + .offset(y: -4) + .transition(.opacity) + .zIndex(20) + } + } + .overlay(alignment: .bottomTrailing) { + if self.showActionsMenu { + self.actionsMenuContent + .fixedSize() + .offset(y: -4) + .transition(.opacity) + .zIndex(20) + } + } + .transition(.opacity) + } + // Transcription preview (wrapped, fixed width) if self.hasTranscription && !self.contentState.isProcessing { let previewText = self.contentState.cachedPreviewText @@ -558,6 +795,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 +990,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()