From 1bd57dbaa294123ee1f07c674aabb6f2f78d3ad8 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 10:08:24 -0500 Subject: [PATCH 1/6] feat: add "Paste Last Transcription" action (hotkey + overlay), clipboard-free Re-inserts the most recent transcription into the focused text field using the existing clipboard-free insertion path (TypingService standard mode). Unlike "Copy Last Transcription", it never writes to the system clipboard, so it works for users who keep `copyTranscriptionToClipboard` disabled. Useful when auto-insert drops the tail of a dictation or text lands in the wrong field. Exposed two ways, both routed through ContentView.pasteLastDictationFromHistory(): - A configurable global hotkey, unbound and disabled by default (opt-in, assigned in Settings, mirroring the Command/Edit-mode default-off model). GlobalHotkeyManager reads the shortcut live from SettingsStore, like Cancel. - An item in the overlay actions menu beside "Copy Last Transcription". The action pastes the last entry's processedText (fallback rawText) verbatim: no new history entry, no reformatting. It reuses resolveTypingTargetPID() / restoreFocusToRecordingTarget() so it targets the right field for both the hotkey (insert at cursor) and menu (restore prior focus) cases, is ignored while recording, and no-ops with a debug log when history is empty. The new shortcut + enabled flag are persisted and added to SettingsBackupPayload as optionals (older backups still decode). Adds HotkeyShortcutTests coverage for the default (unbound/disabled) and persistence/clear round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Fluid/ContentView.swift | 73 ++++++++++++++++++- Sources/Fluid/Persistence/BackupService.swift | 3 + Sources/Fluid/Persistence/SettingsStore.swift | 45 ++++++++++++ .../Fluid/Services/GlobalHotkeyManager.swift | 31 ++++++++ Sources/Fluid/UI/SettingsView.swift | 31 ++++++++ Sources/Fluid/Views/BottomOverlayView.swift | 13 ++++ Sources/Fluid/Views/NotchContentViews.swift | 2 + .../HotkeyShortcutTests.swift | 33 +++++++++ 8 files changed, 230 insertions(+), 1 deletion(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 1a5d3ec3..c5723ed2 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -77,6 +77,7 @@ enum ShortcutRecordingTarget: Hashable { case command case edit case cancel + case pasteLast case dictationPrompt(String) case newPrompt @@ -92,6 +93,8 @@ enum ShortcutRecordingTarget: Hashable { return "Edit Mode" case .cancel: return "Cancel Recording" + case .pasteLast: + return "Paste Last Transcription" case .dictationPrompt: return "Prompt Shortcut" case .newPrompt: @@ -101,7 +104,7 @@ enum ShortcutRecordingTarget: Hashable { var enablesFeatureOnAssignment: Bool { switch self { - case .secondaryDictation, .command, .edit: + case .secondaryDictation, .command, .edit, .pasteLast: return true case .primaryDictation, .cancel, .dictationPrompt, .newPrompt: return false @@ -182,6 +185,8 @@ struct ContentView: View { @State private var commandModeHotkeyShortcut: HotkeyShortcut? = SettingsStore.shared.commandModeHotkeyShortcut @State private var rewriteModeHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.rewriteModeHotkeyShortcut @State private var cancelRecordingHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.cancelRecordingHotkeyShortcut + @State private var pasteLastTranscriptionHotkeyShortcut: HotkeyShortcut? = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut + @State private var isPasteLastTranscriptionShortcutEnabled: Bool = SettingsStore.shared.pasteLastTranscriptionShortcutEnabled @State private var isPromptModeShortcutEnabled: Bool = SettingsStore.shared.promptModeShortcutEnabled @State private var isCommandModeShortcutEnabled: Bool = SettingsStore.shared.commandModeShortcutEnabled @State private var isRewriteModeShortcutEnabled: Bool = SettingsStore.shared.rewriteModeShortcutEnabled @@ -456,6 +461,20 @@ struct ContentView: View { .onChange(of: self.isRewriteModeShortcutEnabled) { newValue in self.handleRewriteShortcutEnabledChange(newValue) } + .onChange(of: self.pasteLastTranscriptionHotkeyShortcut) { _, newValue in + // The hotkey manager reads this value live from SettingsStore, so persisting is enough. + SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut = newValue + } + .onChange(of: self.isPasteLastTranscriptionShortcutEnabled) { newValue in + self.handlePasteLastTranscriptionShortcutEnabledChange(newValue) + } + } + + private func handlePasteLastTranscriptionShortcutEnabledChange(_ isEnabled: Bool) { + SettingsStore.shared.pasteLastTranscriptionShortcutEnabled = isEnabled + if !isEnabled, self.activeShortcutRecordingTarget == .pasteLast { + self.clearShortcutRecordingMode() + } } private func handlePromptShortcutEnabledChange(_ isEnabled: Bool) { @@ -999,6 +1018,7 @@ struct ContentView: View { ] let optionalConfiguredShortcuts: [(ShortcutRecordingTarget, HotkeyShortcut?)] = [ (.command, self.commandModeHotkeyShortcut), + (.pasteLast, self.pasteLastTranscriptionHotkeyShortcut), ] for (otherTarget, configuredShortcut) in configuredShortcuts where otherTarget != target { @@ -1064,6 +1084,10 @@ struct ContentView: View { case .cancel: self.cancelRecordingHotkeyShortcut = shortcut SettingsStore.shared.cancelRecordingHotkeyShortcut = shortcut + case .pasteLast: + // The hotkey manager reads this shortcut directly from SettingsStore, so no manager update is needed. + self.pasteLastTranscriptionHotkeyShortcut = shortcut + SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut = shortcut case let .dictationPrompt(key): guard let selection = SettingsStore.shared.dictationPromptSelection(forConfigurationKey: key) else { return } var configuration = SettingsStore.shared.dictationPromptConfiguration(for: selection) @@ -1108,6 +1132,9 @@ struct ContentView: View { self.isRewriteModeShortcutEnabled = enabled SettingsStore.shared.rewriteModeShortcutEnabled = enabled self.hotkeyManager?.updateRewriteModeShortcutEnabled(enabled) + case .pasteLast: + self.isPasteLastTranscriptionShortcutEnabled = enabled + SettingsStore.shared.pasteLastTranscriptionShortcutEnabled = enabled case .primaryDictation, .cancel, .dictationPrompt, .newPrompt: break } @@ -1435,8 +1462,10 @@ struct ContentView: View { commandModeShortcut: self.$commandModeHotkeyShortcut, rewriteShortcut: self.$rewriteModeHotkeyShortcut, cancelRecordingShortcut: self.$cancelRecordingHotkeyShortcut, + pasteLastTranscriptionShortcut: self.$pasteLastTranscriptionHotkeyShortcut, commandModeShortcutEnabled: self.$isCommandModeShortcutEnabled, rewriteShortcutEnabled: self.$isRewriteModeShortcutEnabled, + pasteLastTranscriptionShortcutEnabled: self.$isPasteLastTranscriptionShortcutEnabled, hotkeyManagerInitialized: self.$hotkeyManagerInitialized, hotkeyMode: self.$hotkeyMode, enableStreamingPreview: self.$enableStreamingPreview, @@ -2478,6 +2507,40 @@ struct ContentView: View { DebugLogger.shared.info("Actions: Copied latest transcription to clipboard", source: "ContentView") } + /// Re-inserts the most recent transcription into the focused text field using the same + /// clipboard-free insertion path as live dictation. Unlike copy, this never touches the + /// system clipboard, and unlike reprocess, it pastes the existing text verbatim (no new + /// history entry, no reformatting). Useful when the original auto-insert dropped the tail. + private func pasteLastDictationFromHistory() { + guard let last = TranscriptionHistoryStore.shared.entries.first else { + DebugLogger.shared.info("Actions: Paste requested but history is empty", source: "ContentView") + return + } + + // Prefer the processed text (what was actually delivered, possibly AI-enhanced), + // falling back to raw for older entries or when enhancement was off. + let processed = last.processedText.trimmingCharacters(in: .whitespacesAndNewlines) + let raw = last.rawText.trimmingCharacters(in: .whitespacesAndNewlines) + let text = processed.isEmpty ? raw : processed + guard !text.isEmpty else { + DebugLogger.shared.info("Actions: Paste skipped because latest history text is empty", source: "ContentView") + return + } + + Task { @MainActor in + let typingTarget = self.resolveTypingTargetPID() + guard typingTarget.pid != nil else { + DebugLogger.shared.info("Actions: Paste skipped - no external target field available", source: "ContentView") + return + } + if typingTarget.shouldRestoreOriginalFocus { + await self.restoreFocusToRecordingTarget() + } + self.asr.typeTextToActiveField(text, preferredTargetPID: typingTarget.pid) + DebugLogger.shared.info("Actions: Pasted latest transcription into focused field", source: "ContentView") + } + } + private func undoLastAIProcessingFromHistory() { guard let last = TranscriptionHistoryStore.shared.entries.first else { DebugLogger.shared.info("Actions: Undo AI requested but history is empty", source: "ContentView") @@ -2999,6 +3062,9 @@ struct ContentView: View { NotchContentState.shared.onCopyLastRequested = { self.copyLastDictationFromHistory() } + NotchContentState.shared.onPasteLastRequested = { + self.pasteLastDictationFromHistory() + } NotchContentState.shared.onUndoLastAIRequested = { self.undoLastAIProcessingFromHistory() } @@ -3169,6 +3235,11 @@ struct ContentView: View { return handled } + // Re-insert the most recent transcription on demand (no clipboard involved). + self.hotkeyManager?.setPasteLastTranscriptionCallback { + self.pasteLastDictationFromHistory() + } + // Monitor initialization status Task { // Give some time for initialization diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 2927dfd7..9ca1ced3 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -37,6 +37,9 @@ struct SettingsBackupPayload: Codable, Equatable { let rewriteModeSelectedProviderID: String let rewriteModeLinkedToGlobal: Bool let cancelRecordingHotkeyShortcut: HotkeyShortcut + // Optional so older backup files (which predate this setting) still decode. + let pasteLastTranscriptionHotkeyShortcut: HotkeyShortcut? + let pasteLastTranscriptionShortcutEnabled: Bool? let showThinkingTokens: Bool let hideFromDockAndAppSwitcher: Bool let showMainWindowAtLoginLaunch: Bool? diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 82d0fbec..1325226a 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -2399,6 +2399,43 @@ final class SettingsStore: ObservableObject { } } + // MARK: - Paste Last Transcription Settings + + /// Whether the "Paste Last Transcription" global hotkey is active. Opt-in and off by default. + var pasteLastTranscriptionShortcutEnabled: Bool { + get { + let value = self.defaults.object(forKey: Keys.pasteLastTranscriptionShortcutEnabled) + return value as? Bool ?? false + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.pasteLastTranscriptionShortcutEnabled) + } + } + + /// The shortcut that re-inserts the most recent transcription into the focused field. + /// Unbound (nil) by default so it never collides with an existing shortcut until the user assigns one. + var pasteLastTranscriptionHotkeyShortcut: HotkeyShortcut? { + get { + if let data = defaults.data(forKey: Keys.pasteLastTranscriptionHotkeyShortcut), + let shortcut = try? JSONDecoder().decode(HotkeyShortcut.self, from: data) + { + return shortcut + } + return nil + } + set { + objectWillChange.send() + guard let newValue else { + self.defaults.removeObject(forKey: Keys.pasteLastTranscriptionHotkeyShortcut) + return + } + if let data = try? JSONEncoder().encode(newValue) { + self.defaults.set(data, forKey: Keys.pasteLastTranscriptionHotkeyShortcut) + } + } + } + var commandModeConfirmBeforeExecute: Bool { get { // Default to true (safer - ask before running commands) @@ -2725,6 +2762,8 @@ final class SettingsStore: ObservableObject { rewriteModeSelectedProviderID: self.rewriteModeSelectedProviderID, rewriteModeLinkedToGlobal: self.rewriteModeLinkedToGlobal, cancelRecordingHotkeyShortcut: self.cancelRecordingHotkeyShortcut, + pasteLastTranscriptionHotkeyShortcut: self.pasteLastTranscriptionHotkeyShortcut, + pasteLastTranscriptionShortcutEnabled: self.pasteLastTranscriptionShortcutEnabled, showThinkingTokens: self.showThinkingTokens, hideFromDockAndAppSwitcher: self.hideFromDockAndAppSwitcher, showMainWindowAtLoginLaunch: self.showMainWindowAtLoginLaunch, @@ -2816,6 +2855,10 @@ final class SettingsStore: ObservableObject { self.rewriteModeSelectedProviderID = payload.rewriteModeSelectedProviderID self.rewriteModeLinkedToGlobal = payload.rewriteModeLinkedToGlobal self.cancelRecordingHotkeyShortcut = payload.cancelRecordingHotkeyShortcut + self.pasteLastTranscriptionHotkeyShortcut = payload.pasteLastTranscriptionHotkeyShortcut + if let pasteLastTranscriptionShortcutEnabled = payload.pasteLastTranscriptionShortcutEnabled { + self.pasteLastTranscriptionShortcutEnabled = pasteLastTranscriptionShortcutEnabled + } self.showThinkingTokens = payload.showThinkingTokens self.hideFromDockAndAppSwitcher = payload.hideFromDockAndAppSwitcher self.showMainWindowAtLoginLaunch = payload.showMainWindowAtLoginLaunch ?? true @@ -4367,6 +4410,8 @@ private extension SettingsStore { static let commandModeHotkeyShortcut = "CommandModeHotkeyShortcut" static let commandModeConfirmBeforeExecute = "CommandModeConfirmBeforeExecute" static let cancelRecordingHotkeyShortcut = "CancelRecordingHotkeyShortcut" + static let pasteLastTranscriptionHotkeyShortcut = "PasteLastTranscriptionHotkeyShortcut" + static let pasteLastTranscriptionShortcutEnabled = "PasteLastTranscriptionShortcutEnabled" static let commandModeLinkedToGlobal = "CommandModeLinkedToGlobal" static let commandModeShortcutEnabled = "CommandModeShortcutEnabled" diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 2e1cef4b..bfee8d2b 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -69,6 +69,7 @@ final class GlobalHotkeyManager: NSObject { private var isRewriteRecordingProvider: (() -> Bool)? private var isShortcutCaptureActiveProvider: (() -> Bool)? private var cancelCallback: (() -> Bool)? // Returns true if handled + private var pasteLastTranscriptionCallback: (() -> Void)? private var hotkeyMode: HotkeyActivationMode = SettingsStore.shared.hotkeyMode private let automaticTapThresholdSeconds: TimeInterval = 0.4 @@ -414,6 +415,10 @@ final class GlobalHotkeyManager: NSObject { self.cancelCallback = callback } + func setPasteLastTranscriptionCallback(_ callback: @escaping () -> Void) { + self.pasteLastTranscriptionCallback = callback + } + private func setupGlobalHotkeyWithRetry() { for attempt in 1...self.maxRetryAttempts { DebugLogger.shared.debug("Setup attempt \(attempt)/\(self.maxRetryAttempts)", source: "GlobalHotkeyManager") @@ -649,6 +654,15 @@ final class GlobalHotkeyManager: NSObject { } } + // Check the "paste last transcription" shortcut (a one-shot action, like cancel). + if SettingsStore.shared.pasteLastTranscriptionShortcutEnabled, + let pasteShortcut = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut, + pasteShortcut.matches(keyCode: keyCode, modifiers: eventModifiers) + { + self.triggerPasteLastTranscription() + return nil + } + if let assignment = self.promptShortcutAssignments.first(where: { $0.shortcut.matches(keyCode: keyCode, modifiers: eventModifiers) }) { switch self.hotkeyMode { case .hold: @@ -1652,6 +1666,23 @@ final class GlobalHotkeyManager: NSObject { } } + private func triggerPasteLastTranscription() { + Task { @MainActor [weak self] in + guard let self = self else { return } + guard self.canTriggerRecordingAction("Paste last transcription hotkey") else { return } + // Re-pasting mid-recording would be surprising; ignore while capture is active. + guard !self.asrService.isRunning else { + DebugLogger.shared.info( + "Paste last transcription hotkey ignored - recording in progress", + source: "GlobalHotkeyManager" + ) + return + } + DebugLogger.shared.info("Paste last transcription hotkey triggered", source: "GlobalHotkeyManager") + self.pasteLastTranscriptionCallback?() + } + } + private func triggerDictationMode() { Task { @MainActor [weak self] in guard let self = self else { return } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index d9b280e1..b65b02ad 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -40,8 +40,10 @@ struct SettingsView: View { @Binding var commandModeShortcut: HotkeyShortcut? @Binding var rewriteShortcut: HotkeyShortcut @Binding var cancelRecordingShortcut: HotkeyShortcut + @Binding var pasteLastTranscriptionShortcut: HotkeyShortcut? @Binding var commandModeShortcutEnabled: Bool @Binding var rewriteShortcutEnabled: Bool + @Binding var pasteLastTranscriptionShortcutEnabled: Bool @Binding var hotkeyManagerInitialized: Bool @Binding var hotkeyMode: HotkeyActivationMode @Binding var enableStreamingPreview: Bool @@ -757,6 +759,35 @@ struct SettingsView: View { self.activeShortcutRecordingTarget = .cancel } ) + Divider().opacity(0.2).padding(.vertical, 4) + + self.shortcutRow( + content: .init( + icon: "arrow.down.doc", + iconColor: .secondary, + title: "Paste Last Transcription", + description: "Re-insert your most recent transcription without using the clipboard" + ), + shortcut: self.pasteLastTranscriptionShortcut, + isRecording: self.isRecording(.pasteLast), + isAnyRecordingActive: self.isRecordingAnyShortcut, + recordingMessage: self.isRecording(.pasteLast) ? self.shortcutRecordingMessage : nil, + isEnabled: self.$pasteLastTranscriptionShortcutEnabled, + requiresShortcutToEnable: true, + onChangePressed: { + DebugLogger.shared.debug("Starting to record new paste last transcription shortcut", source: "SettingsView") + self.shortcutRecordingMessage = nil + self.activeShortcutRecordingTarget = .pasteLast + }, + onRemovePressed: { + if self.activeShortcutRecordingTarget == .pasteLast { + self.shortcutRecordingMessage = nil + self.activeShortcutRecordingTarget = nil + } + self.pasteLastTranscriptionShortcut = nil + self.pasteLastTranscriptionShortcutEnabled = false + } + ) } .padding(12) .background( diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index 6cca58a1..ee0211d3 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -1539,6 +1539,10 @@ private struct BottomOverlayActionsMenuView: View { return !(processed.isEmpty && raw.isEmpty) } + private var canPasteLast: Bool { + self.canCopyLast + } + private var canUndoLastAI: Bool { guard !self.contentState.isProcessing else { return false } guard let latest = self.latestEntry else { return false } @@ -1629,6 +1633,15 @@ private struct BottomOverlayActionsMenuView: View { self.contentState.onCopyLastRequested?() } + self.actionRow( + title: "Paste Last Transcription", + icon: "arrow.down.doc", + rowID: "paste_last", + enabled: self.canPasteLast + ) { + self.contentState.onPasteLastRequested?() + } + Divider() .padding(.vertical, 4) diff --git a/Sources/Fluid/Views/NotchContentViews.swift b/Sources/Fluid/Views/NotchContentViews.swift index a3696319..6b32e15f 100644 --- a/Sources/Fluid/Views/NotchContentViews.swift +++ b/Sources/Fluid/Views/NotchContentViews.swift @@ -157,6 +157,8 @@ class NotchContentState: ObservableObject { var onReprocessLastRequested: (() -> Void)? /// Called when the user requests copying the latest saved transcription entry. var onCopyLastRequested: (() -> Void)? + /// Called when the user requests re-pasting the latest saved transcription entry. + var onPasteLastRequested: (() -> Void)? /// Called when the user requests undoing AI processing for the latest entry. var onUndoLastAIRequested: (() -> Void)? /// Called when the user requests opening Preferences. diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index a0e3f4ec..a1cc7a4b 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -6,6 +6,8 @@ import XCTest final class HotkeyShortcutTests: XCTestCase { private let legacyHotkeyShortcutKey = "HotkeyShortcutKey" private let primaryDictationShortcutsKey = "PrimaryDictationShortcuts" + private let pasteLastTranscriptionShortcutKey = "PasteLastTranscriptionHotkeyShortcut" + private let pasteLastTranscriptionEnabledKey = "PasteLastTranscriptionShortcutEnabled" func testLegacyKeyboardShortcutPayloadDefaultsToKeyboardKind() throws { let json = #"{"keyCode":61,"modifierFlagsRawValue":0}"# @@ -110,6 +112,37 @@ final class HotkeyShortcutTests: XCTestCase { } } + func testPasteLastTranscriptionShortcutDefaultsToUnboundAndDisabled() throws { + try self.withRestoredDefaults(keys: [ + self.pasteLastTranscriptionShortcutKey, + self.pasteLastTranscriptionEnabledKey, + ]) { + UserDefaults.standard.removeObject(forKey: self.pasteLastTranscriptionShortcutKey) + UserDefaults.standard.removeObject(forKey: self.pasteLastTranscriptionEnabledKey) + + XCTAssertNil(SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut) + XCTAssertFalse(SettingsStore.shared.pasteLastTranscriptionShortcutEnabled) + } + } + + func testPasteLastTranscriptionShortcutPersistsAndClears() throws { + try self.withRestoredDefaults(keys: [ + self.pasteLastTranscriptionShortcutKey, + self.pasteLastTranscriptionEnabledKey, + ]) { + let shortcut = HotkeyShortcut(keyCode: 9, modifierFlags: [.command, .shift]) + SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut = shortcut + SettingsStore.shared.pasteLastTranscriptionShortcutEnabled = true + + XCTAssertEqual(SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut, shortcut) + XCTAssertTrue(SettingsStore.shared.pasteLastTranscriptionShortcutEnabled) + + // Removing the shortcut returns to the unbound state. + SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut = nil + XCTAssertNil(SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut) + } + } + private func withRestoredDefaults(keys: [String], run: () throws -> Void) rethrows { let defaults = UserDefaults.standard var snapshot: [String: Any] = [:] From 5fc1ad78260b46dc60fcc87c7288d67896fe7ee2 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 11:35:07 -0500 Subject: [PATCH 2/6] fix: make Paste Last Transcription usable and reliable (runtime fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues found while testing the feature on a real build: 1. Shortcut recording gave no feedback for opt-in (off-by-default) rows. In `shortcutRow`, the "press keys…" capture pill was gated on `isRecording && enabledValue`, so for a row whose toggle is still off (Command Mode, Paste Last Transcription) clicking "Change" started recording but kept showing "Not set" — it looked dead. Show the capture pill whenever recording, regardless of the enable toggle. 2. The paste hotkey fired on key-down while its own modifiers (e.g. ⌘⌃) were still physically held, so the synthesized characters were interpreted by the target app as modifier+key shortcuts and dropped (the insert "succeeded" but no text landed). Dictation never hits this because no keys are held by the time it types. Paste now waits (up to 0.6s, polling 15ms) for the modifier keys to be released before inserting, so the text lands cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Fluid/ContentView.swift | 21 +++++++++++++++++++++ Sources/Fluid/UI/SettingsView.swift | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index c5723ed2..6021711b 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2528,6 +2528,13 @@ struct ContentView: View { } Task { @MainActor in + // The hotkey fires on key-down while its own modifier keys (e.g. ⌘⌃) are still + // physically held. Synthesizing text in that state makes the target app treat the + // characters as keyboard shortcuts and drop them, so wait for the modifiers to be + // released before inserting. (Dictation never hits this because by the time it types, + // no keys are held.) + await Self.waitForHotkeyModifiersReleased(timeout: 0.6) + let typingTarget = self.resolveTypingTargetPID() guard typingTarget.pid != nil else { DebugLogger.shared.info("Actions: Paste skipped - no external target field available", source: "ContentView") @@ -2541,6 +2548,20 @@ struct ContentView: View { } } + /// Polls until the keyboard modifier keys are released (or the timeout elapses). Used before + /// synthesizing a paste so the inserted characters aren't swallowed as modifier+key shortcuts. + private static func waitForHotkeyModifiersReleased(timeout: TimeInterval) async { + let relevant: CGEventFlags = [.maskCommand, .maskControl, .maskAlternate, .maskShift, .maskSecondaryFn] + let start = Date() + while Date().timeIntervalSince(start) < timeout { + let flags = CGEventSource.flagsState(.combinedSessionState) + if flags.isDisjoint(with: relevant) { + return + } + try? await Task.sleep(nanoseconds: 15_000_000) // 15ms + } + } + private func undoLastAIProcessingFromHistory() { guard let last = TranscriptionHistoryStore.shared.entries.first else { DebugLogger.shared.info("Actions: Undo AI requested but history is empty", source: "ContentView") diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index b65b02ad..aab5624d 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -2285,7 +2285,7 @@ struct SettingsView: View { Color.clear .frame(width: 20) - if isRecording && enabledValue { + if isRecording { self.shortcutCapturePill() } else { self.shortcutDisplayPill(shortcut?.displayString ?? "Not set") From 08ba816869a66dcba327da20622fef64d3ed401b Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 13:25:55 -0500 Subject: [PATCH 3/6] fix: harden Paste Last Transcription against edge cases (self-review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses issues found in a self-review of the feature: - Abort instead of inserting if the modifier keys never release. The wait helper now returns whether the modifiers were released; if they're still held when the 0.6s timeout elapses (stuck/long-held key), the paste is aborted rather than typing a corrupted — possibly destructive (⌘W/⌘Q) — shortcut sequence. The user can simply retrigger. - Ignore auto-repeat key-downs so holding the chord pastes once, not N times. The auto-repeat state is passed to triggerPasteLastTranscription, which now acts only on the initial press. (Passed as an argument rather than branching in the already-at-limit handleKeyEvent.) - Guard the overlay entry point against pasting mid-recording. The asrService .isRunning check now lives inside pasteLastDictationFromHistory (re-checked after the modifier wait), so the overlay menu item is protected like the hotkey path and the trigger/insert TOCTOU window is closed. - Restore backup symmetrically: the paste shortcut is now `if let`-guarded like its enabled flag, so restoring an older backup can't wipe a configured shortcut while leaving the feature flagged enabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Fluid/ContentView.swift | 25 ++++++++++++++----- Sources/Fluid/Persistence/SettingsStore.swift | 6 ++++- .../Fluid/Services/GlobalHotkeyManager.swift | 9 +++++-- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 6021711b..e9d34977 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2532,8 +2532,19 @@ struct ContentView: View { // physically held. Synthesizing text in that state makes the target app treat the // characters as keyboard shortcuts and drop them, so wait for the modifiers to be // released before inserting. (Dictation never hits this because by the time it types, - // no keys are held.) - await Self.waitForHotkeyModifiersReleased(timeout: 0.6) + // no keys are held.) If they never release (stuck/held), abort rather than typing a + // corrupted — and possibly destructive — shortcut sequence; the user can retrigger. + guard await Self.waitForHotkeyModifiersReleased(timeout: 0.6) else { + DebugLogger.shared.info("Actions: Paste aborted - modifier keys still held", source: "ContentView") + return + } + + // Re-check here rather than only at the hotkey trigger: the overlay menu entry point + // has no pre-check, and the wait above may have elapsed since the trigger fired. + guard !self.asr.isRunning else { + DebugLogger.shared.info("Actions: Paste skipped - recording in progress", source: "ContentView") + return + } let typingTarget = self.resolveTypingTargetPID() guard typingTarget.pid != nil else { @@ -2548,18 +2559,20 @@ struct ContentView: View { } } - /// Polls until the keyboard modifier keys are released (or the timeout elapses). Used before - /// synthesizing a paste so the inserted characters aren't swallowed as modifier+key shortcuts. - private static func waitForHotkeyModifiersReleased(timeout: TimeInterval) async { + /// Polls until the keyboard modifier keys are released, returning `true` once they are, or + /// `false` if the timeout elapses with keys still held. Used before synthesizing a paste so the + /// inserted characters aren't swallowed as modifier+key shortcuts. + private static func waitForHotkeyModifiersReleased(timeout: TimeInterval) async -> Bool { let relevant: CGEventFlags = [.maskCommand, .maskControl, .maskAlternate, .maskShift, .maskSecondaryFn] let start = Date() while Date().timeIntervalSince(start) < timeout { let flags = CGEventSource.flagsState(.combinedSessionState) if flags.isDisjoint(with: relevant) { - return + return true } try? await Task.sleep(nanoseconds: 15_000_000) // 15ms } + return false } private func undoLastAIProcessingFromHistory() { diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 1325226a..a1ed50b8 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -2855,7 +2855,11 @@ final class SettingsStore: ObservableObject { self.rewriteModeSelectedProviderID = payload.rewriteModeSelectedProviderID self.rewriteModeLinkedToGlobal = payload.rewriteModeLinkedToGlobal self.cancelRecordingHotkeyShortcut = payload.cancelRecordingHotkeyShortcut - self.pasteLastTranscriptionHotkeyShortcut = payload.pasteLastTranscriptionHotkeyShortcut + // Both guarded so restoring an older backup (which predates these fields) doesn't wipe a + // currently-configured shortcut or leave the feature enabled with no shortcut bound. + if let pasteLastTranscriptionHotkeyShortcut = payload.pasteLastTranscriptionHotkeyShortcut { + self.pasteLastTranscriptionHotkeyShortcut = pasteLastTranscriptionHotkeyShortcut + } if let pasteLastTranscriptionShortcutEnabled = payload.pasteLastTranscriptionShortcutEnabled { self.pasteLastTranscriptionShortcutEnabled = pasteLastTranscriptionShortcutEnabled } diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index bfee8d2b..6a260411 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -659,7 +659,10 @@ final class GlobalHotkeyManager: NSObject { let pasteShortcut = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut, pasteShortcut.matches(keyCode: keyCode, modifiers: eventModifiers) { - self.triggerPasteLastTranscription() + // Holding the chord emits auto-repeat key-downs; because the paste waits for the + // modifiers to release, every repeat would otherwise queue another insertion and + // paste N times. triggerPasteLastTranscription ignores repeats. + self.triggerPasteLastTranscription(isAutorepeat: event.getIntegerValueField(.keyboardEventAutorepeat) != 0) return nil } @@ -1666,9 +1669,11 @@ final class GlobalHotkeyManager: NSObject { } } - private func triggerPasteLastTranscription() { + private func triggerPasteLastTranscription(isAutorepeat: Bool) { Task { @MainActor [weak self] in guard let self = self else { return } + // Holding the chord auto-repeats the key-down; act only on the initial press. + guard !isAutorepeat else { return } guard self.canTriggerRecordingAction("Paste last transcription hotkey") else { return } // Re-pasting mid-recording would be surprising; ignore while capture is active. guard !self.asrService.isRunning else { From b9eed0179703b71b5495232226f94fc509c3efc6 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 14:45:53 -0500 Subject: [PATCH 4/6] fix: paste waits for key release instead of aborting at 0.6s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier "abort if modifiers still held" guard timed out at 0.6s, so a second/held press of the paste hotkey aborted with the transcript never inserting ("only pastes once"). Make the wait generous (5s) so the paste lands when the keys are released — including a held or quick repeated press — and only abort if a modifier is genuinely stuck. Preserves the protection against synthesizing text while modifiers are down. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Fluid/ContentView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index e9d34977..91e5a16b 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2530,12 +2530,12 @@ struct ContentView: View { Task { @MainActor in // The hotkey fires on key-down while its own modifier keys (e.g. ⌘⌃) are still // physically held. Synthesizing text in that state makes the target app treat the - // characters as keyboard shortcuts and drop them, so wait for the modifiers to be - // released before inserting. (Dictation never hits this because by the time it types, - // no keys are held.) If they never release (stuck/held), abort rather than typing a - // corrupted — and possibly destructive — shortcut sequence; the user can retrigger. - guard await Self.waitForHotkeyModifiersReleased(timeout: 0.6) else { - DebugLogger.shared.info("Actions: Paste aborted - modifier keys still held", source: "ContentView") + // characters as keyboard shortcuts and drop them, so the paste lands once the keys are + // released — effectively "paste when you let go". The timeout is generous so a normal + // hold (or a quick repeated press) still pastes on release; it only aborts if a modifier + // is genuinely stuck, rather than typing a corrupted/destructive shortcut sequence. + guard await Self.waitForHotkeyModifiersReleased(timeout: 5) else { + DebugLogger.shared.info("Actions: Paste aborted - modifier keys still held after timeout", source: "ContentView") return } From 92e66dccb43ef5637d2741ec735d96f957013aa2 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 15:03:22 -0500 Subject: [PATCH 5/6] fix: collapse rapid re-taps to a single paste (one paste in flight) Because the paste waits for the modifier keys to release, a quick double/triple tap of the chord queued several Tasks that all inserted at once on release, duplicating the transcription. Guard with a single in-flight flag (set before the wait, cleared in a defer) so only one paste runs at a time, covering both the hotkey and overlay entry points. Deliberate sequential pastes (press, it lands, press again) still work since the flag clears when the paste completes. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Fluid/ContentView.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 91e5a16b..5f4d235d 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2528,6 +2528,17 @@ struct ContentView: View { } Task { @MainActor in + // Only one paste may be pending at a time. Because the paste waits for the modifier keys + // to release, a quick double/triple-tap of the chord would otherwise queue several Tasks + // that all insert at once on release. This collapses them to a single paste while still + // allowing a deliberate repeat (press, it lands, then press again). + guard !Self.isPasteLastInProgress else { + DebugLogger.shared.info("Actions: Paste skipped - a paste is already pending", source: "ContentView") + return + } + Self.isPasteLastInProgress = true + defer { Self.isPasteLastInProgress = false } + // The hotkey fires on key-down while its own modifier keys (e.g. ⌘⌃) are still // physically held. Synthesizing text in that state makes the target app treat the // characters as keyboard shortcuts and drop them, so the paste lands once the keys are @@ -2559,6 +2570,12 @@ struct ContentView: View { } } + /// Guards against overlapping paste insertions: only one "paste last transcription" may be + /// pending at a time (see pasteLastDictationFromHistory). A rapid re-tap while one is still + /// waiting for the modifier keys to release is ignored rather than queuing a duplicate insert. + /// Only ever touched on the main actor. + private static var isPasteLastInProgress = false + /// Polls until the keyboard modifier keys are released, returning `true` once they are, or /// `false` if the timeout elapses with keys still held. Used before synthesizing a paste so the /// inserted characters aren't swallowed as modifier+key shortcuts. From 3b2dfe787f88ddf398473cebe6c58b8a87efec97 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 30 Jun 2026 16:56:38 -0500 Subject: [PATCH 6/6] feat: allow mouse-button shortcuts for Paste Last Transcription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes Paste Last as flexible as the Primary Dictation ("listen") button — mappable to a mouse button (side buttons, modified clicks), not just the keyboard. Adds .pasteLast to allowsMouseShortcut and wires a mouse-button check into GlobalHotkeyManager: the mouse-down handling is extracted into handleMouseShortcutDown/Up helpers (which also keeps handleKeyEvent under the cyclomatic-complexity limit), the paste fires once on mouse-down (one-shot, mirroring the keyboard path), and the paired mouse-up is swallowed so the focused app never sees an orphaned up. The other secondary actions (Command/Edit/Cancel) remain keyboard-only as before. Adds a test that the paste setting round-trips a mouse shortcut. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Fluid/ContentView.swift | 9 +++- .../Fluid/Services/GlobalHotkeyManager.swift | 53 ++++++++++++++++--- .../HotkeyShortcutTests.swift | 12 +++++ 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 5f4d235d..19878293 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -117,7 +117,12 @@ enum ShortcutRecordingTarget: Hashable { } var allowsMouseShortcut: Bool { - self.isPrimaryDictation + switch self { + case .primaryDictation, .pasteLast: + return true + case .secondaryDictation, .command, .edit, .cancel, .dictationPrompt, .newPrompt: + return false + } } var isPrimaryDictation: Bool { @@ -993,7 +998,7 @@ struct ContentView: View { private func shortcutConflictMessage(for shortcut: HotkeyShortcut, target: ShortcutRecordingTarget) -> String? { if shortcut.isMouseShortcut { guard target.allowsMouseShortcut else { - return "Mouse clicks can only be assigned to Primary Dictation Shortcut" + return "Mouse clicks can only be assigned to Primary Dictation or Paste Last Transcription" } if shortcut.isUnmodifiedLeftOrRightClick, let mouseButton = shortcut.mouseButton { diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index 6a260411..c0272aa5 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -902,18 +902,14 @@ final class GlobalHotkeyManager: NSObject { case .leftMouseDown, .rightMouseDown, .otherMouseDown: self.markOtherInputDuringModifierOnly() - let mouseButton = self.mouseButton(from: event) - if self.primaryShortcuts.contains(where: { $0.matchesMouse(button: mouseButton, modifiers: eventModifiers) }) { - guard self.beginPrimaryShortcutPress(.mouse(mouseButton)) else { return nil } - self.handlePrimaryDictationTriggerDown() + if self.handleMouseShortcutDown(event, modifiers: eventModifiers) { return nil } case .leftMouseUp, .rightMouseUp, .otherMouseUp: - let mouseButton = self.mouseButton(from: event) - guard self.finishPrimaryShortcutPress(.mouse(mouseButton)) else { break } - self.handlePrimaryDictationTriggerUp() - return nil + if self.handleMouseShortcutUp(event) { + return nil + } case .flagsChanged: if HotkeyShortcut.modifierFlag(forKeyCode: keyCode) != nil { @@ -1669,6 +1665,47 @@ final class GlobalHotkeyManager: NSObject { } } + /// Handles a mouse-button down event against the configured mouse shortcuts. Returns true when + /// the event was consumed. "Paste Last Transcription" is a one-shot trigger (mirrors the keyboard + /// path); primary dictation begins a press here and ends it on mouse-up. + private func handleMouseShortcutDown(_ event: CGEvent, modifiers eventModifiers: NSEvent.ModifierFlags) -> Bool { + let mouseButton = self.mouseButton(from: event) + + if SettingsStore.shared.pasteLastTranscriptionShortcutEnabled, + let pasteShortcut = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut, + pasteShortcut.matchesMouse(button: mouseButton, modifiers: eventModifiers) + { + self.triggerPasteLastTranscription(isAutorepeat: false) + return true + } + + if self.primaryShortcuts.contains(where: { $0.matchesMouse(button: mouseButton, modifiers: eventModifiers) }) { + guard self.beginPrimaryShortcutPress(.mouse(mouseButton)) else { return true } + self.handlePrimaryDictationTriggerDown() + return true + } + + return false + } + + /// Handles a mouse-button up event. Swallows the up that pairs with a consumed paste mouse-down + /// so the focused app never sees an orphaned mouse-up; otherwise ends a primary dictation press. + private func handleMouseShortcutUp(_ event: CGEvent) -> Bool { + let mouseButton = self.mouseButton(from: event) + + if SettingsStore.shared.pasteLastTranscriptionShortcutEnabled, + let pasteShortcut = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut, + pasteShortcut.isMouseShortcut, + pasteShortcut.mouseButton == mouseButton + { + return true + } + + guard self.finishPrimaryShortcutPress(.mouse(mouseButton)) else { return false } + self.handlePrimaryDictationTriggerUp() + return true + } + private func triggerPasteLastTranscription(isAutorepeat: Bool) { Task { @MainActor [weak self] in guard let self = self else { return } diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index a1cc7a4b..b7e0f8ee 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -143,6 +143,18 @@ final class HotkeyShortcutTests: XCTestCase { } } + func testPasteLastTranscriptionShortcutSupportsMouseButton() throws { + try self.withRestoredDefaults(keys: [self.pasteLastTranscriptionShortcutKey]) { + let mouseShortcut = HotkeyShortcut(mouseButton: 3, modifierFlags: [.option]) + SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut = mouseShortcut + + let stored = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut + XCTAssertEqual(stored, mouseShortcut) + XCTAssertTrue(stored?.isMouseShortcut ?? false) + XCTAssertTrue(stored?.matchesMouse(button: 3, modifiers: [.option]) ?? false) + } + } + private func withRestoredDefaults(keys: [String], run: () throws -> Void) rethrows { let defaults = UserDefaults.standard var snapshot: [String: Any] = [:]