diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 1a5d3ec3..25f5bdf2 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -2288,9 +2288,14 @@ struct ContentView: View { // When FluidVoice itself is frontmost, the bound editor already receives `finalText`. // Avoid re-inserting or overwriting the clipboard in that self-target case. + let isClipboardOnlyMode = SettingsStore.shared.textInsertionMode == .clipboardOnly + // Clipboard-only is the primary delivery method, so it must write even when FluidVoice is + // frontmost — suppressing it there would leave the user with nothing on the clipboard. The + // backup-copy checkbox keeps its `!isFluidFrontmost` guard, since the bound editor already + // holds the text in that self-target case. let shouldCopyToClipboard = shouldPersistOutputs && - SettingsStore.shared.copyTranscriptionToClipboard && - !isFluidFrontmost + (isClipboardOnlyMode || + (!isFluidFrontmost && SettingsStore.shared.copyTranscriptionToClipboard)) if shouldCopyToClipboard { ClipboardService.copyToClipboard(finalText) @@ -2304,7 +2309,7 @@ struct ContentView: View { } var didTypeExternally = false - let shouldTypeExternally = shouldPersistOutputs && !isFluidFrontmost + let shouldTypeExternally = shouldPersistOutputs && !isFluidFrontmost && !isClipboardOnlyMode DebugLogger.shared.debug( "Typing decision → frontmost: \(frontmostName), fluidFrontmost: \(isFluidFrontmost), editorFocused: \(self.isTranscriptionFocused), willTypeExternally: \(shouldTypeExternally)", @@ -2354,7 +2359,7 @@ struct ContentView: View { NotchOverlayManager.shared.hide() } } else if shouldPersistOutputs, - SettingsStore.shared.copyTranscriptionToClipboard == false, + !shouldCopyToClipboard, SettingsStore.shared.saveTranscriptionHistory { AnalyticsService.shared.capture( @@ -2666,8 +2671,13 @@ struct ContentView: View { if !self.rewriteModeService.rewrittenText.isEmpty { DebugLogger.shared.info("Rewrite successful, typing result (chars: \(self.rewriteModeService.rewrittenText.count))", source: "ContentView") + // In clipboard-only mode the delivery below already writes the pasteboard and emits a + // single `.clipboard` event, so skip the backup copy to avoid double-counting that + // delivery — mirroring how the dictation path collapses these into one event. (#481) + let isClipboardOnlyMode = SettingsStore.shared.textInsertionMode == .clipboardOnly + // Copy to clipboard as backup - if SettingsStore.shared.copyTranscriptionToClipboard { + if SettingsStore.shared.copyTranscriptionToClipboard, !isClipboardOnlyMode { ClipboardService.copyToClipboard(self.rewriteModeService.rewrittenText) AnalyticsService.shared.capture( .outputDelivered, @@ -2678,7 +2688,9 @@ struct ContentView: View { ) } - // Type the rewritten text + // Deliver the rewritten text. In clipboard-only mode the insertion machinery writes to + // the pasteboard instead of typing, so report the matching analytics method rather than + // unconditionally claiming a typed insertion. (#481) let typingTarget = self.resolveTypingTargetPID() if typingTarget.shouldRestoreOriginalFocus { await self.restoreFocusToRecordingTarget() @@ -2691,7 +2703,9 @@ struct ContentView: View { .outputDelivered, properties: [ "mode": AnalyticsMode.rewrite.rawValue, - "method": AnalyticsOutputMethod.typed.rawValue, + "method": isClipboardOnlyMode + ? AnalyticsOutputMethod.clipboard.rawValue + : AnalyticsOutputMethod.typed.rawValue, ] ) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 82d0fbec..6adeb491 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -4467,6 +4467,7 @@ extension SettingsStore { enum TextInsertionMode: String, CaseIterable, Identifiable, Codable { case standard case reliablePaste + case clipboardOnly var id: String { self.rawValue @@ -4478,6 +4479,8 @@ extension SettingsStore { return "Clipboard Free Insert" case .reliablePaste: return "Clipboard Paste" + case .clipboardOnly: + return "Copy to Clipboard Only" } } @@ -4487,6 +4490,8 @@ extension SettingsStore { return "Fastest path. Inserts text without changing the clipboard, with paste fallback if direct insertion is unavailable." case .reliablePaste: return "Compatibility path. Uses a temporary clipboard paste, so clipboard history apps may briefly record dictated text." + case .clipboardOnly: + return "Copies transcribed text to the clipboard without inserting it. Useful for remote desktop workflows or when you prefer to paste manually." } } } diff --git a/Sources/Fluid/Services/RewriteModeService.swift b/Sources/Fluid/Services/RewriteModeService.swift index 0ac9ee25..16c0f9a0 100644 --- a/Sources/Fluid/Services/RewriteModeService.swift +++ b/Sources/Fluid/Services/RewriteModeService.swift @@ -164,11 +164,16 @@ final class RewriteModeService: ObservableObject { NSApp.hide(nil) // Restore focus to the previous app self.typingService.typeTextInstantly(self.rewrittenText) + // Clipboard-only mode routes the delivery to the pasteboard instead of typing, so report the + // method that actually happened rather than always claiming a typed insertion. (#481) + let isClipboardOnlyMode = SettingsStore.shared.textInsertionMode == .clipboardOnly AnalyticsService.shared.capture( .outputDelivered, properties: [ "mode": AnalyticsMode.rewrite.rawValue, - "method": AnalyticsOutputMethod.typed.rawValue, + "method": isClipboardOnlyMode + ? AnalyticsOutputMethod.clipboard.rawValue + : AnalyticsOutputMethod.typed.rawValue, ] ) } diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index 259c9a05..1c4791d6 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -277,6 +277,14 @@ final class TypingService { return } + // Clipboard-only mode: skip all insertion machinery and write to clipboard directly. + // This avoids the accessibility-permission requirement and settle delay entirely. (#481) + if mode == .clipboardOnly { + self.bench("request_return reason=clipboard_only") + ClipboardService.copyToClipboard(text) + return + } + // Prevent concurrent typing operations guard !self.isCurrentlyTyping else { self.bench("request_return reason=already_typing")