diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index b6ff8c1..8c2a607 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -1916,10 +1916,19 @@ struct ContentView: View { if typingTarget.shouldRestoreOriginalFocus { await self.restoreFocusToRecordingTarget() } + 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. + guard let monitoringElement else { return } + AutoLearnDictionaryService.shared.beginMonitoring( + pastedText: finalText, + element: monitoringElement + ) + } didTypeExternally = true } @@ -1943,7 +1952,6 @@ struct ContentView: View { aiModel: modelInfo.model, aiProvider: modelInfo.provider ) - NotchOverlayManager.shared.hide() } else if shouldPersistOutputs, SettingsStore.shared.copyTranscriptionToClipboard == false, diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 5ac4e21..eaa5906 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -62,11 +62,237 @@ 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, 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/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index f4deb98..66fa7dc 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/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index ac2491d..adbcefc 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/AutoLearnDictionaryService.swift b/Sources/Fluid/Services/AutoLearnDictionaryService.swift new file mode 100644 index 0000000..ca6744e --- /dev/null +++ b/Sources/Fluid/Services/AutoLearnDictionaryService.swift @@ -0,0 +1,289 @@ +import AppKit +import ApplicationServices +import Foundation + +final class AutoLearnDictionaryService { + static let shared = AutoLearnDictionaryService() + + private init() {} + + private let monitoringTimeoutSeconds: TimeInterval = 30 + private let suggestionThreshold = 2 + private let maxSegmentTokenCount = 3 + + private var baselineText: String = "" + private var lastKnownText: String = "" + private var axObserver: AXObserver? + private var workspaceObserver: NSObjectProtocol? + private var timeoutTimer: DispatchSourceTimer? + private var pollingTimer: 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 beginMonitoring(pastedText: String, element: AXUIElement) { + guard SettingsStore.shared.autoLearnCustomDictionaryEnabled else { + return + } + self.startMonitoring(pastedText: pastedText, element: element) + } + + func stopMonitoring() { + self.isActive = false + + 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 + } + + if let observer = self.workspaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + self.workspaceObserver = nil + } + } + + private func startMonitoring(pastedText: String, element: AXUIElement) { + self.finalize() + + // 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) + 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() + 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() { + 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() + guard SettingsStore.shared.autoLearnCustomDictionaryEnabled else { return } + + 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.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 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 normalizePhrase(_ text: String) -> String { + text + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + 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 + } +} diff --git a/Sources/Fluid/Services/CorrectionDiffEngine.swift b/Sources/Fluid/Services/CorrectionDiffEngine.swift new file mode 100644 index 0000000..f1549ec --- /dev/null +++ b/Sources/Fluid/Services/CorrectionDiffEngine.swift @@ -0,0 +1,118 @@ +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 [] } + + // 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.. 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/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index b097657..5be189d 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -224,13 +224,13 @@ 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)))\"") @@ -259,6 +259,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") diff --git a/Sources/Fluid/UI/CustomDictionaryView.swift b/Sources/Fluid/UI/CustomDictionaryView.swift index 957a656..29a389a 100644 --- a/Sources/Fluid/UI/CustomDictionaryView.swift +++ b/Sources/Fluid/UI/CustomDictionaryView.swift @@ -9,27 +9,39 @@ 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] = [] + @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 @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 + self.autoLearnSection + // Section 1: Custom Words (Parakeet) self.aiPostProcessingSection @@ -73,6 +85,7 @@ struct CustomDictionaryView: View { } .onAppear { self.loadBoostTerms() + self.reloadAutoLearnSuggestions() } } @@ -95,6 +108,148 @@ struct CustomDictionaryView: View { } } + private var pendingAutoLearnSuggestions: [SettingsStore.AutoLearnSuggestion] { + return self.autoLearnSuggestions + .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 + } + 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 + if !newValue { + AutoLearnDictionaryService.shared.stopMonitoring() + } + } + .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 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 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 +565,74 @@ 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() + 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." + } + } + + 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.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) @@ -1098,3 +1321,107 @@ 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 + @State private var isHovered = false + + 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.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(.caption) + .foregroundStyle(.tertiary) + .padding(.horizontal, 2) + + // 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: 8) { + Button { + self.onApprove() + } label: { + Label("Add", systemImage: "checkmark") + .font(.caption2.weight(.medium)) + } + .buttonStyle(.bordered) + .tint(.green) + .controlSize(.small) + + Button(role: .destructive) { + self.onDismiss() + } label: { + Image(systemName: "xmark") + .font(.caption2) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .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()) + } +}