Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 130 additions & 3 deletions Sources/Fluid/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ enum ShortcutRecordingTarget: Hashable {
case command
case edit
case cancel
case pasteLast
case dictationPrompt(String)
case newPrompt

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -114,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 {
Expand Down Expand Up @@ -182,6 +190,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
Comment on lines +193 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh paste shortcut state after backup restore

These new @State values are only initialized once, but reloadSettingsStateAfterBackupRestore() refreshes the other hotkey bindings after .settingsBackupDidRestore and does not refresh the paste shortcut or enabled flag. If a backup changes this shortcut while Preferences is open, the global hotkey manager reads the restored values from SettingsStore but the Settings UI keeps showing the old binding/toggle and can mislead the user; include these two states in the restore reload alongside the other shortcut states.

Useful? React with 👍 / 👎.

@State private var isPromptModeShortcutEnabled: Bool = SettingsStore.shared.promptModeShortcutEnabled
@State private var isCommandModeShortcutEnabled: Bool = SettingsStore.shared.commandModeShortcutEnabled
@State private var isRewriteModeShortcutEnabled: Bool = SettingsStore.shared.rewriteModeShortcutEnabled
Expand Down Expand Up @@ -456,6 +466,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) {
Expand Down Expand Up @@ -974,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 {
Expand All @@ -999,6 +1023,7 @@ struct ContentView: View {
]
let optionalConfiguredShortcuts: [(ShortcutRecordingTarget, HotkeyShortcut?)] = [
(.command, self.commandModeHotkeyShortcut),
(.pasteLast, self.pasteLastTranscriptionHotkeyShortcut),
]

for (otherTarget, configuredShortcut) in configuredShortcuts where otherTarget != target {
Expand Down Expand Up @@ -1064,6 +1089,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)
Expand Down Expand Up @@ -1108,6 +1137,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
}
Expand Down Expand Up @@ -1435,8 +1467,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,
Expand Down Expand Up @@ -2478,6 +2512,91 @@ 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
Comment on lines +2527 to +2529

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve stored whitespace when re-pasting history

When continuous dictation spacing is enabled, ASRService.applyContinuousDictationFormatting deliberately saves boundary whitespace in processedText (for example a leading space before a word and a trailing space after it), but this new paste path trims both processedText and rawText before insertion. In that setting, re-pasting the latest entry no longer reproduces the text that was originally delivered and can concatenate words at the cursor, which contradicts the clipboard-free “faithful re-paste” behavior.

Useful? React with 👍 / 👎.

guard !text.isEmpty else {
DebugLogger.shared.info("Actions: Paste skipped because latest history text is empty", source: "ContentView")
return
}

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
// 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
}

// 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 {
DebugLogger.shared.info("Actions: Paste skipped - no external target field available", source: "ContentView")
return
}
if typingTarget.shouldRestoreOriginalFocus {
await self.restoreFocusToRecordingTarget()
Comment on lines +2570 to +2571

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid restoring stale focus for paste hotkey

When the paste action is invoked by the global hotkey while the user is still in the same app as the last dictation but has moved the cursor to a different field/window, resolveTypingTargetPID() returns the current PID with shouldRestoreOriginalFocus == true because it only compares against recordingTargetPID; this branch then calls restoreFocusToRecordingTarget(), which refocuses the old AX element captured at recording start. In that scenario the hotkey inserts into the previous dictation field rather than the currently focused field, so focus restoration should be reserved for the overlay/menu path or only when Fluid is frontmost.

Useful? React with 👍 / 👎.

}
self.asr.typeTextToActiveField(text, preferredTargetPID: typingTarget.pid)
DebugLogger.shared.info("Actions: Pasted latest transcription into focused field", source: "ContentView")
}
}

/// 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.
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 true
}
try? await Task.sleep(nanoseconds: 15_000_000) // 15ms
}
return false
}

private func undoLastAIProcessingFromHistory() {
guard let last = TranscriptionHistoryStore.shared.entries.first else {
DebugLogger.shared.info("Actions: Undo AI requested but history is empty", source: "ContentView")
Expand Down Expand Up @@ -2999,6 +3118,9 @@ struct ContentView: View {
NotchContentState.shared.onCopyLastRequested = {
self.copyLastDictationFromHistory()
}
NotchContentState.shared.onPasteLastRequested = {
self.pasteLastDictationFromHistory()
}
NotchContentState.shared.onUndoLastAIRequested = {
self.undoLastAIProcessingFromHistory()
}
Expand Down Expand Up @@ -3169,6 +3291,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
Expand Down
3 changes: 3 additions & 0 deletions Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
49 changes: 49 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2816,6 +2855,14 @@ final class SettingsStore: ObservableObject {
self.rewriteModeSelectedProviderID = payload.rewriteModeSelectedProviderID
self.rewriteModeLinkedToGlobal = payload.rewriteModeLinkedToGlobal
self.cancelRecordingHotkeyShortcut = payload.cancelRecordingHotkeyShortcut
// 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
}
Comment on lines +2860 to +2862

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore cleared paste shortcuts from backups

When restoring a backup created after this change with Paste Last Transcription left unbound, the synthesized encoder omits pasteLastTranscriptionHotkeyShortcut because it is nil, so this if let treats that current backup the same as a pre-feature backup and leaves any existing shortcut in UserDefaults. A user restoring an unbound/disabled configuration over a machine that previously had a paste shortcut will still see the old binding available to re-enable, so backup restore no longer faithfully restores this setting; the restore path needs a way to distinguish missing legacy fields from an intentionally nil shortcut or otherwise clear it for current backups.

Useful? React with 👍 / 👎.

if let pasteLastTranscriptionShortcutEnabled = payload.pasteLastTranscriptionShortcutEnabled {
self.pasteLastTranscriptionShortcutEnabled = pasteLastTranscriptionShortcutEnabled
}
self.showThinkingTokens = payload.showThinkingTokens
self.hideFromDockAndAppSwitcher = payload.hideFromDockAndAppSwitcher
self.showMainWindowAtLoginLaunch = payload.showMainWindowAtLoginLaunch ?? true
Expand Down Expand Up @@ -4367,6 +4414,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"

Expand Down
Loading
Loading