diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index e6b8da2..c88469e 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -151,6 +151,7 @@ struct ContentView: View { @State private var previousSidebarItem: SidebarItem? = nil // Track previous for mode transitions @State private var playgroundUsed: Bool = SettingsStore.shared.playgroundUsed @State private var recordingAppInfo: (name: String, bundleId: String, windowTitle: String)? = nil + @State private var recordingAutoLearnElement: AXUIElement? = nil // Command Mode State // @State private var showCommandMode: Bool = false @@ -1391,6 +1392,14 @@ struct ContentView: View { let focusedPID = TypingService.captureSystemFocusedPID() ?? NSWorkspace.shared.frontmostApplication?.processIdentifier NotchContentState.shared.recordingTargetPID = focusedPID + if SettingsStore.shared.autoLearnCustomDictionaryEnabled { + self.recordingAutoLearnElement = AutoLearnDictionaryService.shared.captureFocusedElement( + preferredPID: focusedPID, + allowsSystemFocusedPIDMismatch: true + ) + } else { + self.recordingAutoLearnElement = nil + } let info = self.getCurrentAppInfo() self.recordingAppInfo = info @@ -2015,10 +2024,74 @@ struct ContentView: View { if typingTarget.shouldRestoreOriginalFocus { await self.restoreFocusToRecordingTarget() } - self.asr.typeTextToActiveField( + let shouldCaptureAutoLearn = SettingsStore.shared.autoLearnCustomDictionaryEnabled + let preInsertionMonitoringElement = shouldCaptureAutoLearn + ? AutoLearnDictionaryService.shared.captureFocusedElement( + preferredPID: typingTarget.pid, + allowsSystemFocusedPIDMismatch: true + ) + : nil + let recordingMonitoringElement = shouldCaptureAutoLearn ? self.recordingAutoLearnElement : nil + let textInsertionMode = SettingsStore.shared.textInsertionMode + let didAcceptTyping = self.asr.typeTextToActiveField( finalText, preferredTargetPID: typingTarget.pid - ) + ) { + guard SettingsStore.shared.autoLearnCustomDictionaryEnabled else { + self.recordingAutoLearnElement = nil + return + } + + // Now that typing is physically complete, we can start monitoring. + // Prefer target-PID matches so focus restore failures do not attach to FluidVoice or another app. + // Some editors expose the focused AX text element from a helper process, so trusted + // pre-insertion captures can still be valid even when their PID differs from the app PID. + let monitoringElement = self.autoLearnMonitoringElement( + targetPID: typingTarget.pid, + candidates: [ + AutoLearnMonitoringCandidate( + label: "pre-insertion", + element: preInsertionMonitoringElement, + allowsHelperProcessFallback: true + ), + AutoLearnMonitoringCandidate( + label: "recording", + element: recordingMonitoringElement, + allowsHelperProcessFallback: true + ), + AutoLearnMonitoringCandidate( + label: "completion", + element: AutoLearnDictionaryService.shared.captureFocusedElement(preferredPID: typingTarget.pid), + allowsHelperProcessFallback: false + ), + ] + ) + self.recordingAutoLearnElement = nil + guard let monitoringElement else { + DebugLogger.shared.debug( + "Auto-learn AX monitoring skipped: no AX element matched typing target; starting event fallback.", + source: "ContentView" + ) + AutoLearnDictionaryService.shared.beginEventFallbackMonitoring( + pastedText: finalText, + targetPID: typingTarget.pid + ) + return + } + AutoLearnDictionaryService.shared.beginMonitoring( + pastedText: finalText, + element: monitoringElement, + targetPID: typingTarget.pid + ) + } + if shouldCaptureAutoLearn, textInsertionMode == .reliablePaste, didAcceptTyping { + // Reliable Paste completion may wait for slow paste targets. Prime the AX-blind fallback + // after typing is accepted so quick corrections in prompt boxes are still observed. + AutoLearnDictionaryService.shared.beginEventFallbackMonitoring( + pastedText: finalText, + targetPID: typingTarget.pid + ) + } didTypeExternally = true } @@ -2042,7 +2115,6 @@ struct ContentView: View { aiModel: modelInfo.model, aiProvider: modelInfo.provider ) - NotchOverlayManager.shared.hide() } else if shouldPersistOutputs, SettingsStore.shared.copyTranscriptionToClipboard == false, @@ -2074,6 +2146,92 @@ struct ContentView: View { return .normal } + private struct AutoLearnMonitoringCandidate { + let label: String + let element: AXUIElement? + let allowsHelperProcessFallback: Bool + } + + private func autoLearnMonitoringElement( + targetPID: pid_t?, + candidates: [AutoLearnMonitoringCandidate] + ) -> AXUIElement? { + let resolvedCandidates = candidates.compactMap { candidate -> (candidate: AutoLearnMonitoringCandidate, pid: pid_t?)? in + guard let element = candidate.element else { return nil } + return (candidate, AutoLearnDictionaryService.shared.pid(for: element)) + } + + self.logAutoLearnCandidatePIDs(targetPID: targetPID, candidates: resolvedCandidates) + + guard let targetPID, targetPID > 0 else { + return resolvedCandidates.first?.candidate.element + } + + if let matchedCandidate = resolvedCandidates.first(where: { $0.pid == targetPID }) { + return matchedCandidate.candidate.element + } + + let selfPID = ProcessInfo.processInfo.processIdentifier + let selfBundleID = Bundle.main.bundleIdentifier + let targetBundleID = self.autoLearnTargetBundleID(targetPID: targetPID) + for resolvedCandidate in resolvedCandidates where resolvedCandidate.candidate.allowsHelperProcessFallback { + guard let candidatePID = resolvedCandidate.pid, + candidatePID != selfPID + else { + continue + } + + let candidateBundleID = NSRunningApplication(processIdentifier: candidatePID)?.bundleIdentifier + guard candidateBundleID != selfBundleID else { continue } + guard let candidateBundleID, + let targetBundleID, + self.isAutoLearnSameAppFamily(candidateBundleID, targetBundleID) + else { + continue + } + + DebugLogger.shared.debug( + "Auto-learn monitoring using helper-process AX element: " + + "source=\(resolvedCandidate.candidate.label), targetPID=\(targetPID), " + + "elementPID=\(candidatePID), elementBundle=\(candidateBundleID)", + source: "ContentView" + ) + return resolvedCandidate.candidate.element + } + + return nil + } + + private func autoLearnTargetBundleID(targetPID: pid_t) -> String? { + NSRunningApplication(processIdentifier: targetPID)?.bundleIdentifier + } + + private func isAutoLearnSameAppFamily(_ lhs: String, _ rhs: String) -> Bool { + let lhs = lhs.lowercased() + let rhs = rhs.lowercased() + guard !lhs.isEmpty, !rhs.isEmpty else { return false } + return lhs == rhs || + lhs.hasPrefix("\(rhs).") || + rhs.hasPrefix("\(lhs).") + } + + private func logAutoLearnCandidatePIDs( + targetPID: pid_t?, + candidates: [(candidate: AutoLearnMonitoringCandidate, pid: pid_t?)] + ) { + let summary = candidates.map { resolvedCandidate -> String in + let pid = resolvedCandidate.pid.map(String.init) ?? "nil" + let bundleID = resolvedCandidate.pid + .flatMap { NSRunningApplication(processIdentifier: $0)?.bundleIdentifier } + ?? "unknown" + return "\(resolvedCandidate.candidate.label):pid=\(pid),bundle=\(bundleID)" + }.joined(separator: "; ") + DebugLogger.shared.debug( + "Auto-learn monitoring candidates: targetPID=\(targetPID.map(String.init) ?? "nil"); \(summary)", + source: "ContentView" + ) + } + private func reprocessLastDictationFromHistory() { guard let last = TranscriptionHistoryStore.shared.entries.first else { DebugLogger.shared.info("Actions: Reprocess requested but history is empty", source: "ContentView") diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index deab020..acaa591 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -63,11 +63,240 @@ struct SettingsBackupPayload: Codable, Equatable { let pauseMediaDuringTranscription: Bool let vocabularyBoostingEnabled: Bool let customDictionaryEntries: [SettingsStore.CustomDictionaryEntry] + // Added in auto-learn dictionary PR — decode with defaults for older backups. + let autoLearnCustomDictionaryEnabled: Bool + let autoLearnCustomDictionarySuggestions: [SettingsStore.AutoLearnSuggestion] let selectedDictationPromptID: String? let dictationPromptOff: Bool? let selectedEditPromptID: String? let defaultDictationPromptOverride: String? let defaultEditPromptOverride: String? + + private enum CodingKeys: String, CodingKey { + case selectedProviderID, selectedModelByProvider, savedProviders, modelReasoningConfigs + case selectedSpeechModel, selectedCohereLanguage + case hotkeyShortcut, promptModeHotkeyShortcut, promptModeShortcutEnabled + case promptModeSelectedPromptID, secondaryDictationPromptOff + case commandModeHotkeyShortcut, commandModeShortcutEnabled + case commandModeSelectedModel, commandModeSelectedProviderID + case commandModeConfirmBeforeExecute, commandModeLinkedToGlobal + case rewriteModeHotkeyShortcut, rewriteModeShortcutEnabled + case rewriteModeSelectedModel, rewriteModeSelectedProviderID, rewriteModeLinkedToGlobal + case cancelRecordingHotkeyShortcut + case showThinkingTokens, hideFromDockAndAppSwitcher + case accentColorOption, transcriptionStartSound + case transcriptionSoundVolume, transcriptionSoundIndependentVolume + case autoUpdateCheckEnabled, betaReleasesEnabled, enableDebugLogs, shareAnonymousAnalytics + case pressAndHoldMode, enableStreamingPreview, enableAIStreaming + case copyTranscriptionToClipboard, textInsertionMode + case preferredInputDeviceUID, preferredOutputDeviceUID + case visualizerNoiseThreshold, overlayPosition, overlayBottomOffset, overlaySize + case transcriptionPreviewCharLimit, userTypingWPM + case saveTranscriptionHistory, notifyAIProcessingFailures, weekendsDontBreakStreak + case fillerWords, removeFillerWordsEnabled + case gaavModeEnabled, pauseMediaDuringTranscription + case vocabularyBoostingEnabled, customDictionaryEntries + case autoLearnCustomDictionaryEnabled, autoLearnCustomDictionarySuggestions + case selectedDictationPromptID, dictationPromptOff + case selectedEditPromptID + case defaultDictationPromptOverride, defaultEditPromptOverride + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.selectedProviderID = try container.decode(String.self, forKey: .selectedProviderID) + self.selectedModelByProvider = try container.decode([String: String].self, forKey: .selectedModelByProvider) + self.savedProviders = try container.decode([SettingsStore.SavedProvider].self, forKey: .savedProviders) + self.modelReasoningConfigs = try container.decode([String: SettingsStore.ModelReasoningConfig].self, forKey: .modelReasoningConfigs) + self.selectedSpeechModel = try container.decode(SettingsStore.SpeechModel.self, forKey: .selectedSpeechModel) + self.selectedCohereLanguage = try container.decode(SettingsStore.CohereLanguage.self, forKey: .selectedCohereLanguage) + self.hotkeyShortcut = try container.decode(HotkeyShortcut.self, forKey: .hotkeyShortcut) + self.promptModeHotkeyShortcut = try container.decode(HotkeyShortcut.self, forKey: .promptModeHotkeyShortcut) + self.promptModeShortcutEnabled = try container.decode(Bool.self, forKey: .promptModeShortcutEnabled) + self.promptModeSelectedPromptID = try container.decodeIfPresent(String.self, forKey: .promptModeSelectedPromptID) + self.secondaryDictationPromptOff = try container.decodeIfPresent(Bool.self, forKey: .secondaryDictationPromptOff) + self.commandModeHotkeyShortcut = try container.decode(HotkeyShortcut.self, forKey: .commandModeHotkeyShortcut) + self.commandModeShortcutEnabled = try container.decode(Bool.self, forKey: .commandModeShortcutEnabled) + self.commandModeSelectedModel = try container.decodeIfPresent(String.self, forKey: .commandModeSelectedModel) + self.commandModeSelectedProviderID = try container.decode(String.self, forKey: .commandModeSelectedProviderID) + self.commandModeConfirmBeforeExecute = try container.decode(Bool.self, forKey: .commandModeConfirmBeforeExecute) + self.commandModeLinkedToGlobal = try container.decode(Bool.self, forKey: .commandModeLinkedToGlobal) + self.rewriteModeHotkeyShortcut = try container.decode(HotkeyShortcut.self, forKey: .rewriteModeHotkeyShortcut) + self.rewriteModeShortcutEnabled = try container.decode(Bool.self, forKey: .rewriteModeShortcutEnabled) + self.rewriteModeSelectedModel = try container.decodeIfPresent(String.self, forKey: .rewriteModeSelectedModel) + self.rewriteModeSelectedProviderID = try container.decode(String.self, forKey: .rewriteModeSelectedProviderID) + self.rewriteModeLinkedToGlobal = try container.decode(Bool.self, forKey: .rewriteModeLinkedToGlobal) + self.cancelRecordingHotkeyShortcut = try container.decode(HotkeyShortcut.self, forKey: .cancelRecordingHotkeyShortcut) + self.showThinkingTokens = try container.decode(Bool.self, forKey: .showThinkingTokens) + self.hideFromDockAndAppSwitcher = try container.decode(Bool.self, forKey: .hideFromDockAndAppSwitcher) + self.accentColorOption = try container.decode(SettingsStore.AccentColorOption.self, forKey: .accentColorOption) + self.transcriptionStartSound = try container.decode(SettingsStore.TranscriptionStartSound.self, forKey: .transcriptionStartSound) + self.transcriptionSoundVolume = try container.decode(Float.self, forKey: .transcriptionSoundVolume) + self.transcriptionSoundIndependentVolume = try container.decode(Bool.self, forKey: .transcriptionSoundIndependentVolume) + self.autoUpdateCheckEnabled = try container.decode(Bool.self, forKey: .autoUpdateCheckEnabled) + self.betaReleasesEnabled = try container.decode(Bool.self, forKey: .betaReleasesEnabled) + self.enableDebugLogs = try container.decode(Bool.self, forKey: .enableDebugLogs) + self.shareAnonymousAnalytics = try container.decode(Bool.self, forKey: .shareAnonymousAnalytics) + self.pressAndHoldMode = try container.decode(Bool.self, forKey: .pressAndHoldMode) + self.enableStreamingPreview = try container.decode(Bool.self, forKey: .enableStreamingPreview) + self.enableAIStreaming = try container.decode(Bool.self, forKey: .enableAIStreaming) + self.copyTranscriptionToClipboard = try container.decode(Bool.self, forKey: .copyTranscriptionToClipboard) + self.textInsertionMode = try container.decode(SettingsStore.TextInsertionMode.self, forKey: .textInsertionMode) + self.preferredInputDeviceUID = try container.decodeIfPresent(String.self, forKey: .preferredInputDeviceUID) + self.preferredOutputDeviceUID = try container.decodeIfPresent(String.self, forKey: .preferredOutputDeviceUID) + self.visualizerNoiseThreshold = try container.decode(Double.self, forKey: .visualizerNoiseThreshold) + self.overlayPosition = try container.decode(SettingsStore.OverlayPosition.self, forKey: .overlayPosition) + self.overlayBottomOffset = try container.decode(Double.self, forKey: .overlayBottomOffset) + self.overlaySize = try container.decode(SettingsStore.OverlaySize.self, forKey: .overlaySize) + self.transcriptionPreviewCharLimit = try container.decode(Int.self, forKey: .transcriptionPreviewCharLimit) + self.userTypingWPM = try container.decode(Int.self, forKey: .userTypingWPM) + self.saveTranscriptionHistory = try container.decode(Bool.self, forKey: .saveTranscriptionHistory) + self.notifyAIProcessingFailures = try container.decodeIfPresent(Bool.self, forKey: .notifyAIProcessingFailures) + self.weekendsDontBreakStreak = try container.decode(Bool.self, forKey: .weekendsDontBreakStreak) + self.fillerWords = try container.decode([String].self, forKey: .fillerWords) + self.removeFillerWordsEnabled = try container.decode(Bool.self, forKey: .removeFillerWordsEnabled) + self.gaavModeEnabled = try container.decode(Bool.self, forKey: .gaavModeEnabled) + self.pauseMediaDuringTranscription = try container.decode(Bool.self, forKey: .pauseMediaDuringTranscription) + self.vocabularyBoostingEnabled = try container.decode(Bool.self, forKey: .vocabularyBoostingEnabled) + self.customDictionaryEntries = try container.decode([SettingsStore.CustomDictionaryEntry].self, forKey: .customDictionaryEntries) + // Backward-compatible defaults for keys added by auto-learn dictionary PR. + self.autoLearnCustomDictionaryEnabled = try container.decodeIfPresent(Bool.self, forKey: .autoLearnCustomDictionaryEnabled) ?? false + self.autoLearnCustomDictionarySuggestions = try container.decodeIfPresent([SettingsStore.AutoLearnSuggestion].self, forKey: .autoLearnCustomDictionarySuggestions) ?? [] + self.selectedDictationPromptID = try container.decodeIfPresent(String.self, forKey: .selectedDictationPromptID) + self.dictationPromptOff = try container.decodeIfPresent(Bool.self, forKey: .dictationPromptOff) + self.selectedEditPromptID = try container.decodeIfPresent(String.self, forKey: .selectedEditPromptID) + self.defaultDictationPromptOverride = try container.decodeIfPresent(String.self, forKey: .defaultDictationPromptOverride) + self.defaultEditPromptOverride = try container.decodeIfPresent(String.self, forKey: .defaultEditPromptOverride) + } + + // Memberwise init for programmatic construction (backup export). + init( + selectedProviderID: String, + selectedModelByProvider: [String: String], + savedProviders: [SettingsStore.SavedProvider], + modelReasoningConfigs: [String: SettingsStore.ModelReasoningConfig], + selectedSpeechModel: SettingsStore.SpeechModel, + selectedCohereLanguage: SettingsStore.CohereLanguage, + hotkeyShortcut: HotkeyShortcut, + promptModeHotkeyShortcut: HotkeyShortcut, + promptModeShortcutEnabled: Bool, + promptModeSelectedPromptID: String?, + secondaryDictationPromptOff: Bool?, + commandModeHotkeyShortcut: HotkeyShortcut, + commandModeShortcutEnabled: Bool, + commandModeSelectedModel: String?, + commandModeSelectedProviderID: String, + commandModeConfirmBeforeExecute: Bool, + commandModeLinkedToGlobal: Bool, + rewriteModeHotkeyShortcut: HotkeyShortcut, + rewriteModeShortcutEnabled: Bool, + rewriteModeSelectedModel: String?, + rewriteModeSelectedProviderID: String, + rewriteModeLinkedToGlobal: Bool, + cancelRecordingHotkeyShortcut: HotkeyShortcut, + showThinkingTokens: Bool, + hideFromDockAndAppSwitcher: Bool, + accentColorOption: SettingsStore.AccentColorOption, + transcriptionStartSound: SettingsStore.TranscriptionStartSound, + transcriptionSoundVolume: Float, + transcriptionSoundIndependentVolume: Bool, + autoUpdateCheckEnabled: Bool, + betaReleasesEnabled: Bool, + enableDebugLogs: Bool, + shareAnonymousAnalytics: Bool, + pressAndHoldMode: Bool, + enableStreamingPreview: Bool, + enableAIStreaming: Bool, + copyTranscriptionToClipboard: Bool, + textInsertionMode: SettingsStore.TextInsertionMode, + preferredInputDeviceUID: String?, + preferredOutputDeviceUID: String?, + visualizerNoiseThreshold: Double, + overlayPosition: SettingsStore.OverlayPosition, + overlayBottomOffset: Double, + overlaySize: SettingsStore.OverlaySize, + transcriptionPreviewCharLimit: Int, + userTypingWPM: Int, + saveTranscriptionHistory: Bool, + notifyAIProcessingFailures: Bool? = nil, + weekendsDontBreakStreak: Bool, + fillerWords: [String], + removeFillerWordsEnabled: Bool, + gaavModeEnabled: Bool, + pauseMediaDuringTranscription: Bool, + vocabularyBoostingEnabled: Bool, + customDictionaryEntries: [SettingsStore.CustomDictionaryEntry], + autoLearnCustomDictionaryEnabled: Bool = false, + autoLearnCustomDictionarySuggestions: [SettingsStore.AutoLearnSuggestion] = [], + selectedDictationPromptID: String?, + dictationPromptOff: Bool?, + selectedEditPromptID: String?, + defaultDictationPromptOverride: String?, + defaultEditPromptOverride: String? + ) { + self.selectedProviderID = selectedProviderID + self.selectedModelByProvider = selectedModelByProvider + self.savedProviders = savedProviders + self.modelReasoningConfigs = modelReasoningConfigs + self.selectedSpeechModel = selectedSpeechModel + self.selectedCohereLanguage = selectedCohereLanguage + self.hotkeyShortcut = hotkeyShortcut + self.promptModeHotkeyShortcut = promptModeHotkeyShortcut + self.promptModeShortcutEnabled = promptModeShortcutEnabled + self.promptModeSelectedPromptID = promptModeSelectedPromptID + self.secondaryDictationPromptOff = secondaryDictationPromptOff + self.commandModeHotkeyShortcut = commandModeHotkeyShortcut + self.commandModeShortcutEnabled = commandModeShortcutEnabled + self.commandModeSelectedModel = commandModeSelectedModel + self.commandModeSelectedProviderID = commandModeSelectedProviderID + self.commandModeConfirmBeforeExecute = commandModeConfirmBeforeExecute + self.commandModeLinkedToGlobal = commandModeLinkedToGlobal + self.rewriteModeHotkeyShortcut = rewriteModeHotkeyShortcut + self.rewriteModeShortcutEnabled = rewriteModeShortcutEnabled + self.rewriteModeSelectedModel = rewriteModeSelectedModel + self.rewriteModeSelectedProviderID = rewriteModeSelectedProviderID + self.rewriteModeLinkedToGlobal = rewriteModeLinkedToGlobal + self.cancelRecordingHotkeyShortcut = cancelRecordingHotkeyShortcut + self.showThinkingTokens = showThinkingTokens + self.hideFromDockAndAppSwitcher = hideFromDockAndAppSwitcher + self.accentColorOption = accentColorOption + self.transcriptionStartSound = transcriptionStartSound + self.transcriptionSoundVolume = transcriptionSoundVolume + self.transcriptionSoundIndependentVolume = transcriptionSoundIndependentVolume + self.autoUpdateCheckEnabled = autoUpdateCheckEnabled + self.betaReleasesEnabled = betaReleasesEnabled + self.enableDebugLogs = enableDebugLogs + self.shareAnonymousAnalytics = shareAnonymousAnalytics + self.pressAndHoldMode = pressAndHoldMode + self.enableStreamingPreview = enableStreamingPreview + self.enableAIStreaming = enableAIStreaming + self.copyTranscriptionToClipboard = copyTranscriptionToClipboard + self.textInsertionMode = textInsertionMode + self.preferredInputDeviceUID = preferredInputDeviceUID + self.preferredOutputDeviceUID = preferredOutputDeviceUID + self.visualizerNoiseThreshold = visualizerNoiseThreshold + self.overlayPosition = overlayPosition + self.overlayBottomOffset = overlayBottomOffset + self.overlaySize = overlaySize + self.transcriptionPreviewCharLimit = transcriptionPreviewCharLimit + self.userTypingWPM = userTypingWPM + self.saveTranscriptionHistory = saveTranscriptionHistory + self.notifyAIProcessingFailures = notifyAIProcessingFailures + self.weekendsDontBreakStreak = weekendsDontBreakStreak + self.fillerWords = fillerWords + self.removeFillerWordsEnabled = removeFillerWordsEnabled + self.gaavModeEnabled = gaavModeEnabled + self.pauseMediaDuringTranscription = pauseMediaDuringTranscription + self.vocabularyBoostingEnabled = vocabularyBoostingEnabled + self.customDictionaryEntries = customDictionaryEntries + self.autoLearnCustomDictionaryEnabled = autoLearnCustomDictionaryEnabled + self.autoLearnCustomDictionarySuggestions = autoLearnCustomDictionarySuggestions + self.selectedDictationPromptID = selectedDictationPromptID + self.dictationPromptOff = dictationPromptOff + self.selectedEditPromptID = selectedEditPromptID + self.defaultDictationPromptOverride = defaultDictationPromptOverride + self.defaultEditPromptOverride = defaultEditPromptOverride + } } struct AppBackupDocument: Codable, Equatable { diff --git a/Sources/Fluid/Persistence/SettingsStore+CustomDictionary.swift b/Sources/Fluid/Persistence/SettingsStore+CustomDictionary.swift new file mode 100644 index 0000000..5c4a900 --- /dev/null +++ b/Sources/Fluid/Persistence/SettingsStore+CustomDictionary.swift @@ -0,0 +1,62 @@ +import Foundation + +extension SettingsStore { + /// A custom dictionary entry that maps multiple misheard/alternate spellings to a correct replacement. + /// For example: ["fluid voice", "fluid boys"] -> "FluidVoice" + struct CustomDictionaryEntry: Codable, Identifiable, Hashable { + let id: UUID + /// Words/phrases to look for (case-insensitive matching) + var triggers: [String] + /// The correct replacement text + var replacement: String + + init(triggers: [String], replacement: String) { + self.id = UUID() + self.triggers = triggers.map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + self.replacement = replacement + } + + init(id: UUID, triggers: [String], replacement: String) { + self.id = id + self.triggers = triggers.map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + self.replacement = replacement + } + } + + enum AutoLearnSuggestionStatus: String, Codable, Hashable { + case pending + case dismissed + } + + struct AutoLearnSuggestion: Codable, Identifiable, Hashable { + let id: UUID + var originalText: String + var replacement: String + var occurrences: Int + var lastObservedAt: Date + var status: AutoLearnSuggestionStatus + var dismissedAtOccurrenceCount: Int? + + init( + id: UUID = UUID(), + originalText: String, + replacement: String, + occurrences: Int = 1, + lastObservedAt: Date = Date(), + status: AutoLearnSuggestionStatus = .pending, + dismissedAtOccurrenceCount: Int? = nil + ) { + self.id = id + self.originalText = originalText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + self.replacement = replacement.trimmingCharacters(in: .whitespacesAndNewlines) + self.occurrences = occurrences + self.lastObservedAt = lastObservedAt + self.status = status + self.dismissedAtOccurrenceCount = dismissedAtOccurrenceCount + } + } +} + +extension Notification.Name { + static let autoLearnSuggestionsDidChange = Notification.Name("AutoLearnSuggestionsDidChange") +} diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 3541038..f4fac32 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -2263,6 +2263,8 @@ final class SettingsStore: ObservableObject { pauseMediaDuringTranscription: self.pauseMediaDuringTranscription, vocabularyBoostingEnabled: self.vocabularyBoostingEnabled, customDictionaryEntries: self.customDictionaryEntries, + autoLearnCustomDictionaryEnabled: self.autoLearnCustomDictionaryEnabled, + autoLearnCustomDictionarySuggestions: self.autoLearnCustomDictionarySuggestions, selectedDictationPromptID: self.selectedDictationPromptID, dictationPromptOff: self.isDictationPromptOff, selectedEditPromptID: self.selectedEditPromptID, @@ -2335,6 +2337,8 @@ final class SettingsStore: ObservableObject { self.pauseMediaDuringTranscription = payload.pauseMediaDuringTranscription self.vocabularyBoostingEnabled = payload.vocabularyBoostingEnabled self.customDictionaryEntries = payload.customDictionaryEntries + self.autoLearnCustomDictionaryEnabled = payload.autoLearnCustomDictionaryEnabled + self.autoLearnCustomDictionarySuggestions = payload.autoLearnCustomDictionarySuggestions self.dictationPromptProfiles = promptProfiles self.appPromptBindings = appPromptBindings @@ -2911,28 +2915,6 @@ final class SettingsStore: ObservableObject { // MARK: - Custom Dictionary - /// A custom dictionary entry that maps multiple misheard/alternate spellings to a correct replacement. - /// For example: ["fluid voice", "fluid boys"] -> "FluidVoice" - struct CustomDictionaryEntry: Codable, Identifiable, Hashable { - let id: UUID - /// Words/phrases to look for (case-insensitive matching) - var triggers: [String] - /// The correct replacement text - var replacement: String - - init(triggers: [String], replacement: String) { - self.id = UUID() - self.triggers = triggers.map { $0.trimmingCharacters(in: .whitespaces).lowercased() } - self.replacement = replacement - } - - init(id: UUID, triggers: [String], replacement: String) { - self.id = id - self.triggers = triggers.map { $0.trimmingCharacters(in: .whitespaces).lowercased() } - self.replacement = replacement - } - } - var vocabularyBoostingEnabled: Bool { get { let value = self.defaults.object(forKey: Keys.vocabularyBoostingEnabled) @@ -2963,6 +2945,35 @@ final class SettingsStore: ObservableObject { } } + var autoLearnCustomDictionaryEnabled: Bool { + get { + let value = self.defaults.object(forKey: Keys.autoLearnCustomDictionaryEnabled) + return value as? Bool ?? false + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.autoLearnCustomDictionaryEnabled) + } + } + + var autoLearnCustomDictionarySuggestions: [AutoLearnSuggestion] { + get { + guard let data = defaults.data(forKey: Keys.autoLearnCustomDictionarySuggestions), + let decoded = try? JSONDecoder().decode([AutoLearnSuggestion].self, from: data) + else { + return [] + } + return decoded + } + set { + objectWillChange.send() + if let encoded = try? JSONEncoder().encode(newValue) { + self.defaults.set(encoded, forKey: Keys.autoLearnCustomDictionarySuggestions) + NotificationCenter.default.post(name: .autoLearnSuggestionsDidChange, object: nil) + } + } + } + // MARK: - Speech Model (Unified ASR Model Selection) /// Unified speech recognition model selection. @@ -3650,6 +3661,8 @@ private extension SettingsStore { // Custom Dictionary static let customDictionaryEntries = "CustomDictionaryEntries" static let vocabularyBoostingEnabled = "VocabularyBoostingEnabled" + static let autoLearnCustomDictionaryEnabled = "AutoLearnCustomDictionaryEnabled" + static let autoLearnCustomDictionarySuggestions = "AutoLearnCustomDictionarySuggestions" // Transcription Provider (ASR) static let selectedTranscriptionProvider = "SelectedTranscriptionProvider" diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 984d511..5c82997 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -2601,12 +2601,14 @@ final class ASRService: ObservableObject { private let typingService = TypingService() // Reuse instance to avoid conflicts - func typeTextToActiveField(_ text: String) { - self.typingService.typeTextInstantly(text) + @discardableResult + func typeTextToActiveField(_ text: String, onComplete: (() -> Void)? = nil) -> Bool { + self.typingService.typeTextInstantly(text, onComplete: onComplete) } - func typeTextToActiveField(_ text: String, preferredTargetPID: pid_t?) { - self.typingService.typeTextInstantly(text, preferredTargetPID: preferredTargetPID) + @discardableResult + func typeTextToActiveField(_ text: String, preferredTargetPID: pid_t?, onComplete: (() -> Void)? = nil) -> Bool { + self.typingService.typeTextInstantly(text, preferredTargetPID: preferredTargetPID, onComplete: onComplete) } /// Removes filler sounds from transcribed text @@ -2626,28 +2628,32 @@ final class ASRService: ObservableObject { // MARK: - Custom Dictionary (Cached Regex) /// Cache for compiled custom dictionary regexes. - /// Key: trigger word, Value: (compiled regex, replacement text) + /// Key: trigger word, Value: (compiled regex, escaped replacement template) /// Cleared when dictionary entries change. - private static var cachedDictionaryPatterns: [(regex: NSRegularExpression, replacement: String)] = [] + private static var cachedDictionaryPatterns: [(regex: NSRegularExpression, replacementTemplate: String)] = [] private static var dictionaryCacheNeedsRebuild: Bool = true /// Rebuilds the regex cache if dictionary has changed. /// Called lazily on first apply after settings change. private static func rebuildDictionaryCache() { let entries = SettingsStore.shared.customDictionaryEntries - var patterns: [(regex: NSRegularExpression, replacement: String)] = [] + var patterns: [(regex: NSRegularExpression, replacementTemplate: String)] = [] for entry in entries { for trigger in entry.triggers { guard !trigger.isEmpty else { continue } - let escapedTrigger = NSRegularExpression.escapedPattern(for: trigger) guard let regex = try? NSRegularExpression( - pattern: "\\b" + escapedTrigger + "\\b", + pattern: self.customDictionaryPattern(for: trigger), options: .caseInsensitive ) else { continue } - patterns.append((regex: regex, replacement: entry.replacement)) + patterns.append( + ( + regex: regex, + replacementTemplate: NSRegularExpression.escapedTemplate(for: entry.replacement) + ) + ) } } @@ -2655,6 +2661,12 @@ final class ASRService: ObservableObject { self.dictionaryCacheNeedsRebuild = false } + private static func customDictionaryPattern(for trigger: String) -> String { + let escapedTrigger = NSRegularExpression.escapedPattern(for: trigger) + let tokenCharacterClass = #"\p{L}\p{N}_"# + return "(? AXUIElement? { + if let element = self.captureSystemFocusedElement() { + if let preferredPID, self.pid(for: element) != preferredPID { + if allowsSystemFocusedPIDMismatch { + self.log( + "AXElementCapture_SystemFocusedPIDMismatchAllowed: preferredPID=\(preferredPID), elementPID=\(self.pid(for: element).map(String.init) ?? "nil")." + ) + return element + } + self.log( + "AXElementCapture_SystemFocusedPIDMismatch: preferredPID=\(preferredPID), elementPID=\(self.pid(for: element).map(String.init) ?? "nil")." + ) + } else { + return element + } + } + + guard let preferredPID, preferredPID > 0 else { + self.log("AXElementCapture_Unavailable: no system focused element.") + return nil + } + + if let element = self.captureApplicationFocusedElement(pid: preferredPID) { + self.log("AXElementCapture_AppFocusedFallback") + return element + } + + let appElement = AXUIElementCreateApplication(preferredPID) + let rootElement = self.captureFocusedWindowElement(from: appElement) ?? appElement + let candidates = self.readableTextEditingCandidates( + in: rootElement, + maxDepth: 10, + maxVisited: 600 + ) + + guard candidates.count == 1, let element = candidates.first else { + if candidates.isEmpty { + self.log("AXElementCapture_NoReadableTextCandidate: pid=\(preferredPID).") + } else { + self.log("AXElementCapture_AmbiguousReadableTextCandidates: pid=\(preferredPID), count=\(candidates.count).") + } + return nil + } + + self.log("AXElementCapture_SingleReadableTextCandidateFallback: pid=\(preferredPID).") + return element + } + + private func captureSystemFocusedElement() -> AXUIElement? { + let systemWide = AXUIElementCreateSystemWide() + var focusedElement: CFTypeRef? + + guard AXUIElementCopyAttributeValue( + systemWide, + kAXFocusedUIElementAttribute as CFString, + &focusedElement + ) == .success, + let focusedElement, + CFGetTypeID(focusedElement) == AXUIElementGetTypeID() + else { + return nil + } + + return unsafeBitCast(focusedElement, to: AXUIElement.self) + } + + private func captureApplicationFocusedElement(pid: pid_t) -> AXUIElement? { + let appElement = AXUIElementCreateApplication(pid) + var focusedElement: CFTypeRef? + + guard AXUIElementCopyAttributeValue( + appElement, + kAXFocusedUIElementAttribute as CFString, + &focusedElement + ) == .success, + let focusedElement, + CFGetTypeID(focusedElement) == AXUIElementGetTypeID() + else { + return nil + } + + return unsafeBitCast(focusedElement, to: AXUIElement.self) + } + + private func captureFocusedWindowElement(from appElement: AXUIElement) -> AXUIElement? { + self.axElementAttribute(appElement, kAXFocusedWindowAttribute as CFString) + ?? self.axElementAttribute(appElement, kAXMainWindowAttribute as CFString) + } + + private func axElementAttribute(_ element: AXUIElement, _ attribute: CFString) -> AXUIElement? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success, + let value, + CFGetTypeID(value) == AXUIElementGetTypeID() + else { + return nil + } + + return unsafeBitCast(value, to: AXUIElement.self) + } + + private func readableTextEditingCandidates( + in rootElement: AXUIElement, + maxDepth: Int, + maxVisited: Int + ) -> [AXUIElement] { + var candidates: [AXUIElement] = [] + var visited = 0 + self.collectReadableTextEditingCandidates( + in: rootElement, + depth: 0, + maxDepth: maxDepth, + maxVisited: maxVisited, + visited: &visited, + candidates: &candidates + ) + return candidates + } + + private func collectReadableTextEditingCandidates( + in element: AXUIElement, + depth: Int, + maxDepth: Int, + maxVisited: Int, + visited: inout Int, + candidates: inout [AXUIElement] + ) { + guard depth <= maxDepth, visited < maxVisited else { return } + visited += 1 + + if self.isReadableTextEditingElement(element) { + candidates.append(element) + } + + for child in self.axElementArrayAttribute(element, kAXChildrenAttribute as CFString).prefix(40) { + self.collectReadableTextEditingCandidates( + in: child, + depth: depth + 1, + maxDepth: maxDepth, + maxVisited: maxVisited, + visited: &visited, + candidates: &candidates + ) + } + } + + private func isReadableTextEditingElement(_ element: AXUIElement) -> Bool { + guard let role = self.stringAttribute(element, kAXRoleAttribute as CFString) else { + return false + } + let editableRoles = ["AXTextArea", "AXTextField", "AXComboBox", "AXSearchField"] + guard editableRoles.contains(role), + self.currentText(from: element) != nil + else { + return false + } + + var isSettable = DarwinBoolean(false) + guard AXUIElementIsAttributeSettable(element, kAXValueAttribute as CFString, &isSettable) == .success else { + return true + } + return isSettable.boolValue + } + + private func stringAttribute(_ element: AXUIElement, _ attribute: CFString) -> String? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success else { + return nil + } + return value as? String + } + + private func axElementArrayAttribute(_ element: AXUIElement, _ attribute: CFString) -> [AXUIElement] { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success, + let array = value as? [AXUIElement] + else { + return [] + } + return array + } + + func pid(for element: AXUIElement) -> pid_t? { + var pid: pid_t = 0 + guard AXUIElementGetPid(element, &pid) == .success, pid > 0 else { + return nil + } + return pid + } + + func beginMonitoring(pastedText: String, element: AXUIElement, targetPID: pid_t? = nil) { + guard SettingsStore.shared.autoLearnCustomDictionaryEnabled else { + self.log("Monitoring skipped: auto-learn disabled.") + return + } + self.startMonitoring(pastedText: pastedText, element: element, targetPID: targetPID) + } + + func beginEventFallbackMonitoring(pastedText: String, targetPID: pid_t?) { + if Thread.isMainThread { + self.startEventFallbackMonitoring(pastedText: pastedText, targetPID: targetPID) + } else { + DispatchQueue.main.async { + self.startEventFallbackMonitoring(pastedText: pastedText, targetPID: targetPID) + } + } + } + + func handleObservedKeyDown( + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags, + characters: String? + ) { + if Thread.isMainThread { + self.handleObservedKeyDownOnMain( + keyCode: keyCode, + modifiers: modifiers, + characters: characters + ) + } else { + DispatchQueue.main.async { + self.handleObservedKeyDownOnMain( + keyCode: keyCode, + modifiers: modifiers, + characters: characters + ) + } + } + } + + func handleObservedMouseDown() { + if Thread.isMainThread { + self.flushAndClearEventFallbackTypedRun() + } else { + DispatchQueue.main.async { + self.flushAndClearEventFallbackTypedRun() + } + } + } + + func stopMonitoring() { + self.isActive = false + + self.timeoutTimer?.cancel() + self.timeoutTimer = nil + + self.correctionProcessingTimer?.cancel() + self.correctionProcessingTimer = nil + + self.pollingTimer?.cancel() + self.pollingTimer = nil + self.monitoredElement = nil + self.monitoredPID = nil + + if let observer = self.axObserver { + CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .defaultMode) + self.axObserver = nil + } + + if let observer = self.workspaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + self.workspaceObserver = nil + } + + self.stopEventFallbackMonitoring() + } + + private func startMonitoring(pastedText: String, element: AXUIElement, targetPID: pid_t?) { + self.finalize() + self.finalizeEventFallback() + self.insertedText = pastedText + self.recordedSessionObservationCounts = [:] + self.monitoredElement = element + let elementPID = self.pid(for: element) + self.monitoredPID = self.monitoringActivationPID(elementPID: elementPID, targetPID: targetPID) + + // Capture the full field value as baseline so that both baselineText + // and lastKnownText (updated via kAXValueChanged) cover the same scope. + // Without this, dictating into an existing non-empty editor would diff + // the partial transcript against the entire field and produce junk. + var fieldValue: CFTypeRef? + if AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, &fieldValue) == .success, + let fullText = fieldValue as? String + { + self.baselineText = fullText + self.lastKnownText = fullText + } else { + self.log("AXText_Unreadable: starting event fallback monitoring.") + self.insertedText = "" + self.baselineText = "" + self.lastKnownText = "" + self.recordedSessionObservationCounts = [:] + self.monitoredElement = nil + self.monitoredPID = nil + self.startEventFallbackMonitoring( + pastedText: pastedText, + targetPID: targetPID ?? elementPID + ) + return + } + self.isActive = true + + self.setupValueChangeObserver(for: element) + self.setupAppSwitchObserver() + self.startTimeoutTimer() + } + + private func setupValueChangeObserver(for element: AXUIElement) { + var pid: pid_t = 0 + guard AXUIElementGetPid(element, &pid) == .success else { + self.setupPollingFallback(for: element) + return + } + + var observer: AXObserver? + let callback: AXObserverCallback = { _, changedElement, _, refcon in + guard let refcon else { return } + let service = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + guard service.isActive else { return } + + var value: CFTypeRef? + if AXUIElementCopyAttributeValue(changedElement, kAXValueAttribute as CFString, &value) == .success, + let text = value as? String + { + service.updateLastKnownText(text) + } + } + + guard AXObserverCreate(pid, callback, &observer) == .success, let observer else { + self.setupPollingFallback(for: element) + return + } + + let refcon = Unmanaged.passUnretained(self).toOpaque() + if AXObserverAddNotification(observer, element, kAXValueChangedNotification as CFString, refcon) == .success { + CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .defaultMode) + self.axObserver = observer + self.setupPollingFallback(for: element) + } else { + // Notification registration failed (e.g., unsupported control). + // Fall back to a lightweight polling loop to track edits. + self.setupPollingFallback(for: element) + } + } + + private func setupPollingFallback(for element: AXUIElement) { + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + 0.5, repeating: 0.5) + timer.setEventHandler { [weak self] in + guard let self, self.isActive else { return } + if let text = self.currentText(from: element) { + self.updateLastKnownText(text) + } + } + timer.resume() + self.pollingTimer = timer + } + + private func updateLastKnownText(_ text: String) { + guard self.lastKnownText != text else { return } + self.lastKnownText = text + self.scheduleCorrectionProcessing() + } + + private func scheduleCorrectionProcessing() { + guard self.isActive, self.baselineText != self.lastKnownText else { return } + + self.correctionProcessingTimer?.cancel() + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + self.correctionProcessingDebounceSeconds) + timer.setEventHandler { [weak self] in + guard let self, self.isActive else { return } + self.correctionProcessingTimer = nil + self.processCurrentCorrections() + } + timer.resume() + self.correctionProcessingTimer = timer + } + + private func setupAppSwitchObserver() { + self.workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, self.isActive else { return } + if let activatedApp = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + !self.shouldFinalizeForActivatedApplication(pid: activatedApp.processIdentifier) + { + return + } + self.finalize() + } + } + + private func shouldFinalizeForActivatedApplication(pid activatedPID: pid_t?) -> Bool { + guard let activatedPID, let monitoredPID = self.monitoredPID else { + return true + } + return activatedPID != monitoredPID + } + + private func monitoringActivationPID(elementPID: pid_t?, targetPID: pid_t?) -> pid_t? { + if let targetPID, targetPID > 0 { + return targetPID + } + return elementPID + } + + private func startTimeoutTimer() { + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + self.monitoringTimeoutSeconds) + timer.setEventHandler { [weak self] in + guard let self, self.isActive else { return } + self.finalize() + } + timer.resume() + self.timeoutTimer = timer + } + + private func startEventFallbackMonitoring(pastedText: String, targetPID: pid_t?) { + guard SettingsStore.shared.autoLearnCustomDictionaryEnabled else { + self.log("EventFallbackSkipped_Disabled") + return + } + + let trimmedText = pastedText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty else { + self.log("EventFallbackSkipped_EmptyInsertedText") + return + } + + self.finalize() + self.finalizeEventFallback() + + guard let targetApp = self.eventFallbackTargetApplication(targetPID: targetPID) else { + self.log("EventFallbackSkipped_NoTargetApplication") + return + } + self.eventFallbackState = EventFallbackMonitoringState( + insertedText: pastedText, + targetPID: targetPID, + targetFrontmostPID: targetApp.processIdentifier, + targetBundleIdentifier: targetApp.bundleIdentifier + ) + self.setupEventFallbackAppSwitchObserver() + self.startEventFallbackTimeoutTimer() + self.log( + "EventFallbackStarted: targetPID=\(targetPID.map(String.init) ?? "nil"), " + + "targetFrontmostPID=\(targetApp.processIdentifier), " + + "targetBundle=\(targetApp.bundleIdentifier ?? "nil"), insertedChars=\(pastedText.count)." + ) + } + + private func eventFallbackTargetApplication(targetPID: pid_t?) -> NSRunningApplication? { + if let targetPID, + let targetApp = NSRunningApplication(processIdentifier: targetPID), + !self.isSelfApplication(targetApp) + { + return targetApp + } + + if let frontmostApp = NSWorkspace.shared.frontmostApplication, + !self.isSelfApplication(frontmostApp) + { + return frontmostApp + } + + return nil + } + + private func isSelfApplication(_ app: NSRunningApplication) -> Bool { + if app.processIdentifier == ProcessInfo.processInfo.processIdentifier { + return true + } + guard let selfBundleID = Bundle.main.bundleIdentifier else { + return false + } + return app.bundleIdentifier == selfBundleID + } + + private func setupEventFallbackAppSwitchObserver() { + self.eventFallbackWorkspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, let state = self.eventFallbackState else { return } + guard let activatedApp = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { + self.finalizeEventFallback() + return + } + + if !self.isEventFallbackTargetApplication(activatedApp, state: state) { + self.finalizeEventFallback() + } + } + } + + private func startEventFallbackTimeoutTimer() { + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + self.monitoringTimeoutSeconds) + timer.setEventHandler { [weak self] in + self?.finalizeEventFallback() + } + timer.resume() + self.eventFallbackTimeoutTimer = timer + } + + private func stopEventFallbackMonitoring() { + self.eventFallbackTimeoutTimer?.cancel() + self.eventFallbackTimeoutTimer = nil + + self.eventFallbackProcessingTimer?.cancel() + self.eventFallbackProcessingTimer = nil + + if let observer = self.eventFallbackWorkspaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + self.eventFallbackWorkspaceObserver = nil + } + + self.eventFallbackState = nil + } + + private func finalizeEventFallback() { + guard self.eventFallbackState != nil else { return } + _ = self.processEventFallbackTypedRun(allowPotentialPrefixCompletion: true) + self.stopEventFallbackMonitoring() + } + + private func handleObservedKeyDownOnMain( + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags, + characters: String? + ) { + guard self.eventFallbackState != nil else { return } + guard self.isEventFallbackTargetFrontmost() else { + self.finalizeEventFallback() + return + } + + if !modifiers.isDisjoint(with: [.command, .control, .option, .function]) { + self.flushAndClearEventFallbackTypedRun() + return + } + + switch keyCode { + case 51: + self.handleEventFallbackBackspace() + case 36, 48, 53, 76, 123, 124, 125, 126: + self.flushAndClearEventFallbackTypedRun() + default: + self.handleEventFallbackCharacters(characters) + } + } + + private func isEventFallbackTargetFrontmost() -> Bool { + guard let state = self.eventFallbackState else { + return false + } + + guard let frontmostApp = NSWorkspace.shared.frontmostApplication else { return false } + + return self.isEventFallbackTargetApplication(frontmostApp, state: state) + } + + private func isEventFallbackTargetApplication( + _ app: NSRunningApplication, + state: EventFallbackMonitoringState + ) -> Bool { + if let targetBundleIdentifier = state.targetBundleIdentifier { + guard let bundleIdentifier = app.bundleIdentifier else { return false } + return self.isSameApplicationFamily(bundleIdentifier, targetBundleIdentifier) + } + + if let targetFrontmostPID = state.targetFrontmostPID { + return app.processIdentifier == targetFrontmostPID + } + + guard let targetPID = state.targetPID, targetPID > 0 else { return false } + return app.processIdentifier == targetPID + } + + private func isSameApplicationFamily(_ lhs: String, _ rhs: String) -> Bool { + let lhs = lhs.lowercased() + let rhs = rhs.lowercased() + guard !lhs.isEmpty, !rhs.isEmpty else { return false } + return lhs == rhs || + lhs.hasPrefix("\(rhs).") || + rhs.hasPrefix("\(lhs).") + } + + private func handleEventFallbackBackspace() { + guard var state = self.eventFallbackState else { return } + guard !state.typedRun.isEmpty else { return } + state.typedRun.removeLast() + self.eventFallbackState = state + self.scheduleEventFallbackProcessing() + } + + private func handleEventFallbackCharacters(_ characters: String?) { + guard var state = self.eventFallbackState, + let characters, + !characters.isEmpty + else { + return + } + + if characters.rangeOfCharacter(from: .newlines) != nil || + characters.rangeOfCharacter(from: .whitespaces) != nil + { + self.flushAndClearEventFallbackTypedRun() + return + } + + let sanitized = characters.filter { character in + !character.isNewline && !character.isWhitespace && !character.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) } + } + guard !sanitized.isEmpty else { return } + + state.typedRun += String(sanitized) + if state.typedRun.count > self.eventFallbackMaxTypedReplacementLength { + self.log("EventFallbackTypedRunCleared_OverLength") + state.typedRun = "" + } + self.eventFallbackState = state + self.scheduleEventFallbackProcessing() + } + + private func flushAndClearEventFallbackTypedRun() { + _ = self.processEventFallbackTypedRun(allowPotentialPrefixCompletion: true) + self.eventFallbackState?.typedRun = "" + self.eventFallbackProcessingTimer?.cancel() + self.eventFallbackProcessingTimer = nil + } + + private func scheduleEventFallbackProcessing() { + self.eventFallbackProcessingTimer?.cancel() + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + self.eventFallbackProcessingDebounceSeconds) + timer.setEventHandler { [weak self] in + guard let self else { return } + self.eventFallbackProcessingTimer = nil + let outcome = self.processEventFallbackTypedRun(allowPotentialPrefixCompletion: false) + if outcome != .deferred { + self.eventFallbackState?.typedRun = "" + } + } + timer.resume() + self.eventFallbackProcessingTimer = timer + } + + private func processEventFallbackTypedRun(allowPotentialPrefixCompletion: Bool) -> EventFallbackProcessingOutcome { + guard SettingsStore.shared.autoLearnCustomDictionaryEnabled, + let state = self.eventFallbackState + else { + return .skipped + } + + let typedReplacement = state.typedRun.trimmingCharacters(in: .whitespacesAndNewlines) + guard !typedReplacement.isEmpty else { return .skipped } + + if !allowPotentialPrefixCompletion, + self.shouldDeferPotentialCorrectionCompletion( + insertedText: state.insertedText, + typedReplacement: typedReplacement, + allowsOrdinaryPrefix: true + ) + { + self.log("EventFallbackDeferred_PotentialPrefix: typedChars=\(typedReplacement.count).") + return .deferred + } + + if !allowPotentialPrefixCompletion, + self.singleLetterCaseCorrectionCandidate( + insertedText: state.insertedText, + replacement: typedReplacement + ) != nil + { + self.log("EventFallbackDeferred_SingleLetterCase: typedChars=\(typedReplacement.count).") + return .deferred + } + + guard let correction = self.eventFallbackCorrection( + insertedText: state.insertedText, + typedReplacement: typedReplacement + ) else { + self.log("EventFallbackSkipped_NoInference: typedChars=\(typedReplacement.count).") + return .skipped + } + + self.recordObservation(original: correction.original, replacement: correction.replacement) + self.log( + "EventFallbackRecorded: originalChars=\(correction.original.count), replacementChars=\(correction.replacement.count), highSignal=\(self.isHighSignalReplacement(correction.replacement))." + ) + return .recorded + } + + private func finalize() { + guard self.isActive else { return } + if let monitoredElement = self.monitoredElement, + let currentText = self.currentText(from: monitoredElement) + { + self.lastKnownText = currentText + } + self.processCurrentCorrections() + self.stopMonitoring() + self.insertedText = "" + self.baselineText = "" + self.lastKnownText = "" + self.recordedSessionObservationCounts = [:] + } + + private func currentText(from element: AXUIElement) -> String? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, &value) == .success else { + return nil + } + return value as? String + } + + private func eventFallbackCorrection( + insertedText: String, + typedReplacement: String + ) -> CorrectionDiffEngine.Candidate? { + let replacement = typedReplacement.trimmingCharacters(in: .whitespacesAndNewlines) + guard !replacement.isEmpty, + replacement.count <= self.eventFallbackMaxTypedReplacementLength + else { + return nil + } + + let isHighSignalReplacement = self.isHighSignalReplacement(replacement) + + if let singleLetterCaseCorrection = self.singleLetterCaseCorrectionCandidate( + insertedText: insertedText, + replacement: replacement + ) { + return singleLetterCaseCorrection + } + + let candidates = self.eventFallbackOriginalCandidates( + insertedText: insertedText, + replacement: replacement + ) + guard let best = candidates.min(by: { lhs, rhs in + if lhs.score != rhs.score { return lhs.score < rhs.score } + return lhs.candidate.original.count > rhs.candidate.original.count + }) else { + return nil + } + + let equallyGoodDifferentOriginals = candidates.filter { + abs($0.score - best.score) < 0.0001 && + self.triggerPhrase($0.candidate.original) != self.triggerPhrase(best.candidate.original) + } + guard equallyGoodDifferentOriginals.isEmpty else { + self.log("EventFallbackSkipped_AmbiguousOriginal") + return nil + } + + let isAllowedOrdinaryCorrection = + self.isOrdinaryCaseOnlyEventFallbackCorrection(best.candidate) || + self.isCompactEquivalentEventFallbackCorrection(best.candidate) || + self.isDiacriticEquivalentEventFallbackCorrection(best.candidate) + + if !isHighSignalReplacement, + !isAllowedOrdinaryCorrection + { + self.log("EventFallbackSkipped_NotHighSignal") + return nil + } + + return best.candidate + } + + private typealias EventFallbackOriginalCandidate = ( + candidate: CorrectionDiffEngine.Candidate, + score: Double + ) + + private func isOrdinaryCaseOnlyEventFallbackCorrection(_ candidate: CorrectionDiffEngine.Candidate) -> Bool { + let original = self.triggerPhrase(candidate.original) + let replacement = candidate.replacement.trimmingCharacters(in: .whitespacesAndNewlines) + guard original.count >= 4, replacement.count >= 4 else { return false } + guard self.normalizePhrase(original) == self.normalizePhrase(replacement) else { return false } + return original.caseInsensitiveCompare(replacement) == .orderedSame && original != replacement + } + + private func isCompactEquivalentEventFallbackCorrection(_ candidate: CorrectionDiffEngine.Candidate) -> Bool { + let original = self.triggerPhrase(candidate.original) + let replacement = candidate.replacement.trimmingCharacters(in: .whitespacesAndNewlines) + guard original.count >= 4, replacement.count >= 4 else { return false } + guard self.compactLearningKey(original) == self.compactLearningKey(replacement) else { return false } + guard original.caseInsensitiveCompare(replacement) != .orderedSame else { return false } + + return self.learningTokens(original).count > 1 || + original.rangeOfCharacter(from: self.compactEquivalentSeparatorCharacters) != nil || + replacement.rangeOfCharacter(from: self.compactEquivalentSeparatorCharacters) != nil + } + + private func isDiacriticEquivalentEventFallbackCorrection(_ candidate: CorrectionDiffEngine.Candidate) -> Bool { + let original = self.triggerPhrase(candidate.original) + let replacement = candidate.replacement.trimmingCharacters(in: .whitespacesAndNewlines) + guard original.count >= 4, replacement.count >= 4 else { return false } + guard self.containsDiacritic(original) || self.containsDiacritic(replacement) else { return false } + guard self.diacriticInsensitiveCompactKey(original) == self.diacriticInsensitiveCompactKey(replacement) else { + return false + } + + return original.caseInsensitiveCompare(replacement) != .orderedSame + } + + private var compactEquivalentSeparatorCharacters: CharacterSet { + CharacterSet(charactersIn: "-_./&+'’‐‑‒–—") + } + + private func singleLetterCaseCorrectionCandidate( + insertedText: String, + replacement: String + ) -> CorrectionDiffEngine.Candidate? { + let typedReplacement = replacement.trimmingCharacters(in: .whitespacesAndNewlines) + guard typedReplacement.count == 1, + let typedCharacter = typedReplacement.first, + typedCharacter.isUppercase, + typedReplacement.unicodeScalars.allSatisfy({ CharacterSet.uppercaseLetters.contains($0) }) + else { + return nil + } + + var uniqueCandidates: [String: CorrectionDiffEngine.Candidate] = [:] + let typedKey = typedReplacement.lowercased() + let tokens = CorrectionDiffEngine.learningTokenTexts(in: insertedText) + + for token in tokens { + guard token.count >= 4, + let originalFirstCharacter = token.first, + String(originalFirstCharacter).lowercased() == typedKey, + String(originalFirstCharacter) != typedReplacement + else { + continue + } + + let replacementToken = typedReplacement + String(token.dropFirst()) + let candidate = CorrectionDiffEngine.Candidate( + original: token, + replacement: replacementToken + ) + guard self.shouldTrack(candidate, editedText: replacementToken), + self.isOrdinaryCaseOnlyEventFallbackCorrection(candidate) || + self.isHighSignalReplacement(replacementToken) + else { + continue + } + + uniqueCandidates[self.sessionObservationKey(original: candidate.original, replacement: candidate.replacement)] = candidate + } + + return uniqueCandidates.count == 1 ? uniqueCandidates.values.first : nil + } + + private func shouldDeferPotentialCorrectionCompletion( + insertedText: String, + typedReplacement: String, + allowsOrdinaryPrefix: Bool + ) -> Bool { + guard allowsOrdinaryPrefix || self.isHighSignalReplacement(typedReplacement) else { return false } + + let replacementKey = self.compactLearningKey(typedReplacement) + guard replacementKey.count >= 3 else { return false } + + let tokens = CorrectionDiffEngine.learningTokenTexts(in: insertedText) + guard tokens.count > 1 else { return false } + + let maxSpanLength = min(self.maxSegmentTokenCount, tokens.count) + guard maxSpanLength > 1 else { return false } + + for spanLength in 2...maxSpanLength { + for startIndex in 0...(tokens.count - spanLength) { + let endIndex = startIndex + spanLength + let spanKey = self.compactLearningKey(tokens[startIndex.. replacementKey.count, + spanKey.hasPrefix(replacementKey) + { + return true + } + } + } + + return false + } + + private func eventFallbackOriginalCandidates( + insertedText: String, + replacement: String + ) -> [EventFallbackOriginalCandidate] { + let tokens = CorrectionDiffEngine.learningTokenTexts(in: insertedText) + guard !tokens.isEmpty else { return [] } + + let replacementKey = self.normalizePhrase(replacement) + guard !replacementKey.isEmpty else { return [] } + + var seenOriginals = Set() + var candidates: [EventFallbackOriginalCandidate] = [] + let maxSpanLength = min(self.maxSegmentTokenCount, tokens.count) + + for spanLength in 1...maxSpanLength { + for startIndex in 0...(tokens.count - spanLength) { + let endIndex = startIndex + spanLength + let original = tokens[startIndex.. 0 else { continue } + + let distance = self.levenshteinDistance(originalKey, replacementKey) + let score = Double(distance) / Double(maxLength) + candidates.append((candidate: candidate, score: score)) + } + } + + return candidates + } + + private func correctionCandidates( + baseline: String, + currentText: String, + insertedText: String + ) -> [CorrectionDiffEngine.Candidate] { + let baselineTokenCount = self.diffTokenCount(baseline) + let currentTokenCount = self.diffTokenCount(currentText) + if baselineTokenCount <= self.maxFullDiffTokenCount, + currentTokenCount <= self.maxFullDiffTokenCount + { + return CorrectionDiffEngine.findCorrectionCandidates( + original: baseline, + edited: currentText, + maxSegmentTokenCount: self.maxSegmentTokenCount + ) + } + + self.log( + "DiffSkipped_OverTokenLimit: baselineTokens=\(baselineTokenCount), currentTokens=\(currentTokenCount); attempting scoped diff." + ) + + guard let scopedText = self.scopedDiffText( + baseline: baseline, + currentText: currentText, + insertedText: insertedText + ) else { + return [] + } + + let scopedBaselineTokenCount = self.diffTokenCount(scopedText.baseline) + let scopedCurrentTokenCount = self.diffTokenCount(scopedText.current) + guard scopedBaselineTokenCount <= self.maxFullDiffTokenCount, + scopedCurrentTokenCount <= self.maxFullDiffTokenCount + else { + self.log( + "DiffSkipped_ScopedWindowOverTokenLimit: baselineTokens=\(scopedBaselineTokenCount), currentTokens=\(scopedCurrentTokenCount)." + ) + return [] + } + + return CorrectionDiffEngine.findCorrectionCandidates( + original: scopedText.baseline, + edited: scopedText.current, + maxSegmentTokenCount: self.maxSegmentTokenCount + ) + } + + private func scopedDiffText( + baseline: String, + currentText: String, + insertedText: String + ) -> (baseline: String, current: String)? { + let inserted = insertedText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !inserted.isEmpty else { + self.log("DiffSkipped_EmptyInsertedText") + return nil + } + + let insertedRangeResult = self.uniqueRange(of: inserted, in: baseline) + guard case let .found(insertedRange) = insertedRangeResult else { + switch insertedRangeResult { + case .ambiguous: + self.log("DiffSkipped_AmbiguousOccurrence") + case .notFound: + self.log("DiffSkipped_InsertedTextNotFound") + case .found: + break + } + return nil + } + + let baselineWindowRange = self.expandedRange( + in: baseline, + around: insertedRange, + contextCharacters: self.scopedDiffContextCharacterCount + ) + let prefixAnchor = String(baseline[baselineWindowRange.lowerBound..) + case notFound + case ambiguous + } + + private func uniqueRange(of needle: String, in haystack: String) -> UniqueRangeResult { + self.uniqueRange(of: needle, in: haystack, range: haystack.startIndex.. + ) -> UniqueRangeResult { + guard let firstRange = haystack.range(of: needle, range: searchRange) else { + return .notFound + } + + if haystack.range(of: needle, range: firstRange.upperBound.., + contextCharacters: Int + ) -> Range { + let lowerDistance = min(contextCharacters, text.distance(from: text.startIndex, to: range.lowerBound)) + let upperDistance = min(contextCharacters, text.distance(from: range.upperBound, to: text.endIndex)) + let lowerBound = text.index(range.lowerBound, offsetBy: -lowerDistance) + let upperBound = text.index(range.upperBound, offsetBy: upperDistance) + return lowerBound.. Range? { + let startIndex: String.Index + let suffixSearchStart: String.Index + if prefixAnchor.isEmpty { + startIndex = currentText.startIndex + suffixSearchStart = currentText.startIndex + } else { + let prefixResult = self.uniqueRange(of: prefixAnchor, in: currentText) + guard case let .found(prefixRange) = prefixResult else { + switch prefixResult { + case .ambiguous: + self.log("DiffSkipped_AmbiguousPrefixAnchor") + case .notFound: + self.log("DiffSkipped_PrefixAnchorNotFound") + case .found: + break + } + return nil + } + startIndex = prefixRange.lowerBound + suffixSearchStart = prefixRange.upperBound + } + + let endIndex: String.Index + if suffixAnchor.isEmpty { + endIndex = currentText.endIndex + } else { + let searchRange = suffixSearchStart.. Int { + text.components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .count + } + + private func log(_ message: String) { + DebugLogger.shared.debug(message, source: "AutoLearnDictionary") + } + + private func processCurrentCorrections() { + guard self.isActive else { return } + guard SettingsStore.shared.autoLearnCustomDictionaryEnabled else { return } + + let baseline = self.baselineText + let currentText = self.lastKnownText + let insertedText = self.insertedText + + guard baseline != currentText else { return } + + let corrections = self.correctionCandidates( + baseline: baseline, + currentText: currentText, + insertedText: insertedText + ) + + guard !corrections.isEmpty else { return } + + var observationCounts: [String: (correction: CorrectionDiffEngine.Candidate, count: Int)] = [:] + + for correction in corrections where + self.isCorrectionFromInsertedText(correction, insertedText: insertedText) && + self.shouldTrack(correction, editedText: currentText) + { + if self.shouldDeferPotentialCorrectionCompletion( + insertedText: insertedText, + typedReplacement: correction.replacement, + allowsOrdinaryPrefix: false + ) { + self.log("CorrectionDeferred_PotentialPrefix: replacementChars=\(correction.replacement.count).") + continue + } + let observationKey = self.sessionObservationKey( + original: correction.original, + replacement: correction.replacement + ) + var observation = observationCounts[observationKey] ?? (correction: correction, count: 0) + observation.count += 1 + observationCounts[observationKey] = observation + } + + for (observationKey, observation) in observationCounts { + let alreadyRecordedCount = self.recordedSessionObservationCounts[observationKey, default: 0] + guard observation.count > alreadyRecordedCount else { continue } + + for _ in alreadyRecordedCount.. String { + "\(self.triggerPhrase(original))\u{1F}\(replacement.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())" + } + + private func shouldTrack(_ candidate: CorrectionDiffEngine.Candidate, editedText: String) -> Bool { + let originalText = self.triggerPhrase(candidate.original) + let originalKey = self.normalizePhrase(candidate.original) + let replacement = candidate.replacement.trimmingCharacters(in: .whitespacesAndNewlines) + let replacementKey = self.normalizePhrase(replacement) + let signal = self.suggestionSignal( + original: originalText, + replacement: replacement + ) + let isSameKeyCorrection = originalKey == replacementKey + + guard !originalText.isEmpty, !originalKey.isEmpty, !replacement.isEmpty, !replacementKey.isEmpty else { return false } + guard !self.hasUnsupportedReplacementBoundary(replacement) else { return false } + guard !self.isMismatchedApostropheRewrite(original: originalText, replacement: replacement) else { return false } + guard !isSameKeyCorrection || signal != nil else { return false } + guard originalText.count <= 64, replacement.count <= 64 else { return false } + guard isSameKeyCorrection || self.looksLikeARealCorrection(original: originalKey, replacement: replacementKey) else { return false } + guard !self.mappingAlreadyExists(original: originalText, replacement: replacement) else { return false } + + return true + } + + private func mappingAlreadyExists(original: String, replacement: String) -> Bool { + SettingsStore.shared.customDictionaryEntries.contains { entry in + entry.replacement.caseInsensitiveCompare(replacement) == .orderedSame && + entry.triggers.contains(original) + } + } + + private func recordObservation(original: String, replacement: String) { + let normalizedOriginal = self.triggerPhrase(original) + let normalizedReplacement = replacement.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedOriginal.isEmpty, !normalizedReplacement.isEmpty else { return } + + var suggestions = SettingsStore.shared.autoLearnCustomDictionarySuggestions + let now = Date() + + if let index = suggestions.firstIndex(where: { + self.triggerPhrase($0.originalText) == normalizedOriginal && + $0.replacement.caseInsensitiveCompare(normalizedReplacement) == .orderedSame + }) { + let previousOccurrences = suggestions[index].occurrences + suggestions[index].occurrences += 1 + suggestions[index].lastObservedAt = now + if suggestions[index].status == .dismissed { + let dismissedAt = suggestions[index].dismissedAtOccurrenceCount ?? previousOccurrences + suggestions[index].dismissedAtOccurrenceCount = dismissedAt + + if suggestions[index].occurrences - dismissedAt >= self.displayThreshold(forReplacement: normalizedReplacement) { + suggestions[index].status = .pending + suggestions[index].dismissedAtOccurrenceCount = nil + } + } + } else { + suggestions.append( + SettingsStore.AutoLearnSuggestion( + originalText: normalizedOriginal, + replacement: normalizedReplacement, + occurrences: 1, + lastObservedAt: now, + status: .pending + ) + ) + } + + suggestions.sort { lhs, rhs in + if lhs.status != rhs.status { + return lhs.status == .pending + } + if lhs.occurrences != rhs.occurrences { + return lhs.occurrences > rhs.occurrences + } + return lhs.lastObservedAt > rhs.lastObservedAt + } + + SettingsStore.shared.autoLearnCustomDictionarySuggestions = suggestions + } + + private func looksLikeARealCorrection(original: String, replacement: String) -> Bool { + let distance = self.levenshteinDistance(original, replacement) + let maxLength = max(original.count, replacement.count) + guard maxLength > 0 else { return false } + + // Ratio-based threshold: allow higher edit distances for longer + // (multi-word) phrases. Single-token swaps use a tighter ratio. + let isMultiWord = original.contains(" ") || replacement.contains(" ") + let maxRatio: Double = isMultiWord ? 0.65 : 0.50 + let ratio = Double(distance) / Double(maxLength) + return ratio <= maxRatio + } + + private func hasUnsupportedReplacementBoundary(_ replacement: String) -> Bool { + let trimmedReplacement = replacement.trimmingCharacters(in: .whitespacesAndNewlines) + guard let firstScalar = trimmedReplacement.unicodeScalars.first, + let lastScalar = trimmedReplacement.unicodeScalars.last + else { + return false + } + + let unsupportedBoundaryCharacters = CharacterSet(charactersIn: #"()[]{}"'“”‘’"#) + return unsupportedBoundaryCharacters.contains(firstScalar) || + unsupportedBoundaryCharacters.contains(lastScalar) + } + + private func isMismatchedApostropheRewrite(original: String, replacement: String) -> Bool { + let apostropheCharacters = CharacterSet(charactersIn: "'’") + guard replacement.rangeOfCharacter(from: apostropheCharacters) != nil else { + return false + } + + return self.compactLearningKey(original) != self.compactLearningKey(replacement) + } + + private func isCorrectionFromInsertedText( + _ candidate: CorrectionDiffEngine.Candidate, + insertedText: String + ) -> Bool { + // Use token-sequence containment to avoid learning edits outside the + // most recent insertion without adding fragile editor-specific ranges. + // If the same phrase appears elsewhere in the field, the review gate + // still protects users from accepting an unwanted replacement. + let insertedTokens = self.learningTokens(insertedText) + let originalTokens = self.learningTokens(candidate.original) + guard !insertedTokens.isEmpty, !originalTokens.isEmpty else { return false } + guard originalTokens.count <= insertedTokens.count else { return false } + + for startIndex in 0...(insertedTokens.count - originalTokens.count) { + let endIndex = startIndex + originalTokens.count + if Array(insertedTokens[startIndex.. [String] { + text + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + } + + func displayThreshold(for suggestion: SettingsStore.AutoLearnSuggestion) -> Int { + self.displayThreshold(forReplacement: suggestion.replacement) + } + + func displayThreshold(forReplacement replacement: String) -> Int { + self.isHighSignalReplacement(replacement) + ? 1 + : self.minimumSuggestionOccurrences + } + + private enum SuggestionSignal { + case ordinary + case high + } + + private func suggestionSignal(original: String, replacement: String) -> SuggestionSignal? { + guard !original.isEmpty, !replacement.isEmpty else { return nil } + guard original != replacement else { return nil } + guard self.normalizePhrase(original) == self.normalizePhrase(replacement) else { return nil } + + if self.isHighSignalReplacement(replacement) { + return .high + } + + return original.caseInsensitiveCompare(replacement) == .orderedSame ? .ordinary : nil + } + + func isHighSignalReplacement(_ replacement: String) -> Bool { + let replacementTokens = self.replacementSignalTokens(replacement) + return replacementTokens.contains { token in + let uppercaseCount = token.unicodeScalars.filter { CharacterSet.uppercaseLetters.contains($0) }.count + return uppercaseCount > 1 || + token.dropFirst().contains(where: { $0.isUppercase }) + } || + replacement.rangeOfCharacter(from: .decimalDigits) != nil || + replacement.rangeOfCharacter(from: self.compactEquivalentSeparatorCharacters) != nil + } + + private func replacementSignalTokens(_ text: String) -> [String] { + text + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + } + + private func triggerPhrase(_ text: String) -> String { + text + .lowercased() + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func normalizePhrase(_ text: String) -> String { + text + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func compactLearningKey(_ text: String) -> String { + text + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined() + } + + private func diacriticInsensitiveCompactKey(_ text: String) -> String { + self.compactLearningKey( + text.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .current + ) + ) + } + + private func containsDiacritic(_ text: String) -> Bool { + text.folding(options: .diacriticInsensitive, locale: .current) != text + } + + private func levenshteinDistance(_ lhs: String, _ rhs: String) -> Int { + let lhsChars = Array(lhs) + let rhsChars = Array(rhs) + guard !lhsChars.isEmpty else { return rhsChars.count } + guard !rhsChars.isEmpty else { return lhsChars.count } + + var previous = Array(0...rhsChars.count) + for (lhsIndex, lhsChar) in lhsChars.enumerated() { + var current = [lhsIndex + 1] + current.reserveCapacity(rhsChars.count + 1) + + for (rhsIndex, rhsChar) in rhsChars.enumerated() { + let insertion = current[rhsIndex] + 1 + let deletion = previous[rhsIndex + 1] + 1 + let substitution = previous[rhsIndex] + (lhsChar == rhsChar ? 0 : 1) + current.append(min(insertion, deletion, substitution)) + } + + previous = current + } + + return previous[rhsChars.count] + } + + var minimumSuggestionOccurrences: Int { + self.suggestionThreshold + } +} + +#if DEBUG +extension AutoLearnDictionaryService { + struct EventFallbackInferenceResult: Equatable { + let original: String + let replacement: String + } + + func shouldTrackForTesting(original: String, replacement: String) -> Bool { + self.shouldTrack( + CorrectionDiffEngine.Candidate(original: original, replacement: replacement), + editedText: replacement + ) + } + + func recordObservationForTesting(original: String, replacement: String) { + self.recordObservation(original: original, replacement: replacement) + } + + func beginEventFallbackSessionForTesting(insertedText: String, targetPID: pid_t? = nil) { + self.finalize() + self.finalizeEventFallback() + self.eventFallbackState = EventFallbackMonitoringState( + insertedText: insertedText, + targetPID: targetPID, + targetFrontmostPID: nil, + targetBundleIdentifier: nil + ) + } + + func setEventFallbackTypedRunForTesting(_ typedRun: String) { + self.eventFallbackState?.typedRun = typedRun + } + + func flushEventFallbackTypedRunForTesting() { + self.flushAndClearEventFallbackTypedRun() + } + + func processEventFallbackDebouncedTypedRunForTesting() { + let outcome = self.processEventFallbackTypedRun(allowPotentialPrefixCompletion: false) + if outcome != .deferred { + self.eventFallbackState?.typedRun = "" + } + } + + func inferEventFallbackCorrectionForTesting( + insertedText: String, + typedReplacement: String + ) -> EventFallbackInferenceResult? { + self.eventFallbackCorrection( + insertedText: insertedText, + typedReplacement: typedReplacement + ).map { + EventFallbackInferenceResult( + original: $0.original, + replacement: $0.replacement + ) + } + } + + func recordEventFallbackReplacementForTesting(insertedText: String, typedReplacement: String) { + guard let correction = self.eventFallbackCorrection( + insertedText: insertedText, + typedReplacement: typedReplacement + ) else { + return + } + + self.recordObservation(original: correction.original, replacement: correction.replacement) + } + + func recordCorrectionsForTesting(insertedText: String, baselineText: String, currentText: String) { + let corrections = self.correctionCandidates( + baseline: baselineText, + currentText: currentText, + insertedText: insertedText + ) + + for correction in corrections + where self.isCorrectionFromInsertedText(correction, insertedText: insertedText) && + self.shouldTrack(correction, editedText: currentText) + { + if self.shouldDeferPotentialCorrectionCompletion( + insertedText: insertedText, + typedReplacement: correction.replacement, + allowsOrdinaryPrefix: false + ) { + continue + } + self.recordObservation(original: correction.original, replacement: correction.replacement) + } + } + + func recordCorrectionsDuringSessionForTesting( + insertedText: String, + baselineText: String, + currentText: String, + processingPasses: Int + ) { + self.stopMonitoring() + self.insertedText = insertedText + self.baselineText = baselineText + self.lastKnownText = currentText + self.recordedSessionObservationCounts = [:] + self.isActive = true + + defer { + self.stopMonitoring() + self.insertedText = "" + self.baselineText = "" + self.lastKnownText = "" + self.recordedSessionObservationCounts = [:] + } + + for _ in 0.. pid_t? { + self.monitoringActivationPID(elementPID: elementPID, targetPID: targetPID) + } + + func finalizeForTesting() { + self.finalize() + } +} +#endif diff --git a/Sources/Fluid/Services/CorrectionDiffEngine.swift b/Sources/Fluid/Services/CorrectionDiffEngine.swift new file mode 100644 index 0000000..f70d893 --- /dev/null +++ b/Sources/Fluid/Services/CorrectionDiffEngine.swift @@ -0,0 +1,211 @@ +import Foundation + +enum CorrectionDiffEngine { + struct Candidate: Equatable { + let original: String + let replacement: String + } + + private struct DiffToken: Equatable { + let text: String + + var matchKey: String { + self.text.lowercased() + } + } + + static func findCorrectionCandidates( + original: String, + edited: String, + maxSegmentTokenCount: Int = 3 + ) -> [Candidate] { + let originalTokens = tokenize(original) + let editedTokens = tokenize(edited) + guard !originalTokens.isEmpty, !editedTokens.isEmpty else { return [] } + + // Guard against quadratic DP blow-up. The LCS matrix is O(n*m) in + // both time and memory. 500 tokens is generous for a dictation segment; + // anything larger would stall the main queue for no meaningful gain. + let maxTokenCount = 500 + guard originalTokens.count <= maxTokenCount, editedTokens.count <= maxTokenCount else { return [] } + + let anchorPairs = lcsIndexPairs(originalTokens, editedTokens) + var candidates: [Candidate] = [] + var originalIndex = 0 + var editedIndex = 0 + + for (anchorOriginal, anchorEdited) in anchorPairs { + let originalSegment = Array(originalTokens[originalIndex.. [String] { + Self.tokenize(text).map(\.text) + } + + private static func buildCandidate( + originalSegment: [DiffToken], + editedSegment: [DiffToken], + maxSegmentTokenCount: Int + ) -> Candidate? { + guard !originalSegment.isEmpty, !editedSegment.isEmpty else { return nil } + guard originalSegment.count <= maxSegmentTokenCount, editedSegment.count <= maxSegmentTokenCount else { return nil } + + let originalPhrase = originalSegment.map(\.text).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + let editedPhrase = editedSegment.map(\.text).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + + guard !originalPhrase.isEmpty, !editedPhrase.isEmpty else { return nil } + guard originalPhrase.caseInsensitiveCompare(editedPhrase) != .orderedSame else { return nil } + + return Candidate(original: originalPhrase, replacement: editedPhrase) + } + + private static func buildCaseOnlyCandidate(originalToken: DiffToken, editedToken: DiffToken) -> Candidate? { + guard originalToken.text != editedToken.text else { return nil } + guard originalToken.text.caseInsensitiveCompare(editedToken.text) == .orderedSame else { return nil } + return Candidate(original: originalToken.text, replacement: editedToken.text) + } + + private static func lcsIndexPairs(_ lhs: [DiffToken], _ rhs: [DiffToken]) -> [(Int, Int)] { + let lhsCount = lhs.count + let rhsCount = rhs.count + guard lhsCount > 0, rhsCount > 0 else { return [] } + + var dp = Array( + repeating: Array(repeating: 0, count: rhsCount + 1), + count: lhsCount + 1 + ) + + for lhsIndex in 1...lhsCount { + for rhsIndex in 1...rhsCount { + if lhs[lhsIndex - 1].matchKey == rhs[rhsIndex - 1].matchKey { + dp[lhsIndex][rhsIndex] = dp[lhsIndex - 1][rhsIndex - 1] + 1 + } else { + dp[lhsIndex][rhsIndex] = max(dp[lhsIndex - 1][rhsIndex], dp[lhsIndex][rhsIndex - 1]) + } + } + } + + var pairs: [(Int, Int)] = [] + var lhsIndex = lhsCount + var rhsIndex = rhsCount + + while lhsIndex > 0 && rhsIndex > 0 { + if lhs[lhsIndex - 1].matchKey == rhs[rhsIndex - 1].matchKey { + pairs.append((lhsIndex - 1, rhsIndex - 1)) + lhsIndex -= 1 + rhsIndex -= 1 + } else if dp[lhsIndex - 1][rhsIndex] > dp[lhsIndex][rhsIndex - 1] { + lhsIndex -= 1 + } else { + rhsIndex -= 1 + } + } + + return pairs.reversed() + } + + private nonisolated static func tokenize(_ text: String) -> [DiffToken] { + text.components(separatedBy: .whitespacesAndNewlines) + .map(Self.tokenText) + .filter { !$0.isEmpty } + .map(DiffToken.init(text:)) + } + + private nonisolated static func tokenText(_ rawToken: String) -> String { + let characters = Array(rawToken.trimmingCharacters(in: .whitespacesAndNewlines)) + guard !characters.isEmpty else { return "" } + + var startIndex = 0 + var endIndex = characters.count + + while startIndex < endIndex, + Self.shouldTrimLeading(characters[startIndex], in: characters, at: startIndex, endIndex: endIndex) { + startIndex += 1 + } + + while endIndex > startIndex, + Self.shouldTrimTrailing(characters[endIndex - 1], in: characters, startIndex: startIndex, at: endIndex - 1) { + endIndex -= 1 + } + + guard startIndex < endIndex else { return "" } + return String(characters[startIndex.. Bool { + guard Self.isPunctuation(character) else { return false } + guard Self.isTechnicalLeadingEdge(character), + index + 1 < endIndex, + characters[index + 1].isLetter || characters[index + 1].isNumber + else { + return true + } + + return false + } + + private nonisolated static func shouldTrimTrailing( + _ character: Character, + in characters: [Character], + startIndex: Int, + at index: Int + ) -> Bool { + guard Self.isPunctuation(character) else { return false } + guard Self.isTechnicalTrailingEdge(character), + index > startIndex, + characters[index - 1].isLetter || characters[index - 1].isNumber || Self.isTechnicalTrailingEdge(characters[index - 1]) + else { + return true + } + + return false + } + + private nonisolated static func isPunctuation(_ character: Character) -> Bool { + character.unicodeScalars.allSatisfy { CharacterSet.punctuationCharacters.contains($0) } + } + + private nonisolated static func isTechnicalLeadingEdge(_ character: Character) -> Bool { + ".+$#".contains(character) + } + + private nonisolated static func isTechnicalTrailingEdge(_ character: Character) -> Bool { + "+#%".contains(character) + } +} diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 4b7423c..ca14a78 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -306,6 +306,9 @@ final class GlobalHotkeyManager: NSObject { let eventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.flagsChanged.rawValue) + | (1 << CGEventType.leftMouseDown.rawValue) + | (1 << CGEventType.rightMouseDown.rawValue) + | (1 << CGEventType.otherMouseDown.rawValue) self.eventTap = CGEvent.tapCreate( tap: .cgSessionEventTap, @@ -368,6 +371,11 @@ final class GlobalHotkeyManager: NSObject { return Unmanaged.passUnretained(event) } + if type == .leftMouseDown || type == .rightMouseDown || type == .otherMouseDown { + AutoLearnDictionaryService.shared.handleObservedMouseDown() + return Unmanaged.passUnretained(event) + } + let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) let flags = event.flags @@ -396,6 +404,11 @@ final class GlobalHotkeyManager: NSObject { Task { await PostTranscriptionEditTracker.shared.handleKeyDown(keyCode: keyCode, modifiers: eventModifiers) } + AutoLearnDictionaryService.shared.handleObservedKeyDown( + keyCode: keyCode, + modifiers: eventModifiers, + characters: NSEvent(cgEvent: event)?.characters + ) // Check the configured cancel shortcut first. if SettingsStore.shared.cancelRecordingHotkeyShortcut.matches(keyCode: keyCode, modifiers: eventModifiers) { diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index b097657..16f0e64 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -39,6 +39,13 @@ final class TypingService { let selectedRange: CFRange? let appScriptValue: String? let appScriptSelectedRange: CFRange? + + var canVerifyInsertion: Bool { + self.value != nil || + self.selectedRange != nil || + self.appScriptValue != nil || + self.appScriptSelectedRange != nil + } } private enum PasteVerificationResult: String { @@ -54,6 +61,9 @@ final class TypingService { private static let pasteboardSessionSemaphore = DispatchSemaphore(value: 1) private static let pasteboardRestoreQueue = DispatchQueue(label: "TypingService.PasteboardRestore", qos: .utility) private static var focusSnapshot: FocusSnapshot? + private static let reliablePasteVerificationTimeoutMicros: useconds_t = 5_000_000 + private static let standardVerifyTimeoutMicros: useconds_t = 750_000 + private static let standardUnverifiedSettleMicros: useconds_t = 200_000 private var textInsertionMode: SettingsStore.TextInsertionMode { SettingsStore.shared.textInsertionMode @@ -224,41 +234,47 @@ final class TypingService { // MARK: - Public API - func typeTextInstantly(_ text: String) { - self.typeTextInstantly(text, preferredTargetPID: nil) + @discardableResult + func typeTextInstantly(_ text: String, onComplete: (() -> Void)? = nil) -> Bool { + self.typeTextInstantly(text, preferredTargetPID: nil, onComplete: onComplete) } /// Types/inserts text, optionally preferring a specific target PID for CGEvent posting. /// This helps when our overlay temporarily has focus; we can still target the original app. - func typeTextInstantly(_ text: String, preferredTargetPID: pid_t?) { + @discardableResult + func typeTextInstantly(_ text: String, preferredTargetPID: pid_t?, onComplete: (() -> Void)? = nil) -> Bool { self.log("[TypingService] ENTRY: typeTextInstantly called with text length: \(text.count)") self.log("[TypingService] Text preview: \"\(String(text.prefix(100)))\"") guard text.isEmpty == false else { self.log("[TypingService] ERROR: Empty text provided, aborting") - return + return false } // Prevent concurrent typing operations guard !self.isCurrentlyTyping else { self.log("[TypingService] WARNING: Skipping text injection - already in progress") - return + return false } // Check accessibility permissions first guard AXIsProcessTrusted() else { self.log("[TypingService] ERROR: Accessibility permissions required for text injection") self.log("[TypingService] Current accessibility status: \(AXIsProcessTrusted())") - return + return false } self.log("[TypingService] Accessibility check passed, proceeding with text injection") self.isCurrentlyTyping = true + let insertionMode = self.textInsertionMode DispatchQueue.global(qos: .userInitiated).async { defer { self.isCurrentlyTyping = false self.log("[TypingService] Typing operation completed, isCurrentlyTyping set to false") + DispatchQueue.main.async { + onComplete?() + } } self.log("[TypingService] Starting async text insertion process") @@ -270,8 +286,16 @@ final class TypingService { usleep(200_000) } self.log("[TypingService] Delay completed, calling insertTextInstantly") + let focusedTextSnapshot = self.captureFocusedTextSnapshot() self.insertTextInstantly(text, preferredTargetPID: preferredTargetPID) + self.waitForInsertionToSettle( + from: focusedTextSnapshot, + expectedText: text, + insertionMode: insertionMode + ) } + + return true } // MARK: - Internal insertion pipeline @@ -571,6 +595,47 @@ final class TypingService { return true } + private func waitForInsertionToSettle( + from snapshot: FocusedTextSnapshot?, + expectedText: String, + insertionMode: SettingsStore.TextInsertionMode + ) { + guard let snapshot else { + usleep(Self.insertionSettleTimeoutMicros( + insertionMode: insertionMode, + canVerifyFocusedText: false + )) + return + } + + let timeoutMicros = Self.insertionSettleTimeoutMicros( + insertionMode: insertionMode, + canVerifyFocusedText: snapshot.canVerifyInsertion + ) + let result = self.waitForFocusedTextVerification( + from: snapshot, + expectedText: expectedText, + timeoutMicros: timeoutMicros + ) + self.log("[TypingService] Completion settle verification: \(result.rawValue)") + } + + private static func insertionSettleTimeoutMicros( + insertionMode: SettingsStore.TextInsertionMode, + canVerifyFocusedText: Bool + ) -> useconds_t { + switch (insertionMode, canVerifyFocusedText) { + case (.reliablePaste, true): + return self.reliablePasteVerificationTimeoutMicros + case (.reliablePaste, false): + return self.reliablePasteVerificationTimeoutMicros + case (.standard, true): + return self.standardVerifyTimeoutMicros + case (.standard, false): + return self.standardUnverifiedSettleMicros + } + } + /// Clipboard-paste insertion targeted at a specific PID. /// Uses postToPid for Cmd+V while preserving the full previous pasteboard payload. private func insertTextViaClipboardToPid(_ text: String, targetPID: pid_t, activateTargetFirst: Bool = true) -> Bool { @@ -586,7 +651,7 @@ final class TypingService { usleep(80_000) } - return self.withTemporaryPasteboardString(text, restoreDelayMicros: 5_000_000) { + return self.withTemporaryPasteboardString(text, restoreDelayMicros: Self.reliablePasteVerificationTimeoutMicros) { let vKey = Self.pasteVirtualKeyCode guard let cmdVDown = CGEvent(keyboardEventSource: nil, virtualKey: vKey, keyDown: true), let cmdVUp = CGEvent(keyboardEventSource: nil, virtualKey: vKey, keyDown: false) @@ -672,7 +737,7 @@ final class TypingService { /// More reliable but slightly slower - copies text to clipboard then pastes private func insertTextViaClipboard(_ text: String) -> Bool { self.log("[TypingService] Starting clipboard-based insertion") - return self.withTemporaryPasteboardString(text, restoreDelayMicros: 5_000_000) { + return self.withTemporaryPasteboardString(text, restoreDelayMicros: Self.reliablePasteVerificationTimeoutMicros) { let vKey = Self.pasteVirtualKeyCode guard let cmdVDown = CGEvent(keyboardEventSource: nil, virtualKey: vKey, keyDown: true), let cmdVUp = CGEvent(keyboardEventSource: nil, virtualKey: vKey, keyDown: false) @@ -699,7 +764,7 @@ final class TypingService { return false } - return self.withTemporaryPasteboardString(text, restoreDelayMicros: 5_000_000) { + return self.withTemporaryPasteboardString(text, restoreDelayMicros: Self.reliablePasteVerificationTimeoutMicros) { let escapedAppName = appName.replacingOccurrences(of: "\"", with: "\\\"") let script = """ tell application "System Events" @@ -944,6 +1009,11 @@ final class TypingService { return .unavailable } + guard snapshot.canVerifyInsertion else { + usleep(timeoutMicros) + return .unavailable + } + let pollMicros: useconds_t = 50_000 let expectedLength = max(1, (expectedText as NSString).length) let tolerance = max(2, expectedLength / 5) @@ -1177,3 +1247,17 @@ final class TypingService { keyUpEvent.post(tap: .cghidEventTap) } } + +#if DEBUG +extension TypingService { + static func insertionSettleTimeoutMicrosForTesting( + insertionMode: SettingsStore.TextInsertionMode, + canVerifyFocusedText: Bool + ) -> useconds_t { + self.insertionSettleTimeoutMicros( + insertionMode: insertionMode, + canVerifyFocusedText: canVerifyFocusedText + ) + } +} +#endif diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index 957a656..08a2b52 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -9,32 +9,46 @@ import SwiftUI struct CustomDictionaryView: View { + private enum SuggestionApprovalResult { + case applied + case alreadyPresent + case conflict(existingReplacement: String) + } + + private let maxVisibleAutoLearnSuggestions = 5 + private let addedSuggestionConfirmationDelay: TimeInterval = 1.25 @Environment(\.theme) private var theme @State private var entries: [SettingsStore.CustomDictionaryEntry] = SettingsStore.shared.customDictionaryEntries @State private var boostTerms: [ParakeetVocabularyStore.VocabularyConfig.Term] = [] + @State private var autoLearnSuggestions: [SettingsStore.AutoLearnSuggestion] = SettingsStore.shared.autoLearnCustomDictionarySuggestions + @State private var confirmingAddedSuggestionIDs: Set = [] @State private var showAddSheet = false @State private var editingEntry: SettingsStore.CustomDictionaryEntry? @State private var showAddBoostSheet = false @State private var editingBoostTerm: EditableBoostTerm? + @State private var autoLearnEnabled: Bool = SettingsStore.shared.autoLearnCustomDictionaryEnabled + @State private var showAutoLearnInfo = false // Collapsible section states + @State private var isAutoLearnSectionExpanded = true @State private var isOfflineSectionExpanded = false @State private var isAISectionExpanded = true @State private var boostStatusMessage = "Add custom words for better Parakeet recognition." @State private var boostHasError = false @State private var vocabBoostingEnabled: Bool = SettingsStore.shared.vocabularyBoostingEnabled + @State private var autoLearnStatusMessage: String? var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { self.pageHeader - // Section 1: Custom Words (Parakeet) - self.aiPostProcessingSection + self.autoLearnSection - // Section 2: Instant Replacement self.offlineReplacementSection + + self.aiPostProcessingSection } .padding(20) } @@ -73,6 +87,12 @@ struct CustomDictionaryView: View { } .onAppear { self.loadBoostTerms() + self.reloadAutoLearnSuggestions() + } + .onReceive(NotificationCenter.default.publisher(for: .autoLearnSuggestionsDidChange)) { _ in + withAnimation(.easeInOut(duration: 0.2)) { + self.reloadAutoLearnSuggestions() + } } } @@ -89,13 +109,255 @@ struct CustomDictionaryView: View { .fontWeight(.semibold) } - Text("Improve transcription accuracy with Custom Words for names and product terms, plus Instant Replacement for simple find-and-replace.") + Text("Add words and replacements FluidVoice should remember.") .font(.subheadline) .foregroundStyle(.secondary) } } - // MARK: - Section 2: Offline Replacement + private var pendingAutoLearnSuggestions: [SettingsStore.AutoLearnSuggestion] { + return self.autoLearnSuggestions + .filter { suggestion in + guard suggestion.status == .pending else { return false } + + let threshold = AutoLearnDictionaryService.shared.displayThreshold(for: suggestion) + return suggestion.occurrences >= threshold + } + .sorted { lhs, rhs in + if lhs.occurrences != rhs.occurrences { + return lhs.occurrences > rhs.occurrences + } + + let lhsHighSignal = AutoLearnDictionaryService.shared.isHighSignalReplacement(lhs.replacement) + let rhsHighSignal = AutoLearnDictionaryService.shared.isHighSignalReplacement(rhs.replacement) + if lhsHighSignal != rhsHighSignal { + return lhsHighSignal + } + + if lhs.lastObservedAt != rhs.lastObservedAt { + return lhs.lastObservedAt > rhs.lastObservedAt + } + + return lhs.replacement.localizedCaseInsensitiveCompare(rhs.replacement) == .orderedAscending + } + } + + private var visibleAutoLearnSuggestions: [SettingsStore.AutoLearnSuggestion] { + Array(self.pendingAutoLearnSuggestions.prefix(self.maxVisibleAutoLearnSuggestions)) + } + + private var hiddenAutoLearnSuggestionCount: Int { + max(0, self.pendingAutoLearnSuggestions.count - self.visibleAutoLearnSuggestions.count) + } + + private var autoLearnSection: some View { + ThemedCard(hoverEffect: false) { + VStack(alignment: .leading, spacing: 0) { + HStack { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + self.isAutoLearnSectionExpanded.toggle() + } + } label: { + HStack(spacing: 6) { + Image(systemName: self.isAutoLearnSectionExpanded ? "chevron.down" : "chevron.right") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 16) + + Text("Replacement Suggestions") + .font(.headline) + + Text("Alpha") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 4).fill(Color(red: 1.0, green: 0.35, blue: 0.35))) + .foregroundStyle(.white) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button { + self.showAutoLearnInfo.toggle() + } label: { + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text("How it works") + } + .font(.caption2.weight(.semibold)) + .foregroundStyle(self.theme.palette.accent) + .padding(.horizontal, 7) + .frame(height: 18) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(self.theme.palette.accent.opacity(self.showAutoLearnInfo ? 0.14 : 0.08)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(self.theme.palette.accent.opacity(0.30), lineWidth: 1) + ) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("How suggestions work") + .popover(isPresented: self.$showAutoLearnInfo, arrowEdge: .top) { + self.autoLearnInfoPopover + } + + Spacer() + + if !self.pendingAutoLearnSuggestions.isEmpty { + Text("\(self.pendingAutoLearnSuggestions.count)") + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Capsule().fill(.quaternary)) + .foregroundStyle(.secondary) + } + } + + if self.isAutoLearnSectionExpanded { + Divider() + .padding(.vertical, 12) + + VStack(alignment: .leading, spacing: 12) { + Toggle(isOn: self.$autoLearnEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Suggest replacements from corrections") + .font(.subheadline.weight(.medium)) + Text("When you correct dictated text, FluidVoice can queue likely replacements for review.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.switch) + .controlSize(.small) + .onChange(of: self.autoLearnEnabled) { _, newValue in + SettingsStore.shared.autoLearnCustomDictionaryEnabled = newValue + if !newValue { + AutoLearnDictionaryService.shared.stopMonitoring() + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(self.theme.palette.contentBackground.opacity(0.6)) + ) + + if let autoLearnStatusMessage { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.orange) + Text(autoLearnStatusMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.orange.opacity(0.08)) + ) + } + + if self.pendingAutoLearnSuggestions.isEmpty { + VStack(spacing: 10) { + Image(systemName: "sparkles.rectangle.stack") + .font(.system(size: 28)) + .foregroundStyle(.tertiary) + Text( + self.autoLearnEnabled + ? "No suggestions yet" + : "Turn this on to review replacement suggestions from future corrections." + ) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } else { + VStack(spacing: 8) { + ForEach(self.visibleAutoLearnSuggestions) { suggestion in + AutoLearnSuggestionRow( + suggestion: suggestion, + isAdded: self.confirmingAddedSuggestionIDs.contains(suggestion.id), + onApprove: { self.approveSuggestion(suggestion) }, + onDismiss: { self.dismissSuggestion(suggestion) } + ) + } + + if self.hiddenAutoLearnSuggestionCount > 0 { + Text("\(self.hiddenAutoLearnSuggestionCount) more waiting. Add or dismiss one to show the next.") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + } + } + } + } + } + } + .padding(14) + } + } + + private var autoLearnInfoPopover: some View { + VStack(alignment: .leading, spacing: 12) { + Text("How suggestions work") + .font(.headline) + + VStack(alignment: .leading, spacing: 10) { + self.autoLearnInfoRow( + icon: "waveform", + text: "FluidVoice watches text you just dictated for a short time." + ) + self.autoLearnInfoRow( + icon: "pencil", + text: "If you correct that text, FluidVoice can suggest a replacement." + ) + self.autoLearnInfoRow( + icon: "number", + text: "Most suggestions appear after 2 corrections. Technical spellings like FluidVoice can appear after 1." + ) + self.autoLearnInfoRow( + icon: "checkmark.circle", + text: "Nothing is added to Instant Replacement until you choose Add." + ) + self.autoLearnInfoRow( + icon: "xmark.circle", + text: "Dismissed suggestions can return if you make the same correction again." + ) + self.autoLearnInfoRow( + icon: "cpu", + text: "Works locally; AI Enhancements are not required." + ) + } + } + .frame(width: 384, alignment: .leading) + .padding(14) + .background(self.theme.palette.elevatedCardBackground.opacity(0.98)) + .presentationBackground(self.theme.palette.elevatedCardBackground.opacity(0.98)) + } + + private func autoLearnInfoRow(icon: String, text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(self.theme.palette.accent) + .frame(width: 16, alignment: .center) + + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - Instant Replacement private var offlineReplacementSection: some View { ThemedCard(hoverEffect: false) { @@ -229,7 +491,7 @@ struct CustomDictionaryView: View { } } - // MARK: - Section 1: Custom Words (Parakeet) + // MARK: - Custom Words (Parakeet) private var aiPostProcessingSection: some View { ThemedCard(hoverEffect: false) { @@ -256,15 +518,15 @@ struct CustomDictionaryView: View { .background(RoundedRectangle(cornerRadius: 4).fill(self.theme.palette.accent.opacity(0.2))) .foregroundStyle(self.theme.palette.accent) + Spacer() + Text("\(self.boostTerms.count)") - .font(.caption2.weight(.medium)) - .padding(.horizontal, 6) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) .padding(.vertical, 2) .background(Capsule().fill(.quaternary)) .foregroundStyle(.secondary) - Spacer() - if self.isAISectionExpanded && !self.boostTerms.isEmpty { Button { self.showAddBoostSheet = true @@ -410,6 +672,85 @@ struct CustomDictionaryView: View { } } + private func reloadAutoLearnSuggestions() { + self.autoLearnSuggestions = SettingsStore.shared.autoLearnCustomDictionarySuggestions + self.autoLearnEnabled = SettingsStore.shared.autoLearnCustomDictionaryEnabled + } + + private func saveAutoLearnSuggestions() { + SettingsStore.shared.autoLearnCustomDictionarySuggestions = self.autoLearnSuggestions + } + + private func approveSuggestion(_ suggestion: SettingsStore.AutoLearnSuggestion) { + let trigger = suggestion.originalText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let replacement = suggestion.replacement.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trigger.isEmpty, !replacement.isEmpty else { return } + + switch self.applySuggestion(trigger: trigger, replacement: replacement) { + case .applied: + self.autoLearnStatusMessage = nil + self.saveEntries() + self.confirmAndRemoveApprovedSuggestion(suggestion) + case .alreadyPresent: + self.autoLearnStatusMessage = nil + self.confirmAndRemoveApprovedSuggestion(suggestion) + case let .conflict(existingReplacement): + self.autoLearnStatusMessage = + "\"\(trigger)\" already maps to \"\(existingReplacement)\". Review the existing dictionary entry before approving this suggestion." + } + } + + private func confirmAndRemoveApprovedSuggestion(_ suggestion: SettingsStore.AutoLearnSuggestion) { + withAnimation(.easeInOut(duration: 0.15)) { + self.confirmingAddedSuggestionIDs.insert(suggestion.id) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + self.addedSuggestionConfirmationDelay) { + guard self.confirmingAddedSuggestionIDs.contains(suggestion.id) else { return } + withAnimation(.easeInOut(duration: 0.25)) { + self.autoLearnSuggestions.removeAll { $0.id == suggestion.id } + self.confirmingAddedSuggestionIDs.remove(suggestion.id) + } + self.saveAutoLearnSuggestions() + } + } + + private func dismissSuggestion(_ suggestion: SettingsStore.AutoLearnSuggestion) { + guard let index = self.autoLearnSuggestions.firstIndex(where: { $0.id == suggestion.id }) else { return } + withAnimation(.easeInOut(duration: 0.25)) { + self.autoLearnSuggestions[index].status = .dismissed + self.autoLearnSuggestions[index].dismissedAtOccurrenceCount = self.autoLearnSuggestions[index].occurrences + self.confirmingAddedSuggestionIDs.remove(suggestion.id) + } + self.saveAutoLearnSuggestions() + } + + private func applySuggestion(trigger: String, replacement: String) -> SuggestionApprovalResult { + if let mappedEntry = self.entries.first(where: { $0.triggers.contains(trigger) }) { + if mappedEntry.replacement.caseInsensitiveCompare(replacement) == .orderedSame { + return .alreadyPresent + } + return .conflict(existingReplacement: mappedEntry.replacement) + } + + if let index = self.entries.firstIndex(where: { $0.replacement.caseInsensitiveCompare(replacement) == .orderedSame }) { + if self.entries[index].triggers.contains(trigger) { + return .alreadyPresent + } + self.entries[index].triggers.append(trigger) + self.entries[index].triggers = Array(Set(self.entries[index].triggers)).sorted() + return .applied + } + + self.entries.append( + SettingsStore.CustomDictionaryEntry( + triggers: [trigger], + replacement: replacement + ) + ) + return .applied + } + private func saveBoostTerms() { do { try ParakeetVocabularyStore.shared.saveUserBoostTerms(self.boostTerms) @@ -463,7 +804,9 @@ private enum BoostStrengthPreset: String, CaseIterable, Identifiable { case balanced = "Balanced" case strong = "Strong" - var id: String { self.rawValue } + var id: String { + self.rawValue + } var weight: Float { switch self { @@ -725,6 +1068,13 @@ struct EditBoostTermSheet: View { // MARK: - Dictionary Entry Row +private let dictionaryRowActionColumnWidth: CGFloat = 112 +private let suggestionActionButtonHeight: CGFloat = 24 +private let suggestionAddButtonWidth: CGFloat = 62 +private let suggestionDismissButtonWidth: CGFloat = 24 +private let suggestionActionButtonCornerRadius: CGFloat = 5 +private let suggestionDismissIconFont = Font.system(size: 9, weight: .medium) + struct DictionaryEntryRow: View { let entry: SettingsStore.CustomDictionaryEntry let onEdit: () -> Void @@ -789,6 +1139,7 @@ struct DictionaryEntryRow: View { .buttonStyle(.bordered) .controlSize(.mini) } + .frame(width: dictionaryRowActionColumnWidth, alignment: .trailing) } .padding(10) .background(RoundedRectangle(cornerRadius: 8).fill(.quaternary.opacity(0.5))) @@ -1098,3 +1449,137 @@ struct EditDictionaryEntrySheet: View { self.dismiss() } } + +// MARK: - AutoLearn Suggestion Row + +struct AutoLearnSuggestionRow: View { + let suggestion: SettingsStore.AutoLearnSuggestion + let isAdded: Bool + let onApprove: () -> Void + let onDismiss: () -> Void + + @Environment(\.theme) private var theme + + var body: some View { + HStack(alignment: .center, spacing: 12) { + // Left: original text + metadata + VStack(alignment: .leading, spacing: 5) { + Text("When heard:") + .font(.caption2) + .foregroundStyle(.tertiary) + + Text(self.suggestion.originalText) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 5).fill(.quaternary)) + + HStack(spacing: 6) { + Text(self.occurrenceText) + .font(.caption2) + .foregroundStyle(.tertiary) + + Text("·") + .foregroundStyle(.quaternary) + + Text(self.relativeTimestamp) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Centre: directional arrow + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundStyle(.tertiary) + + // Right: replacement text + VStack(alignment: .leading, spacing: 5) { + Text("Replace with:") + .font(.caption2) + .foregroundStyle(.tertiary) + + Text(self.suggestion.replacement) + .font(.callout.weight(.semibold)) + .foregroundStyle(self.theme.palette.accent) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Actions + HStack(spacing: 6) { + Button { + if !self.isAdded { + self.onApprove() + } + } label: { + HStack(spacing: 4) { + if self.isAdded { + Image(systemName: "checkmark") + .font(.caption2.weight(.bold)) + } + + Text(self.isAdded ? "Added" : "Add") + } + .font(.caption2.weight(.semibold)) + .frame(width: suggestionAddButtonWidth, height: suggestionActionButtonHeight) + .foregroundStyle(self.theme.palette.accent) + .background( + RoundedRectangle(cornerRadius: suggestionActionButtonCornerRadius) + .fill(self.theme.palette.accent.opacity(self.isAdded ? 0.18 : 0.10)) + ) + .overlay( + RoundedRectangle(cornerRadius: suggestionActionButtonCornerRadius) + .stroke( + self.theme.palette.accent.opacity(self.isAdded ? 0.55 : 0.34), + lineWidth: 1 + ) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(self.isAdded ? "Added to Instant Replacement" : "Add to Instant Replacement") + .accessibilityLabel(self.isAdded ? "Added to Instant Replacement" : "Add to Instant Replacement") + + Button { + self.onDismiss() + } label: { + Image(systemName: "xmark") + .font(suggestionDismissIconFont) + .frame(width: suggestionDismissButtonWidth, height: suggestionActionButtonHeight) + .foregroundStyle(.secondary) + .background( + RoundedRectangle(cornerRadius: suggestionActionButtonCornerRadius) + .fill(Color.white.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: suggestionActionButtonCornerRadius) + .stroke(Color.white.opacity(0.16), lineWidth: 1) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(self.isAdded) + .help("Dismiss for now") + .accessibilityLabel("Dismiss this suggestion for now") + } + .frame(width: dictionaryRowActionColumnWidth, alignment: .trailing) + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(.quaternary.opacity(0.5))) + .transition(.opacity.combined(with: .move(edge: .top))) + .animation(.easeInOut(duration: 0.15), value: self.isAdded) + } + + private var relativeTimestamp: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: self.suggestion.lastObservedAt, relativeTo: Date()) + } + + private var occurrenceText: String { + self.suggestion.occurrences == 1 + ? "1 correction" + : "\(self.suggestion.occurrences) corrections" + } +} diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 131f08f..843b63f 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -1,8 +1,7 @@ +@testable import FluidVoice_Debug import Foundation import XCTest -@testable import FluidVoice_Debug - @MainActor final class DictationE2ETests: XCTestCase { private let enableTranscriptionSoundsKey = "EnableTranscriptionSounds" @@ -17,6 +16,9 @@ final class DictationE2ETests: XCTestCase { private let selectedProviderIDKey = "SelectedProviderID" private let availableModelsByProviderKey = "AvailableModelsByProvider" private let selectedModelByProviderKey = "SelectedModelByProvider" + private let customDictionaryEntriesKey = "CustomDictionaryEntries" + private let autoLearnCustomDictionaryEnabledKey = "AutoLearnCustomDictionaryEnabled" + private let autoLearnCustomDictionarySuggestionsKey = "AutoLearnCustomDictionarySuggestions" func testTranscriptionStartSound_noneOptionHasNoFile() { XCTAssertEqual(SettingsStore.TranscriptionStartSound.none.displayName, "None") @@ -51,6 +53,27 @@ final class DictationE2ETests: XCTestCase { } } + func testReliablePasteCompletionWaitsForVerifiableInsertion() { + XCTAssertEqual( + TypingService.insertionSettleTimeoutMicrosForTesting( + insertionMode: .reliablePaste, + canVerifyFocusedText: true + ), + 5_000_000 + ) + XCTAssertEqual( + TypingService.insertionSettleTimeoutMicrosForTesting( + insertionMode: .reliablePaste, + canVerifyFocusedText: false + ), + 5_000_000 + ) + } + + func testTypingServiceRejectsEmptyInsertionRequest() { + XCTAssertFalse(TypingService().typeTextInstantly("")) + } + func testDictationEndToEnd_whisperTiny_transcribesFixture() async throws { // Arrange SettingsStore.shared.shareAnonymousAnalytics = false @@ -253,6 +276,999 @@ final class DictationE2ETests: XCTestCase { XCTAssertFalse(SimpleUpdater.isRollbackVersion(nil, differentFrom: "1.5.11-beta.3")) } + func testCorrectionDiffEngineCapturesCaseOnlyAcronymCorrection() { + let candidates = CorrectionDiffEngine.findCorrectionCandidates( + original: "yaml sample", + edited: "YAML sample" + ) + + XCTAssertEqual(candidates, [ + CorrectionDiffEngine.Candidate(original: "yaml", replacement: "YAML"), + ]) + } + + func testCorrectionDiffEngineCapturesCaseOnlyProductNameCorrection() { + let candidates = CorrectionDiffEngine.findCorrectionCandidates( + original: "fluidvoice test", + edited: "FluidVoice test" + ) + + XCTAssertEqual(candidates, [ + CorrectionDiffEngine.Candidate(original: "fluidvoice", replacement: "FluidVoice"), + ]) + } + + func testCorrectionDiffEnginePreservesAddedTechnicalEdgePunctuation() { + XCTAssertEqual( + CorrectionDiffEngine.findCorrectionCandidates( + original: "Use net here.", + edited: "Use .NET here." + ), + [CorrectionDiffEngine.Candidate(original: "net", replacement: ".NET")] + ) + XCTAssertEqual( + CorrectionDiffEngine.findCorrectionCandidates( + original: "Use c here.", + edited: "Use C++ here." + ), + [CorrectionDiffEngine.Candidate(original: "c", replacement: "C++")] + ) + XCTAssertEqual( + CorrectionDiffEngine.findCorrectionCandidates( + original: "Use sharp here.", + edited: "Use C# here." + ), + [CorrectionDiffEngine.Candidate(original: "sharp", replacement: "C#")] + ) + XCTAssertEqual( + CorrectionDiffEngine.findCorrectionCandidates( + original: "Use five percent here.", + edited: "Use 5% here." + ), + [CorrectionDiffEngine.Candidate(original: "five percent", replacement: "5%")] + ) + } + + func testCorrectionDiffEngineStripsSentencePunctuationAroundTechnicalTokens() { + let candidates = CorrectionDiffEngine.findCorrectionCandidates( + original: "Please use node.js, here.", + edited: "Please use Node.js, here." + ) + + XCTAssertEqual(candidates, [ + CorrectionDiffEngine.Candidate(original: "node.js", replacement: "Node.js"), + ]) + } + + func testAutoLearnObservationPreservesPunctuationInTrigger() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordObservationForTesting( + original: "node.js", + replacement: "Node.js" + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "node.js") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "Node.js") + } + } + + func testCustomDictionaryMatchesPunctuationEdgeTriggers() { + self.withRestoredDefaults(keys: [self.customDictionaryEntriesKey]) { + SettingsStore.shared.customDictionaryEntries = [ + SettingsStore.CustomDictionaryEntry(triggers: [".net"], replacement: ".NET"), + SettingsStore.CustomDictionaryEntry(triggers: ["c++"], replacement: "C++"), + SettingsStore.CustomDictionaryEntry(triggers: ["node.js"], replacement: "Node.js"), + ] + ASRService.invalidateDictionaryCache() + + let text = "I use c++ with .net and node.js, not objc++ or planet." + let result = ASRService.applyCustomDictionary(text) + + XCTAssertEqual( + result, + "I use C++ with .NET and Node.js, not objc++ or planet." + ) + } + } + + func testCustomDictionaryEscapesReplacementTemplates() { + self.withRestoredDefaults(keys: [self.customDictionaryEntriesKey]) { + SettingsStore.shared.customDictionaryEntries = [ + SettingsStore.CustomDictionaryEntry(triggers: ["five dollars"], replacement: "$5"), + SettingsStore.CustomDictionaryEntry(triggers: ["tools path"], replacement: #"C:\Tools"#), + ] + ASRService.invalidateDictionaryCache() + + let text = "Pay five dollars and open tools path." + let result = ASRService.applyCustomDictionary(text) + + XCTAssertEqual( + result, + #"Pay $5 and open C:\Tools."# + ) + } + } + + func testAutoLearnTracksPunctuationOnlyTechnicalCorrection() { + self.withRestoredDefaults(keys: [self.customDictionaryEntriesKey]) { + SettingsStore.shared.customDictionaryEntries = [] + + XCTAssertTrue( + AutoLearnDictionaryService.shared.shouldTrackForTesting( + original: "k8s-io", + replacement: "k8s.io" + ) + ) + } + } + + func testAutoLearnRejectsAccidentalApostrophePhraseRewrite() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + XCTAssertFalse( + AutoLearnDictionaryService.shared.shouldTrackForTesting( + original: "it is", + replacement: "(I'm" + ) + ) + XCTAssertFalse( + AutoLearnDictionaryService.shared.shouldTrackForTesting( + original: "it is", + replacement: "I'm" + ) + ) + XCTAssertFalse( + AutoLearnDictionaryService.shared.shouldTrackForTesting( + original: "fluid voice", + replacement: "(FluidVoice" + ) + ) + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "it is", + typedReplacement: "(I'm" + ) + XCTAssertTrue(SettingsStore.shared.autoLearnCustomDictionarySuggestions.isEmpty) + + AutoLearnDictionaryService.shared.recordCorrectionsForTesting( + insertedText: "it is", + baselineText: "it is", + currentText: "(I'm" + ) + XCTAssertTrue(SettingsStore.shared.autoLearnCustomDictionarySuggestions.isEmpty) + } + } + + func testAutoLearnKeepsUsefulTechnicalPunctuationCorrections() { + self.withRestoredDefaults(keys: [self.customDictionaryEntriesKey]) { + SettingsStore.shared.customDictionaryEntries = [] + + XCTAssertTrue( + AutoLearnDictionaryService.shared.shouldTrackForTesting( + original: "o reilly", + replacement: "O'Reilly" + ) + ) + XCTAssertTrue( + AutoLearnDictionaryService.shared.shouldTrackForTesting( + original: "net", + replacement: ".NET" + ) + ) + } + } + + func testAutoLearnTracksSimpleTitleCaseCorrectionAsOrdinarySuggestion() { + self.withRestoredDefaults(keys: [self.customDictionaryEntriesKey]) { + SettingsStore.shared.customDictionaryEntries = [] + + XCTAssertTrue( + AutoLearnDictionaryService.shared.shouldTrackForTesting( + original: "obsidian", + replacement: "Obsidian" + ) + ) + XCTAssertFalse( + AutoLearnDictionaryService.shared.isHighSignalReplacement("Obsidian") + ) + XCTAssertEqual( + AutoLearnDictionaryService.shared.displayThreshold(forReplacement: "Obsidian"), + AutoLearnDictionaryService.shared.minimumSuggestionOccurrences + ) + XCTAssertTrue( + AutoLearnDictionaryService.shared.shouldTrackForTesting( + original: "works", + replacement: "Works" + ) + ) + XCTAssertFalse( + AutoLearnDictionaryService.shared.isHighSignalReplacement("Works") + ) + } + } + + func testAutoLearnTreatsAcronymAndCamelCaseCorrectionsAsHighSignal() { + XCTAssertTrue( + AutoLearnDictionaryService.shared.isHighSignalReplacement("YAML") + ) + XCTAssertEqual( + AutoLearnDictionaryService.shared.displayThreshold(forReplacement: "YAML"), + 1 + ) + XCTAssertTrue( + AutoLearnDictionaryService.shared.isHighSignalReplacement("FluidVoice") + ) + XCTAssertTrue( + AutoLearnDictionaryService.shared.isHighSignalReplacement("Please use FluidVoice") + ) + XCTAssertFalse( + AutoLearnDictionaryService.shared.isHighSignalReplacement("Works") + ) + } + + func testAutoLearnDoesNotTreatTrailingCapitalFragmentAsHighSignal() { + XCTAssertFalse( + AutoLearnDictionaryService.shared.isHighSignalReplacement("agree W") + ) + XCTAssertEqual( + AutoLearnDictionaryService.shared.displayThreshold(forReplacement: "agree W"), + AutoLearnDictionaryService.shared.minimumSuggestionOccurrences + ) + } + + func testAutoLearnOnlyRecordsCorrectionsFromInsertedText() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordCorrectionsForTesting( + insertedText: "New obsidian note.", + baselineText: "Old patten. New obsidian note.", + currentText: "Old Pattern. New obsidian note." + ) + + XCTAssertTrue(SettingsStore.shared.autoLearnCustomDictionarySuggestions.isEmpty) + + AutoLearnDictionaryService.shared.recordCorrectionsForTesting( + insertedText: "New obsidian note.", + baselineText: "Old patten. New obsidian note.", + currentText: "Old patten. New Obsidian note." + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "obsidian") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "Obsidian") + } + } + + func testAutoLearnUsesScopedDiffForUniqueInsertionInLongDocument() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + let prefix = (0..<520).map { "prefix\($0)" }.joined(separator: " ") + let suffix = (0..<520).map { "suffix\($0)" }.joined(separator: " ") + let insertedText = "Please use zeta flow here" + let baselineText = "\(prefix) \(insertedText) \(suffix)" + let currentText = "\(prefix) Please use ZetaFlow here \(suffix)" + + AutoLearnDictionaryService.shared.recordCorrectionsForTesting( + insertedText: insertedText, + baselineText: baselineText, + currentText: currentText + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "zeta flow") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "ZetaFlow") + } + } + + func testAutoLearnSkipsScopedDiffWhenInsertedTextOccurrenceIsAmbiguous() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + let prefix = (0..<520).map { "prefix\($0)" }.joined(separator: " ") + let suffix = (0..<520).map { "suffix\($0)" }.joined(separator: " ") + let insertedText = "Please use zeta flow here" + let baselineText = "\(insertedText) \(prefix) \(insertedText) \(suffix)" + let currentText = "\(insertedText) \(prefix) Please use ZetaFlow here \(suffix)" + + AutoLearnDictionaryService.shared.recordCorrectionsForTesting( + insertedText: insertedText, + baselineText: baselineText, + currentText: currentText + ) + + XCTAssertTrue(SettingsStore.shared.autoLearnCustomDictionarySuggestions.isEmpty) + } + } + + func testAutoLearnCountsRepeatedSessionCorrectionsWithoutDoubleCounting() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + let baselineText = """ + I wrote an obsidian note + I wrote an obsidian note. + I wrote an obsidian note + """ + let currentText = """ + I wrote an Obsidian note + I wrote an Obsidian note. + I wrote an Obsidian note + """ + + AutoLearnDictionaryService.shared.recordCorrectionsDuringSessionForTesting( + insertedText: baselineText, + baselineText: baselineText, + currentText: currentText, + processingPasses: 2 + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "obsidian") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "Obsidian") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 3) + } + } + + func testAutoLearnCountsRepeatedMultiTokenCorrectionsInSingleSession() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + let baselineText = """ + Please use beta flow here + Please use beta flow here + """ + let currentText = """ + Please use BetaFlow here + Please use BetaFlow here + """ + + AutoLearnDictionaryService.shared.recordCorrectionsDuringSessionForTesting( + insertedText: baselineText, + baselineText: baselineText, + currentText: currentText, + processingPasses: 2 + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "beta flow") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "BetaFlow") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 2) + } + } + + func testAutoLearnCountsSequentialCorrectionsWhenTargetAppReactivatesDuringMonitor() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + let monitoredPID: pid_t = 12_345 + AutoLearnDictionaryService.shared.beginSyntheticMonitoringForTesting( + insertedText: "Please use beta flow here", + baselineText: "Please use beta flow here", + monitoredPID: monitoredPID + ) + AutoLearnDictionaryService.shared.updateSyntheticCurrentTextForTesting("Please use BetaFlow here") + + AutoLearnDictionaryService.shared.beginSyntheticMonitoringForTesting( + insertedText: "Please use beta flow here", + baselineText: """ + Please use BetaFlow here + Please use beta flow here + """, + monitoredPID: monitoredPID + ) + AutoLearnDictionaryService.shared.handleActivatedApplicationForTesting(pid: monitoredPID) + AutoLearnDictionaryService.shared.updateSyntheticCurrentTextForTesting( + """ + Please use BetaFlow here + Please use BetaFlow here + """ + ) + AutoLearnDictionaryService.shared.finalizeForTesting() + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "beta flow") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "BetaFlow") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 2) + } + } + + func testAutoLearnPrefersTargetPIDForHelperElementActivation() { + let targetPID: pid_t = 12_345 + let helperElementPID: pid_t = 54_321 + + XCTAssertEqual( + AutoLearnDictionaryService.shared.monitoringActivationPIDForTesting( + elementPID: helperElementPID, + targetPID: targetPID + ), + targetPID + ) + XCTAssertEqual( + AutoLearnDictionaryService.shared.monitoringActivationPIDForTesting( + elementPID: helperElementPID, + targetPID: nil + ), + helperElementPID + ) + } + + func testAutoLearnDismissedOrdinarySuggestionReappearsAfterFreshEvidence() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [ + SettingsStore.AutoLearnSuggestion( + originalText: "obsidian", + replacement: "Obsidian", + occurrences: 2, + status: .dismissed + ), + ] + + AutoLearnDictionaryService.shared.recordObservationForTesting(original: "obsidian", replacement: "Obsidian") + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.status, .dismissed) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.dismissedAtOccurrenceCount, 2) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 3) + + AutoLearnDictionaryService.shared.recordObservationForTesting(original: "obsidian", replacement: "Obsidian") + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.status, .pending) + XCTAssertNil(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.dismissedAtOccurrenceCount) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 4) + } + } + + func testAutoLearnEventFallbackInfersHighSignalSelectionReplacement() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "Please use signal flow here", + typedReplacement: "SignalFlow" + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "signal flow") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "SignalFlow") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 1) + } + } + + func testAutoLearnEventFallbackPreservesPunctuatedOriginalTrigger() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "Please use .net, here", + typedReplacement: ".NET" + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, ".net") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, ".NET") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 1) + } + } + + func testAutoLearnEventFallbackIncrementsExistingSuggestion() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [ + SettingsStore.AutoLearnSuggestion( + originalText: "signal flow", + replacement: "SignalFlow", + occurrences: 4, + lastObservedAt: Date(), + status: .pending + ), + ] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "Please use signal flow here", + typedReplacement: "SignalFlow" + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 5) + } + } + + func testAutoLearnEventFallbackFlushesPendingTypedRun() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.beginEventFallbackSessionForTesting( + insertedText: "Please use signal flow here" + ) + defer { AutoLearnDictionaryService.shared.stopMonitoring() } + + AutoLearnDictionaryService.shared.setEventFallbackTypedRunForTesting("SignalFlow") + AutoLearnDictionaryService.shared.flushEventFallbackTypedRunForTesting() + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "signal flow") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "SignalFlow") + } + } + + func testAutoLearnEventFallbackDefersPartialMultiTokenReplacement() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.beginEventFallbackSessionForTesting( + insertedText: "I am testing fluid sync" + ) + defer { AutoLearnDictionaryService.shared.stopMonitoring() } + + AutoLearnDictionaryService.shared.setEventFallbackTypedRunForTesting("FluidS") + AutoLearnDictionaryService.shared.processEventFallbackDebouncedTypedRunForTesting() + + XCTAssertTrue(SettingsStore.shared.autoLearnCustomDictionarySuggestions.isEmpty) + + AutoLearnDictionaryService.shared.setEventFallbackTypedRunForTesting("FluidSync") + AutoLearnDictionaryService.shared.processEventFallbackDebouncedTypedRunForTesting() + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "fluid sync") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "FluidSync") + } + } + + func testAutoLearnEventFallbackDefersOrdinaryPrefixUntilHighSignalReplacementCompletes() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.beginEventFallbackSessionForTesting( + insertedText: "Please use signal flow here" + ) + defer { AutoLearnDictionaryService.shared.stopMonitoring() } + + AutoLearnDictionaryService.shared.setEventFallbackTypedRunForTesting("Signal") + AutoLearnDictionaryService.shared.processEventFallbackDebouncedTypedRunForTesting() + + XCTAssertTrue(SettingsStore.shared.autoLearnCustomDictionarySuggestions.isEmpty) + + AutoLearnDictionaryService.shared.setEventFallbackTypedRunForTesting("SignalFlow") + AutoLearnDictionaryService.shared.processEventFallbackDebouncedTypedRunForTesting() + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "signal flow") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "SignalFlow") + } + } + + func testAutoLearnAxDiffDefersPartialMultiTokenReplacement() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordCorrectionsForTesting( + insertedText: "I am testing fluid sync", + baselineText: "I am testing fluid sync", + currentText: "I am testing FluidS" + ) + + XCTAssertTrue(SettingsStore.shared.autoLearnCustomDictionarySuggestions.isEmpty) + + AutoLearnDictionaryService.shared.recordCorrectionsForTesting( + insertedText: "I am testing fluid sync", + baselineText: "I am testing fluid sync", + currentText: "I am testing FluidSync" + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "fluid sync") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "FluidSync") + } + } + + func testAutoLearnEventFallbackTracksOrdinaryCaseOnlyCorrection() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "I wrote an obsidian note", + typedReplacement: "Obsidian" + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "obsidian") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "Obsidian") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 1) + XCTAssertEqual( + AutoLearnDictionaryService.shared.displayThreshold(forReplacement: "Obsidian"), + AutoLearnDictionaryService.shared.minimumSuggestionOccurrences + ) + } + } + + func testAutoLearnEventFallbackTracksRepeatedPunctuationCollapseCorrection() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "I corrected anti-gravity today", + typedReplacement: "Antigravity" + ) + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "I corrected anti-gravity today", + typedReplacement: "Antigravity" + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "anti-gravity") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "Antigravity") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 2) + XCTAssertEqual( + AutoLearnDictionaryService.shared.displayThreshold(forReplacement: "Antigravity"), + AutoLearnDictionaryService.shared.minimumSuggestionOccurrences + ) + } + } + + func testAutoLearnEventFallbackTracksRepeatedWhitespaceCollapseCorrection() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "I corrected anti gravity today", + typedReplacement: "Antigravity" + ) + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "I corrected anti gravity today", + typedReplacement: "Antigravity" + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "anti gravity") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "Antigravity") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 2) + } + } + + func testAutoLearnEventFallbackTracksSmartPunctuationCorrection() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "Please use o reilly here", + typedReplacement: "O’Reilly" + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "o reilly") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "O’Reilly") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 1) + } + } + + func testAutoLearnEventFallbackTracksRepeatedDiacriticCorrection() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "I mentioned cafe today", + typedReplacement: "Café" + ) + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "I mentioned cafe today", + typedReplacement: "Café" + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "cafe") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "Café") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 2) + } + } + + func testAutoLearnEventFallbackInfersSingleLetterTitleCaseCorrection() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "I mentioned obsidian today", + typedReplacement: "O" + ) + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "obsidian") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "Obsidian") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 1) + XCTAssertEqual( + AutoLearnDictionaryService.shared.displayThreshold(forReplacement: "Obsidian"), + AutoLearnDictionaryService.shared.minimumSuggestionOccurrences + ) + } + } + + func testAutoLearnEventFallbackDefersSingleLetterTitleCaseCorrectionUntilFlush() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.beginEventFallbackSessionForTesting( + insertedText: "I mentioned obsidian today" + ) + defer { AutoLearnDictionaryService.shared.stopMonitoring() } + + AutoLearnDictionaryService.shared.setEventFallbackTypedRunForTesting("O") + AutoLearnDictionaryService.shared.processEventFallbackDebouncedTypedRunForTesting() + + XCTAssertTrue(SettingsStore.shared.autoLearnCustomDictionarySuggestions.isEmpty) + + AutoLearnDictionaryService.shared.flushEventFallbackTypedRunForTesting() + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.count, 1) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.originalText, "obsidian") + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.replacement, "Obsidian") + } + } + + func testAutoLearnEventFallbackSkipsAmbiguousSingleLetterTitleCaseCorrection() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "I opened obsidian often", + typedReplacement: "O" + ) + + XCTAssertTrue(SettingsStore.shared.autoLearnCustomDictionarySuggestions.isEmpty) + } + } + + func testAutoLearnEventFallbackSkipsOrdinaryFuzzyReplacement() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "I wrote an oblivion note", + typedReplacement: "Obsidian" + ) + + XCTAssertTrue(SettingsStore.shared.autoLearnCustomDictionarySuggestions.isEmpty) + } + } + + func testAutoLearnEventFallbackSkipsUnrelatedHighSignalTyping() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionaryEnabledKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionaryEnabled = true + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [] + + AutoLearnDictionaryService.shared.recordEventFallbackReplacementForTesting( + insertedText: "Please use alpha path here", + typedReplacement: "SignalFlow" + ) + + XCTAssertTrue(SettingsStore.shared.autoLearnCustomDictionarySuggestions.isEmpty) + } + } + + func testAutoLearnDismissedHighSignalSuggestionReappearsAfterOneFreshObservation() { + self.withRestoredDefaults( + keys: [ + self.customDictionaryEntriesKey, + self.autoLearnCustomDictionarySuggestionsKey, + ] + ) { + SettingsStore.shared.customDictionaryEntries = [] + SettingsStore.shared.autoLearnCustomDictionarySuggestions = [ + SettingsStore.AutoLearnSuggestion( + originalText: "yaml", + replacement: "YAML", + occurrences: 1, + status: .dismissed, + dismissedAtOccurrenceCount: 1 + ), + ] + + AutoLearnDictionaryService.shared.recordObservationForTesting(original: "yaml", replacement: "YAML") + + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.status, .pending) + XCTAssertNil(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.dismissedAtOccurrenceCount) + XCTAssertEqual(SettingsStore.shared.autoLearnCustomDictionarySuggestions.first?.occurrences, 2) + } + } + private static func modelDirectoryForRun() -> URL { // Use a stable path on CI so GitHub Actions cache can speed up runs. if ProcessInfo.processInfo.environment["GITHUB_ACTIONS"] == "true" || @@ -277,12 +1293,10 @@ final class DictationE2ETests: XCTestCase { if CharacterSet.punctuationCharacters.contains(scalar) { return " " } return Character(scalar) } - let collapsed = String(noPunct) + return String(noPunct) .components(separatedBy: .whitespacesAndNewlines) .filter { !$0.isEmpty } .joined(separator: " ") - - return collapsed } private func withRestoredDefaults(keys: [String], run: () -> Void) { @@ -308,17 +1322,19 @@ final class DictationE2ETests: XCTestCase { } private func withPromptSettingsRestored(run: () -> Void) { - self.withRestoredDefaults( - keys: [ - self.dictationPromptProfilesKey, - self.appPromptBindingsKey, - self.selectedDictationPromptIDKey, - self.selectedEditPromptIDKey, - self.defaultDictationPromptOverrideKey, - self.defaultEditPromptOverrideKey, - ], - run: run - ) + let keys = [ + self.dictationPromptProfilesKey, + self.appPromptBindingsKey, + self.selectedDictationPromptIDKey, + self.selectedEditPromptIDKey, + self.defaultDictationPromptOverrideKey, + self.defaultEditPromptOverrideKey, + ] + self.withRestoredDefaults(keys: keys) { + let defaults = UserDefaults.standard + keys.forEach { defaults.removeObject(forKey: $0) } + run() + } } private func withProviderSettingsRestored(run: () -> Void) {