From 4979780c0daaae949666880bc43fb9b64714acf4 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 14 Apr 2026 11:15:49 +0100 Subject: [PATCH 01/10] feat: Implement auto-learning custom dictionary suggestions via review queue Resolves #272 by tracking token-level corrections during post-transcription edits and surfacing them in a review queue inside the Custom Dictionary settings panel. --- Sources/Fluid/ContentView.swift | 4 + Sources/Fluid/Persistence/BackupService.swift | 2 + Sources/Fluid/Persistence/SettingsStore.swift | 64 ++++ .../Services/AutoLearnDictionaryService.swift | 322 ++++++++++++++++++ .../Fluid/Services/CorrectionDiffEngine.swift | 112 ++++++ Sources/Fluid/UI/CustomDictionaryView.swift | 227 ++++++++++++ 6 files changed, 731 insertions(+) create mode 100644 Sources/Fluid/Services/AutoLearnDictionaryService.swift create mode 100644 Sources/Fluid/Services/CorrectionDiffEngine.swift diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index b6ff8c17..eacd51c5 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -1916,6 +1916,9 @@ struct ContentView: View { if typingTarget.shouldRestoreOriginalFocus { await self.restoreFocusToRecordingTarget() } + if let element = AutoLearnDictionaryService.shared.captureFocusedElement() { + AutoLearnDictionaryService.shared.prepareMonitoring(pastedText: finalText, element: element) + } self.asr.typeTextToActiveField( finalText, preferredTargetPID: typingTarget.pid @@ -1943,6 +1946,7 @@ struct ContentView: View { aiModel: modelInfo.model, aiProvider: modelInfo.provider ) + AutoLearnDictionaryService.shared.beginMonitoring() NotchOverlayManager.shared.hide() } else if shouldPersistOutputs, diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 5ac4e212..4a25e5f2 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -62,6 +62,8 @@ struct SettingsBackupPayload: Codable, Equatable { let pauseMediaDuringTranscription: Bool let vocabularyBoostingEnabled: Bool let customDictionaryEntries: [SettingsStore.CustomDictionaryEntry] + let autoLearnCustomDictionaryEnabled: Bool + let autoLearnCustomDictionarySuggestions: [SettingsStore.AutoLearnSuggestion] let selectedDictationPromptID: String? let dictationPromptOff: Bool? let selectedEditPromptID: String? diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index f4deb986..66fa7dcb 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -2186,6 +2186,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, @@ -2255,6 +2257,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 @@ -2853,6 +2857,36 @@ final class SettingsStore: ObservableObject { } } + 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 + + init( + id: UUID = UUID(), + originalText: String, + replacement: String, + occurrences: Int = 1, + lastObservedAt: Date = Date(), + status: AutoLearnSuggestionStatus = .pending + ) { + 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 + } + } + var vocabularyBoostingEnabled: Bool { get { let value = self.defaults.object(forKey: Keys.vocabularyBoostingEnabled) @@ -2883,6 +2917,34 @@ 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) + } + } + } + // MARK: - Speech Model (Unified ASR Model Selection) /// Unified speech recognition model selection. @@ -3569,6 +3631,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/AutoLearnDictionaryService.swift b/Sources/Fluid/Services/AutoLearnDictionaryService.swift new file mode 100644 index 00000000..b54a5113 --- /dev/null +++ b/Sources/Fluid/Services/AutoLearnDictionaryService.swift @@ -0,0 +1,322 @@ +import AppKit +import ApplicationServices +import Foundation +import NaturalLanguage + +final class AutoLearnDictionaryService { + static let shared = AutoLearnDictionaryService() + + private init() {} + + private let monitoringTimeoutSeconds: TimeInterval = 30 + private let suggestionThreshold = 2 + private let maxSegmentTokenCount = 3 + + private var pendingElement: AXUIElement? + private var pendingText: String = "" + + private var baselineText: String = "" + private var lastKnownText: String = "" + private var axObserver: AXObserver? + private var workspaceObserver: NSObjectProtocol? + private var timeoutTimer: DispatchSourceTimer? + private var isActive = false + + func captureFocusedElement() -> 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) + } + + func prepareMonitoring(pastedText: String, element: AXUIElement) { + self.pendingElement = element + self.pendingText = pastedText + } + + func beginMonitoring() { + guard SettingsStore.shared.autoLearnCustomDictionaryEnabled else { + self.clearPending() + return + } + guard let element = self.pendingElement else { return } + + let pastedText = self.pendingText + self.clearPending() + self.startMonitoring(pastedText: pastedText, element: element) + } + + func stopMonitoring() { + self.isActive = false + + self.timeoutTimer?.cancel() + self.timeoutTimer = 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 + } + } + + private func clearPending() { + self.pendingElement = nil + self.pendingText = "" + } + + private func startMonitoring(pastedText: String, element: AXUIElement) { + self.stopMonitoring() + + self.baselineText = pastedText + self.lastKnownText = pastedText + 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 { 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.lastKnownText = text + } + } + + guard AXObserverCreate(pid, callback, &observer) == .success, let observer else { return } + + let refcon = Unmanaged.passUnretained(self).toOpaque() + AXObserverAddNotification(observer, element, kAXValueChangedNotification as CFString, refcon) + CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .defaultMode) + self.axObserver = observer + } + + private func setupAppSwitchObserver() { + self.workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self, self.isActive else { return } + self.finalize() + } + } + + 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 finalize() { + guard self.isActive else { return } + self.stopMonitoring() + + let baseline = self.baselineText + let currentText = self.lastKnownText + + guard baseline != currentText else { return } + + let corrections = CorrectionDiffEngine.findCorrectionCandidates( + original: baseline, + edited: currentText, + maxSegmentTokenCount: self.maxSegmentTokenCount + ) + + guard !corrections.isEmpty else { return } + + for correction in corrections where self.shouldTrack(correction, editedText: currentText) { + self.recordObservation(original: correction.original, replacement: correction.replacement) + } + } + + private func shouldTrack(_ candidate: CorrectionDiffEngine.Candidate, editedText: String) -> Bool { + let original = self.normalizePhrase(candidate.original) + let replacement = candidate.replacement.trimmingCharacters(in: .whitespacesAndNewlines) + let replacementKey = self.normalizePhrase(replacement) + + guard !original.isEmpty, !replacement.isEmpty, !replacementKey.isEmpty else { return false } + guard original != replacementKey else { return false } + guard original.count <= 64, replacement.count <= 64 else { return false } + guard self.isDictionaryWorthy(replacement, in: editedText) else { return false } + guard self.looksLikeARealCorrection(original: original, replacement: replacementKey) else { return false } + guard !self.mappingAlreadyExists(original: original, 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.normalizePhrase(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.normalizePhrase($0.originalText) == normalizedOriginal && + $0.replacement.caseInsensitiveCompare(normalizedReplacement) == .orderedSame + }) { + suggestions[index].occurrences += 1 + suggestions[index].lastObservedAt = now + } 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 isDictionaryWorthy(_ replacement: String, in editedText: String) -> Bool { + if self.namedEntities(in: editedText).contains(where: { + $0.caseInsensitiveCompare(replacement) == .orderedSame + }) { + return true + } + + if replacement.rangeOfCharacter(from: .uppercaseLetters) != nil { + return true + } + + if replacement.rangeOfCharacter(from: .decimalDigits) != nil { + return true + } + + let symbolCharacterSet = CharacterSet(charactersIn: "-_/.'&+") + if replacement.rangeOfCharacter(from: symbolCharacterSet) != nil { + return true + } + + return false + } + + private func looksLikeARealCorrection(original: String, replacement: String) -> Bool { + let distance = self.levenshteinDistance(original, replacement) + let maxLength = max(original.count, replacement.count) + let allowedDistance = max(3, maxLength / 2) + return distance <= allowedDistance + } + + private func normalizePhrase(_ text: String) -> String { + text + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func namedEntities(in text: String) -> [String] { + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + guard let language = recognizer.dominantLanguage, + Self.supportedLanguages.contains(language) + else { + return [] + } + + let tagger = NLTagger(tagSchemes: [.nameType]) + tagger.string = text + + var results: [String] = [] + let targetTags: Set = [.personalName, .placeName, .organizationName] + tagger.enumerateTags( + in: text.startIndex.. 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] + } + + private static let supportedLanguages: Set = [ + .english, .german, .french, .spanish, .italian, .portuguese, .russian, .turkish + ] + + var minimumSuggestionOccurrences: Int { + self.suggestionThreshold + } +} diff --git a/Sources/Fluid/Services/CorrectionDiffEngine.swift b/Sources/Fluid/Services/CorrectionDiffEngine.swift new file mode 100644 index 00000000..bb99d293 --- /dev/null +++ b/Sources/Fluid/Services/CorrectionDiffEngine.swift @@ -0,0 +1,112 @@ +import Foundation + +enum CorrectionDiffEngine { + struct Candidate: Equatable { + let original: String + let replacement: String + } + + 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 [] } + + let anchorPairs = lcsIndexPairs(originalTokens, editedTokens) + var candidates: [Candidate] = [] + var originalIndex = 0 + var editedIndex = 0 + + for (anchorOriginal, anchorEdited) in anchorPairs { + let originalSegment = Array(originalTokens[originalIndex.. Candidate? { + guard !originalSegment.isEmpty, !editedSegment.isEmpty else { return nil } + guard originalSegment.count <= maxSegmentTokenCount, editedSegment.count <= maxSegmentTokenCount else { return nil } + + let originalPhrase = originalSegment.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + let editedPhrase = editedSegment.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 lcsIndexPairs(_ lhs: [String], _ rhs: [String]) -> [(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].lowercased() == rhs[rhsIndex - 1].lowercased() { + 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].lowercased() == rhs[rhsIndex - 1].lowercased() { + 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 static func tokenize(_ text: String) -> [String] { + text.components(separatedBy: .whitespacesAndNewlines) + .map { $0.trimmingCharacters(in: .punctuationCharacters) } + .filter { !$0.isEmpty } + } +} diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index 957a656f..5c1c0473 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -12,12 +12,15 @@ struct CustomDictionaryView: View { @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 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 // Collapsible section states + @State private var isAutoLearnSectionExpanded = true @State private var isOfflineSectionExpanded = false @State private var isAISectionExpanded = true @@ -30,6 +33,8 @@ struct CustomDictionaryView: View { VStack(alignment: .leading, spacing: 16) { self.pageHeader + self.autoLearnSection + // Section 1: Custom Words (Parakeet) self.aiPostProcessingSection @@ -73,6 +78,7 @@ struct CustomDictionaryView: View { } .onAppear { self.loadBoostTerms() + self.reloadAutoLearnSuggestions() } } @@ -95,6 +101,119 @@ struct CustomDictionaryView: View { } } + private var pendingAutoLearnSuggestions: [SettingsStore.AutoLearnSuggestion] { + let threshold = AutoLearnDictionaryService.shared.minimumSuggestionOccurrences + return self.autoLearnSuggestions + .filter { $0.status == .pending && $0.occurrences >= threshold } + .sorted { lhs, rhs in + if lhs.occurrences != rhs.occurrences { + return lhs.occurrences > rhs.occurrences + } + return lhs.lastObservedAt > rhs.lastObservedAt + } + } + + private var autoLearnSection: some View { + ThemedCard(hoverEffect: false) { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + self.isAutoLearnSectionExpanded.toggle() + } + } label: { + HStack { + Image(systemName: self.isAutoLearnSectionExpanded ? "chevron.down" : "chevron.right") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 16) + + Text("Suggestions from your corrections") + .font(.headline) + + Text("EXPERIMENTAL") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 4).fill(Color.orange.opacity(0.18))) + .foregroundStyle(Color.orange) + + 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) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if self.isAutoLearnSectionExpanded { + Divider() + .padding(.vertical, 12) + + VStack(alignment: .leading, spacing: 12) { + Toggle(isOn: self.$autoLearnEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Learn from repeated corrections") + .font(.subheadline.weight(.medium)) + Text("Watch typed dictation for repeated token-level corrections and suggest dictionary entries after they recur.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.switch) + .controlSize(.small) + .onChange(of: self.autoLearnEnabled) { _, newValue in + SettingsStore.shared.autoLearnCustomDictionaryEnabled = newValue + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(self.theme.palette.contentBackground.opacity(0.6)) + ) + + Text("Suggestions only appear after the same correction recurs. Nothing is added automatically.") + .font(.caption2) + .foregroundStyle(.secondary) + + 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 start collecting suggestions" + ) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } else { + VStack(spacing: 8) { + ForEach(self.pendingAutoLearnSuggestions) { suggestion in + AutoLearnSuggestionRow( + suggestion: suggestion, + onApprove: { self.approveSuggestion(suggestion) }, + onDismiss: { self.dismissSuggestion(suggestion) } + ) + } + } + } + } + } + } + .padding(14) + } + } + // MARK: - Section 2: Offline Replacement private var offlineReplacementSection: some View { @@ -410,6 +529,45 @@ 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 } + + if let index = self.entries.firstIndex(where: { $0.replacement.caseInsensitiveCompare(replacement) == .orderedSame }) { + if !self.entries[index].triggers.contains(trigger) { + self.entries[index].triggers.append(trigger) + self.entries[index].triggers = Array(Set(self.entries[index].triggers)).sorted() + } + } else { + self.entries.append( + SettingsStore.CustomDictionaryEntry( + triggers: [trigger], + replacement: replacement + ) + ) + } + + self.saveEntries() + self.autoLearnSuggestions.removeAll { $0.id == suggestion.id } + self.saveAutoLearnSuggestions() + } + + private func dismissSuggestion(_ suggestion: SettingsStore.AutoLearnSuggestion) { + guard let index = self.autoLearnSuggestions.firstIndex(where: { $0.id == suggestion.id }) else { return } + self.autoLearnSuggestions[index].status = .dismissed + self.saveAutoLearnSuggestions() + } + private func saveBoostTerms() { do { try ParakeetVocabularyStore.shared.saveUserBoostTerms(self.boostTerms) @@ -1098,3 +1256,72 @@ struct EditDictionaryEntrySheet: View { self.dismiss() } } + +// MARK: - AutoLearn Suggestion Row + +struct AutoLearnSuggestionRow: View { + let suggestion: SettingsStore.AutoLearnSuggestion + let onApprove: () -> Void + let onDismiss: () -> Void + + @Environment(\.theme) private var theme + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("When heard:") + .font(.caption2) + .foregroundStyle(.tertiary) + + Text(self.suggestion.originalText) + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(RoundedRectangle(cornerRadius: 4).fill(.quaternary)) + + Text("Observed \(self.suggestion.occurrences) times") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundStyle(.tertiary) + + VStack(alignment: .leading, spacing: 4) { + Text("Replace with:") + .font(.caption2) + .foregroundStyle(.tertiary) + + Text(self.suggestion.replacement) + .font(.callout.weight(.medium)) + .foregroundStyle(self.theme.palette.accent) + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 6) { + Button { + self.onApprove() + } label: { + Image(systemName: "checkmark") + .font(.caption2) + } + .buttonStyle(.bordered) + .tint(.green) + .controlSize(.mini) + + Button(role: .destructive) { + self.onDismiss() + } label: { + Image(systemName: "xmark") + .font(.caption2) + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(.quaternary.opacity(0.5))) + } +} From 8094b284eb54da8409bad543cc77bb913a0489aa Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 14 Apr 2026 12:02:03 +0100 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20Address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20backup=20compat,=20trigger=20dedup,=20detection=20t?= =?UTF-8?q?uning,=20UI=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add backward-compatible CodingKeys+init(from:) to SettingsBackupPayload so older backup files missing autoLearn keys decode cleanly (P1). - Guard approveSuggestion against duplicate triggers across all entries, preventing order-dependent replacement conflicts (P2). - Switch looksLikeARealCorrection to ratio-based threshold (0.50 single, 0.65 multi-word) so phoneme-distant corrections like 'fluid boy' → 'FluidVoice' are no longer rejected. - Normalise NER entity matching (strip whitespace + lowercase) so 'Fluid Voice' (NER) matches 'FluidVoice' (replacement). - Polish AutoLearnSuggestionRow: orange accent border, hover feedback, relative timestamp, transition animation, labeled Add button. --- Sources/Fluid/Persistence/BackupService.swift | 224 ++++++++++++++++++ .../Services/AutoLearnDictionaryService.swift | 15 +- Sources/Fluid/UI/CustomDictionaryView.swift | 91 +++++-- 3 files changed, 304 insertions(+), 26 deletions(-) diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 4a25e5f2..eaa59062 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -62,6 +62,7 @@ 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? @@ -69,6 +70,229 @@ struct SettingsBackupPayload: Codable, Equatable { 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, 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.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, + 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.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/Services/AutoLearnDictionaryService.swift b/Sources/Fluid/Services/AutoLearnDictionaryService.swift index b54a5113..0c144d2d 100644 --- a/Sources/Fluid/Services/AutoLearnDictionaryService.swift +++ b/Sources/Fluid/Services/AutoLearnDictionaryService.swift @@ -222,8 +222,11 @@ final class AutoLearnDictionaryService { } private func isDictionaryWorthy(_ replacement: String, in editedText: String) -> Bool { + // Normalised NER matching: strip spaces and compare lowercased so + // "Fluid Voice" (NER) matches "FluidVoice" (replacement). + let normalisedReplacement = replacement.filter { !$0.isWhitespace }.lowercased() if self.namedEntities(in: editedText).contains(where: { - $0.caseInsensitiveCompare(replacement) == .orderedSame + $0.filter { !$0.isWhitespace }.lowercased() == normalisedReplacement }) { return true } @@ -247,8 +250,14 @@ final class AutoLearnDictionaryService { private func looksLikeARealCorrection(original: String, replacement: String) -> Bool { let distance = self.levenshteinDistance(original, replacement) let maxLength = max(original.count, replacement.count) - let allowedDistance = max(3, maxLength / 2) - return distance <= allowedDistance + 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 normalizePhrase(_ text: String) -> String { diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index 5c1c0473..bc163fc4 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -543,12 +543,18 @@ struct CustomDictionaryView: View { let replacement = suggestion.replacement.trimmingCharacters(in: .whitespacesAndNewlines) guard !trigger.isEmpty, !replacement.isEmpty else { return } + // Guard: skip if this trigger already exists under *any* replacement + // to avoid order-dependent duplicate entries. + let triggerAlreadyMapped = self.entries.contains { entry in + entry.triggers.contains(trigger) + } + if let index = self.entries.firstIndex(where: { $0.replacement.caseInsensitiveCompare(replacement) == .orderedSame }) { - if !self.entries[index].triggers.contains(trigger) { + if !self.entries[index].triggers.contains(trigger) && !triggerAlreadyMapped { self.entries[index].triggers.append(trigger) self.entries[index].triggers = Array(Set(self.entries[index].triggers)).sorted() } - } else { + } else if !triggerAlreadyMapped { self.entries.append( SettingsStore.CustomDictionaryEntry( triggers: [trigger], @@ -558,13 +564,17 @@ struct CustomDictionaryView: View { } self.saveEntries() - self.autoLearnSuggestions.removeAll { $0.id == suggestion.id } + withAnimation(.easeInOut(duration: 0.25)) { + self.autoLearnSuggestions.removeAll { $0.id == suggestion.id } + } self.saveAutoLearnSuggestions() } private func dismissSuggestion(_ suggestion: SettingsStore.AutoLearnSuggestion) { guard let index = self.autoLearnSuggestions.firstIndex(where: { $0.id == suggestion.id }) else { return } - self.autoLearnSuggestions[index].status = .dismissed + withAnimation(.easeInOut(duration: 0.25)) { + self.autoLearnSuggestions[index].status = .dismissed + } self.saveAutoLearnSuggestions() } @@ -1265,51 +1275,66 @@ struct AutoLearnSuggestionRow: View { let onDismiss: () -> Void @Environment(\.theme) private var theme + @State private var isHovered = false var body: some View { HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { + // Left: original text + metadata + VStack(alignment: .leading, spacing: 5) { Text("When heard:") .font(.caption2) .foregroundStyle(.tertiary) Text(self.suggestion.originalText) - .font(.caption) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(RoundedRectangle(cornerRadius: 4).fill(.quaternary)) - - Text("Observed \(self.suggestion.occurrences) times") - .font(.caption2) - .foregroundStyle(.secondary) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 5).fill(.quaternary)) + + HStack(spacing: 6) { + Text("\(self.suggestion.occurrences)× seen") + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + + Text("·") + .foregroundStyle(.quaternary) + + Text(self.relativeTimestamp) + .font(.caption2) + .foregroundStyle(.tertiary) + } } .frame(maxWidth: .infinity, alignment: .leading) + // Centre: directional arrow Image(systemName: "arrow.right") - .font(.caption2) + .font(.caption) .foregroundStyle(.tertiary) + .padding(.horizontal, 2) - VStack(alignment: .leading, spacing: 4) { + // Right: replacement text + VStack(alignment: .leading, spacing: 5) { Text("Replace with:") .font(.caption2) .foregroundStyle(.tertiary) Text(self.suggestion.replacement) - .font(.callout.weight(.medium)) + .font(.callout.weight(.semibold)) .foregroundStyle(self.theme.palette.accent) } .frame(maxWidth: .infinity, alignment: .leading) - HStack(spacing: 6) { + // Actions + HStack(spacing: 8) { Button { self.onApprove() } label: { - Image(systemName: "checkmark") - .font(.caption2) + Label("Add", systemImage: "checkmark") + .font(.caption2.weight(.medium)) } .buttonStyle(.bordered) .tint(.green) - .controlSize(.mini) + .controlSize(.small) Button(role: .destructive) { self.onDismiss() @@ -1318,10 +1343,30 @@ struct AutoLearnSuggestionRow: View { .font(.caption2) } .buttonStyle(.bordered) - .controlSize(.mini) + .controlSize(.small) } } - .padding(10) - .background(RoundedRectangle(cornerRadius: 8).fill(.quaternary.opacity(0.5))) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(self.isHovered ? .quaternary.opacity(0.8) : .quaternary.opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .inset(by: 0.5) + .stroke(Color.orange.opacity(self.isHovered ? 0.35 : 0.18), lineWidth: 1) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + self.isHovered = hovering + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + private var relativeTimestamp: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: self.suggestion.lastObservedAt, relativeTo: Date()) } } From e052dcc1ef533ef90fb11377234e5318781f716b Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 14 Apr 2026 12:43:48 +0100 Subject: [PATCH 03/10] fix: Capture full-field baseline + guard LCS matrix size - startMonitoring now reads kAXValue for the full field content as baseline instead of using only the pasted transcript snippet. This prevents junk diff candidates when dictating into non-empty editors (Codex P1). - CorrectionDiffEngine.findCorrectionCandidates bails out early when either token array exceeds 500 tokens, preventing O(n*m) DP blow-up on the main queue (Codex P2). Verified: zero errors and zero warnings in project sources via xcodebuild (upstream mcp-swift-sdk concurrency issue is pre-existing). --- .../Services/AutoLearnDictionaryService.swift | 15 +++++++++++++-- Sources/Fluid/Services/CorrectionDiffEngine.swift | 6 ++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Sources/Fluid/Services/AutoLearnDictionaryService.swift b/Sources/Fluid/Services/AutoLearnDictionaryService.swift index 0c144d2d..ebae5fdc 100644 --- a/Sources/Fluid/Services/AutoLearnDictionaryService.swift +++ b/Sources/Fluid/Services/AutoLearnDictionaryService.swift @@ -82,8 +82,19 @@ final class AutoLearnDictionaryService { private func startMonitoring(pastedText: String, element: AXUIElement) { self.stopMonitoring() - self.baselineText = pastedText - self.lastKnownText = pastedText + // 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.baselineText = pastedText + self.lastKnownText = pastedText + } self.isActive = true self.setupValueChangeObserver(for: element) diff --git a/Sources/Fluid/Services/CorrectionDiffEngine.swift b/Sources/Fluid/Services/CorrectionDiffEngine.swift index bb99d293..f1549ec1 100644 --- a/Sources/Fluid/Services/CorrectionDiffEngine.swift +++ b/Sources/Fluid/Services/CorrectionDiffEngine.swift @@ -15,6 +15,12 @@ enum CorrectionDiffEngine { 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 From 8fbfce53d6734b40444b80e824b51a6e7e43f408 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 14 Apr 2026 12:54:44 +0100 Subject: [PATCH 04/10] fix: Finalize active session before restarting monitoring Replaces stopMonitoring() with finalize() inside startMonitoring() so that if a user dictates twice in rapid succession (preempting the previous 30s timeout without an app switch), the corrections from the first dictation are safely diffed and captured before the baseline resets, rather than being discarded. --- Sources/Fluid/Services/AutoLearnDictionaryService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Fluid/Services/AutoLearnDictionaryService.swift b/Sources/Fluid/Services/AutoLearnDictionaryService.swift index ebae5fdc..f137cf88 100644 --- a/Sources/Fluid/Services/AutoLearnDictionaryService.swift +++ b/Sources/Fluid/Services/AutoLearnDictionaryService.swift @@ -80,7 +80,7 @@ final class AutoLearnDictionaryService { } private func startMonitoring(pastedText: String, element: AXUIElement) { - self.stopMonitoring() + self.finalize() // Capture the full field value as baseline so that both baselineText // and lastKnownText (updated via kAXValueChanged) cover the same scope. From 75398e1bef3d0c10d0e01dfe64d299de0806aa36 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 14 Apr 2026 12:59:48 +0100 Subject: [PATCH 05/10] refactor: Remove heuristic filtering from AutoLearn service Drops isDictionaryWorthy() and the NLTagger dependency. Previously, the service aggressively filtered out lowercase technical jargon (e.g., 'grep', 'sudo', 'yaml') because it lacked uppercase characters or digits, and wasn't recognised as a Named Entity. Since architectural safety is now provided by the explicit Review Queue and the occurrence threshold (>=2x), we no longer need these strict, lossy heuristics. This dramatically improves recall for domain-specific jargon without compromising safety. --- .../Services/AutoLearnDictionaryService.swift | 60 ------------------- 1 file changed, 60 deletions(-) diff --git a/Sources/Fluid/Services/AutoLearnDictionaryService.swift b/Sources/Fluid/Services/AutoLearnDictionaryService.swift index f137cf88..4354efba 100644 --- a/Sources/Fluid/Services/AutoLearnDictionaryService.swift +++ b/Sources/Fluid/Services/AutoLearnDictionaryService.swift @@ -1,7 +1,6 @@ import AppKit import ApplicationServices import Foundation -import NaturalLanguage final class AutoLearnDictionaryService { static let shared = AutoLearnDictionaryService() @@ -179,7 +178,6 @@ final class AutoLearnDictionaryService { guard !original.isEmpty, !replacement.isEmpty, !replacementKey.isEmpty else { return false } guard original != replacementKey else { return false } guard original.count <= 64, replacement.count <= 64 else { return false } - guard self.isDictionaryWorthy(replacement, in: editedText) else { return false } guard self.looksLikeARealCorrection(original: original, replacement: replacementKey) else { return false } guard !self.mappingAlreadyExists(original: original, replacement: replacement) else { return false } @@ -232,32 +230,6 @@ final class AutoLearnDictionaryService { SettingsStore.shared.autoLearnCustomDictionarySuggestions = suggestions } - private func isDictionaryWorthy(_ replacement: String, in editedText: String) -> Bool { - // Normalised NER matching: strip spaces and compare lowercased so - // "Fluid Voice" (NER) matches "FluidVoice" (replacement). - let normalisedReplacement = replacement.filter { !$0.isWhitespace }.lowercased() - if self.namedEntities(in: editedText).contains(where: { - $0.filter { !$0.isWhitespace }.lowercased() == normalisedReplacement - }) { - return true - } - - if replacement.rangeOfCharacter(from: .uppercaseLetters) != nil { - return true - } - - if replacement.rangeOfCharacter(from: .decimalDigits) != nil { - return true - } - - let symbolCharacterSet = CharacterSet(charactersIn: "-_/.'&+") - if replacement.rangeOfCharacter(from: symbolCharacterSet) != nil { - return true - } - - return false - } - private func looksLikeARealCorrection(original: String, replacement: String) -> Bool { let distance = self.levenshteinDistance(original, replacement) let maxLength = max(original.count, replacement.count) @@ -280,34 +252,6 @@ final class AutoLearnDictionaryService { .trimmingCharacters(in: .whitespacesAndNewlines) } - private func namedEntities(in text: String) -> [String] { - let recognizer = NLLanguageRecognizer() - recognizer.processString(text) - guard let language = recognizer.dominantLanguage, - Self.supportedLanguages.contains(language) - else { - return [] - } - - let tagger = NLTagger(tagSchemes: [.nameType]) - tagger.string = text - - var results: [String] = [] - let targetTags: Set = [.personalName, .placeName, .organizationName] - tagger.enumerateTags( - in: text.startIndex.. Int { let lhsChars = Array(lhs) let rhsChars = Array(rhs) @@ -332,10 +276,6 @@ final class AutoLearnDictionaryService { return previous[rhsChars.count] } - private static let supportedLanguages: Set = [ - .english, .german, .french, .spanish, .italian, .portuguese, .russian, .turkish - ] - var minimumSuggestionOccurrences: Int { self.suggestionThreshold } From f9d2501188b741c478999724aeb52fa9b652f605 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 14 Apr 2026 13:04:50 +0100 Subject: [PATCH 06/10] feat: Implement tiered hybrid threshold for review queue Replaces the global 2x threshold for AutoLearnSuggestions with a tiered hybrid approach inside CustomDictionaryView: - Fast Track (1x): If a correction contains uppercase letters, digits, or symbols, it hits the review queue immediately, providing instant time-to-value for high-probability custom jargon (e.g. 'K8s', 'macOS'). - Standard Track (2x): If a correction is purely lowercase text, we require 2 occurrences. This provides a robust safety net against review fatigue caused by one-off grammar/prose edits (e.g. 'is' -> 'was'). --- Sources/Fluid/UI/CustomDictionaryView.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index bc163fc4..158bccfb 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -102,9 +102,19 @@ struct CustomDictionaryView: View { } private var pendingAutoLearnSuggestions: [SettingsStore.AutoLearnSuggestion] { - let threshold = AutoLearnDictionaryService.shared.minimumSuggestionOccurrences return self.autoLearnSuggestions - .filter { $0.status == .pending && $0.occurrences >= threshold } + .filter { suggestion in + guard suggestion.status == .pending else { return false } + + // Fast-track: If the correction contains uppercase letters, digits, or special + // characters, it is almost certainly custom jargon/acronyms, not a grammar edit. + let isLikelyJargon = suggestion.replacement.rangeOfCharacter(from: .uppercaseLetters) != nil || + suggestion.replacement.rangeOfCharacter(from: .decimalDigits) != nil || + suggestion.replacement.rangeOfCharacter(from: CharacterSet(charactersIn: "-_/.'&+")) != nil + + let threshold = isLikelyJargon ? 1 : AutoLearnDictionaryService.shared.minimumSuggestionOccurrences + return suggestion.occurrences >= threshold + } .sorted { lhs, rhs in if lhs.occurrences != rhs.occurrences { return lhs.occurrences > rhs.occurrences From a84344430b6fffa6f8cc9af3fb3a6c06832e4194 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 14 Apr 2026 13:15:12 +0100 Subject: [PATCH 07/10] fix: start monitoring only after text injection completes --- Sources/Fluid/ContentView.swift | 8 +++++--- Sources/Fluid/Services/ASRService.swift | 8 ++++---- Sources/Fluid/Services/TypingService.swift | 19 ++++++++++++++++--- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index eacd51c5..ce83a014 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -1922,7 +1922,11 @@ struct ContentView: View { self.asr.typeTextToActiveField( finalText, preferredTargetPID: typingTarget.pid - ) + ) { + // Now that typing is physically complete, we can start monitoring. + // This ensures we snapshot the fresh text instead of the pre-insertion empty state. + AutoLearnDictionaryService.shared.beginMonitoring() + } didTypeExternally = true } @@ -1946,8 +1950,6 @@ struct ContentView: View { aiModel: modelInfo.model, aiProvider: modelInfo.provider ) - AutoLearnDictionaryService.shared.beginMonitoring() - NotchOverlayManager.shared.hide() } else if shouldPersistOutputs, SettingsStore.shared.copyTranscriptionToClipboard == false, diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index ac2491d0..adbcefc6 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -2528,12 +2528,12 @@ final class ASRService: ObservableObject { private let typingService = TypingService() // Reuse instance to avoid conflicts - func typeTextToActiveField(_ text: String) { - self.typingService.typeTextInstantly(text) + func typeTextToActiveField(_ text: String, onComplete: (() -> Void)? = nil) { + self.typingService.typeTextInstantly(text, onComplete: onComplete) } - func typeTextToActiveField(_ text: String, preferredTargetPID: pid_t?) { - self.typingService.typeTextInstantly(text, preferredTargetPID: preferredTargetPID) + func typeTextToActiveField(_ text: String, preferredTargetPID: pid_t?, onComplete: (() -> Void)? = nil) { + self.typingService.typeTextInstantly(text, preferredTargetPID: preferredTargetPID, onComplete: onComplete) } /// Removes filler sounds from transcribed text diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index b0976577..635d0b1a 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -224,24 +224,26 @@ final class TypingService { // MARK: - Public API - func typeTextInstantly(_ text: String) { - self.typeTextInstantly(text, preferredTargetPID: nil) + func typeTextInstantly(_ text: String, onComplete: (() -> Void)? = nil) { + 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?) { + func typeTextInstantly(_ text: String, preferredTargetPID: pid_t?, onComplete: (() -> Void)? = nil) { 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") + onComplete?() return } // Prevent concurrent typing operations guard !self.isCurrentlyTyping else { self.log("[TypingService] WARNING: Skipping text injection - already in progress") + onComplete?() return } @@ -249,6 +251,7 @@ final class TypingService { guard AXIsProcessTrusted() else { self.log("[TypingService] ERROR: Accessibility permissions required for text injection") self.log("[TypingService] Current accessibility status: \(AXIsProcessTrusted())") + onComplete?() return } @@ -259,6 +262,16 @@ final class TypingService { defer { self.isCurrentlyTyping = false self.log("[TypingService] Typing operation completed, isCurrentlyTyping set to false") + + // Allow a small final settle window before firing completion, + // ensuring the target app's event loop has processed the insertion so + // baseline snapshots capture the post-insertion state accurately. + if self.textInsertionMode == .reliablePaste { + usleep(50_000) + } + DispatchQueue.main.async { + onComplete?() + } } self.log("[TypingService] Starting async text insertion process") From f411e1969c2619a8fd9d1b84146182eef65542c4 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 14 Apr 2026 13:54:29 +0100 Subject: [PATCH 08/10] fix: address codex P2 feedback regarding dictionary auto-learn edge cases --- .../Services/AutoLearnDictionaryService.swift | 30 +++++++++++++++++-- Sources/Fluid/Services/TypingService.swift | 3 -- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Sources/Fluid/Services/AutoLearnDictionaryService.swift b/Sources/Fluid/Services/AutoLearnDictionaryService.swift index 4354efba..63ba67c8 100644 --- a/Sources/Fluid/Services/AutoLearnDictionaryService.swift +++ b/Sources/Fluid/Services/AutoLearnDictionaryService.swift @@ -19,6 +19,7 @@ final class AutoLearnDictionaryService { private var axObserver: AXObserver? private var workspaceObserver: NSObjectProtocol? private var timeoutTimer: DispatchSourceTimer? + private var pollingTimer: DispatchSourceTimer? private var isActive = false func captureFocusedElement() -> AXUIElement? { @@ -62,6 +63,9 @@ final class AutoLearnDictionaryService { self.timeoutTimer?.cancel() self.timeoutTimer = nil + self.pollingTimer?.cancel() + self.pollingTimer = nil + if let observer = self.axObserver { CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .defaultMode) self.axObserver = nil @@ -121,9 +125,29 @@ final class AutoLearnDictionaryService { guard AXObserverCreate(pid, callback, &observer) == .success, let observer else { return } let refcon = Unmanaged.passUnretained(self).toOpaque() - AXObserverAddNotification(observer, element, kAXValueChangedNotification as CFString, refcon) - CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .defaultMode) - self.axObserver = observer + if AXObserverAddNotification(observer, element, kAXValueChangedNotification as CFString, refcon) == .success { + CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .defaultMode) + self.axObserver = observer + } 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 } + var value: CFTypeRef? + if AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, &value) == .success, + let text = value as? String { + self.lastKnownText = text + } + } + timer.resume() + self.pollingTimer = timer } private func setupAppSwitchObserver() { diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index 635d0b1a..5be189d7 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -236,14 +236,12 @@ final class TypingService { guard text.isEmpty == false else { self.log("[TypingService] ERROR: Empty text provided, aborting") - onComplete?() return } // Prevent concurrent typing operations guard !self.isCurrentlyTyping else { self.log("[TypingService] WARNING: Skipping text injection - already in progress") - onComplete?() return } @@ -251,7 +249,6 @@ final class TypingService { guard AXIsProcessTrusted() else { self.log("[TypingService] ERROR: Accessibility permissions required for text injection") self.log("[TypingService] Current accessibility status: \(AXIsProcessTrusted())") - onComplete?() return } From f7f7ab438c0c3fdcafbe7f7aa23679fa4fa89392 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 14 Apr 2026 16:52:57 +0100 Subject: [PATCH 09/10] Fix auto-learn handoff and approval conflicts --- Sources/Fluid/ContentView.swift | 10 ++- .../Services/AutoLearnDictionaryService.swift | 20 +---- Sources/Fluid/UI/CustomDictionaryView.swift | 88 ++++++++++++++----- 3 files changed, 72 insertions(+), 46 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index ce83a014..8c2a6073 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -1916,16 +1916,18 @@ struct ContentView: View { if typingTarget.shouldRestoreOriginalFocus { await self.restoreFocusToRecordingTarget() } - if let element = AutoLearnDictionaryService.shared.captureFocusedElement() { - AutoLearnDictionaryService.shared.prepareMonitoring(pastedText: finalText, element: element) - } + let monitoringElement = AutoLearnDictionaryService.shared.captureFocusedElement() self.asr.typeTextToActiveField( finalText, preferredTargetPID: typingTarget.pid ) { // Now that typing is physically complete, we can start monitoring. // This ensures we snapshot the fresh text instead of the pre-insertion empty state. - AutoLearnDictionaryService.shared.beginMonitoring() + guard let monitoringElement else { return } + AutoLearnDictionaryService.shared.beginMonitoring( + pastedText: finalText, + element: monitoringElement + ) } didTypeExternally = true } diff --git a/Sources/Fluid/Services/AutoLearnDictionaryService.swift b/Sources/Fluid/Services/AutoLearnDictionaryService.swift index 63ba67c8..eea69ab3 100644 --- a/Sources/Fluid/Services/AutoLearnDictionaryService.swift +++ b/Sources/Fluid/Services/AutoLearnDictionaryService.swift @@ -11,9 +11,6 @@ final class AutoLearnDictionaryService { private let suggestionThreshold = 2 private let maxSegmentTokenCount = 3 - private var pendingElement: AXUIElement? - private var pendingText: String = "" - private var baselineText: String = "" private var lastKnownText: String = "" private var axObserver: AXObserver? @@ -40,20 +37,10 @@ final class AutoLearnDictionaryService { return unsafeBitCast(focusedElement, to: AXUIElement.self) } - func prepareMonitoring(pastedText: String, element: AXUIElement) { - self.pendingElement = element - self.pendingText = pastedText - } - - func beginMonitoring() { + func beginMonitoring(pastedText: String, element: AXUIElement) { guard SettingsStore.shared.autoLearnCustomDictionaryEnabled else { - self.clearPending() return } - guard let element = self.pendingElement else { return } - - let pastedText = self.pendingText - self.clearPending() self.startMonitoring(pastedText: pastedText, element: element) } @@ -77,11 +64,6 @@ final class AutoLearnDictionaryService { } } - private func clearPending() { - self.pendingElement = nil - self.pendingText = "" - } - private func startMonitoring(pastedText: String, element: AXUIElement) { self.finalize() diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index 158bccfb..c77ecd36 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -9,6 +9,12 @@ import SwiftUI struct CustomDictionaryView: View { + private enum SuggestionApprovalResult { + case applied + case alreadyPresent + case conflict(existingReplacement: String) + } + @Environment(\.theme) private var theme @State private var entries: [SettingsStore.CustomDictionaryEntry] = SettingsStore.shared.customDictionaryEntries @State private var boostTerms: [ParakeetVocabularyStore.VocabularyConfig.Term] = [] @@ -27,6 +33,7 @@ struct CustomDictionaryView: View { @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) { @@ -191,6 +198,22 @@ struct CustomDictionaryView: View { .font(.caption2) .foregroundStyle(.secondary) + 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") @@ -553,31 +576,24 @@ struct CustomDictionaryView: View { let replacement = suggestion.replacement.trimmingCharacters(in: .whitespacesAndNewlines) guard !trigger.isEmpty, !replacement.isEmpty else { return } - // Guard: skip if this trigger already exists under *any* replacement - // to avoid order-dependent duplicate entries. - let triggerAlreadyMapped = self.entries.contains { entry in - entry.triggers.contains(trigger) - } - - if let index = self.entries.firstIndex(where: { $0.replacement.caseInsensitiveCompare(replacement) == .orderedSame }) { - if !self.entries[index].triggers.contains(trigger) && !triggerAlreadyMapped { - self.entries[index].triggers.append(trigger) - self.entries[index].triggers = Array(Set(self.entries[index].triggers)).sorted() + switch self.applySuggestion(trigger: trigger, replacement: replacement) { + case .applied: + self.autoLearnStatusMessage = nil + self.saveEntries() + withAnimation(.easeInOut(duration: 0.25)) { + self.autoLearnSuggestions.removeAll { $0.id == suggestion.id } } - } else if !triggerAlreadyMapped { - self.entries.append( - SettingsStore.CustomDictionaryEntry( - triggers: [trigger], - replacement: replacement - ) - ) - } - - self.saveEntries() - withAnimation(.easeInOut(duration: 0.25)) { - self.autoLearnSuggestions.removeAll { $0.id == suggestion.id } + self.saveAutoLearnSuggestions() + case .alreadyPresent: + self.autoLearnStatusMessage = nil + withAnimation(.easeInOut(duration: 0.25)) { + self.autoLearnSuggestions.removeAll { $0.id == suggestion.id } + } + self.saveAutoLearnSuggestions() + case .conflict(let existingReplacement): + self.autoLearnStatusMessage = + "\"\(trigger)\" already maps to \"\(existingReplacement)\". Review the existing dictionary entry before approving this suggestion." } - self.saveAutoLearnSuggestions() } private func dismissSuggestion(_ suggestion: SettingsStore.AutoLearnSuggestion) { @@ -588,6 +604,32 @@ struct CustomDictionaryView: View { 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) From 58ced2e3787be188cf10689fe2d622867b7b22a0 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Tue, 14 Apr 2026 17:26:25 +0100 Subject: [PATCH 10/10] Stop auto-learn immediately when disabled --- Sources/Fluid/Services/AutoLearnDictionaryService.swift | 1 + Sources/Fluid/UI/CustomDictionaryView.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Sources/Fluid/Services/AutoLearnDictionaryService.swift b/Sources/Fluid/Services/AutoLearnDictionaryService.swift index eea69ab3..ca6744e7 100644 --- a/Sources/Fluid/Services/AutoLearnDictionaryService.swift +++ b/Sources/Fluid/Services/AutoLearnDictionaryService.swift @@ -157,6 +157,7 @@ final class AutoLearnDictionaryService { private func finalize() { guard self.isActive else { return } self.stopMonitoring() + guard SettingsStore.shared.autoLearnCustomDictionaryEnabled else { return } let baseline = self.baselineText let currentText = self.lastKnownText diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index c77ecd36..29a389a9 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -187,6 +187,9 @@ struct CustomDictionaryView: View { .controlSize(.small) .onChange(of: self.autoLearnEnabled) { _, newValue in SettingsStore.shared.autoLearnCustomDictionaryEnabled = newValue + if !newValue { + AutoLearnDictionaryService.shared.stopMonitoring() + } } .padding(10) .background(