From da66dadcf62cacab7fee38b4817e38685850e80e Mon Sep 17 00:00:00 2001 From: Kuber Mehta Date: Tue, 21 Jul 2026 14:22:58 +0530 Subject: [PATCH 1/2] feat: add whole-utterance scratch command matcher ScratchCommandMatcher recognizes "scratch that" / "delete that" (and this-variants) only when the entire dictation is the command, tolerating casing and punctuation. Registered in the hand-rolled test runner and in both TEST_RUNNER source lists. --- Makefile | 4 +- Sources/ScratchCommandMatcher.swift | 37 +++++++++++++ Tests/AppContextServiceTests.swift | 1 + Tests/ScratchCommandMatcherTests.swift | 76 ++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 Sources/ScratchCommandMatcher.swift create mode 100644 Tests/ScratchCommandMatcherTests.swift diff --git a/Makefile b/Makefile index 9b3374a..6a0484f 100644 --- a/Makefile +++ b/Makefile @@ -104,14 +104,14 @@ endif test: $(TEST_RUNNER) @$(TEST_RUNNER) -$(TEST_RUNNER): Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/TranscriptTidier.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/TranscriptTidierTests.swift Tests/WakePhraseMatcherTests.swift +$(TEST_RUNNER): Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/ScratchCommandMatcher.swift Sources/TranscriptTidier.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/ScratchCommandMatcherTests.swift Tests/TranscriptTidierTests.swift Tests/WakePhraseMatcherTests.swift @mkdir -p "$(BUILD_DIR)" swiftc \ -parse-as-library \ -o "$(TEST_RUNNER)" \ -sdk $(shell xcrun --show-sdk-path) \ -target $(ARCH)-apple-macosx26.0 \ - Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/TranscriptTidier.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/TranscriptTidierTests.swift Tests/WakePhraseMatcherTests.swift + Sources/AppContextService.swift Sources/AppleFoundationModelsPostProcessor.swift Sources/DictionaryStore.swift Sources/ScratchCommandMatcher.swift Sources/TranscriptTidier.swift Sources/WakePhraseMatcher.swift Tests/AppContextServiceTests.swift Tests/DictionaryStoreTests.swift Tests/ScratchCommandMatcherTests.swift Tests/TranscriptTidierTests.swift Tests/WakePhraseMatcherTests.swift icon: $(ICON_ICNS) diff --git a/Sources/ScratchCommandMatcher.swift b/Sources/ScratchCommandMatcher.swift new file mode 100644 index 0000000..1a98711 --- /dev/null +++ b/Sources/ScratchCommandMatcher.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Recognizes whole-utterance "scratch that" commands that ask Megaphone to +/// delete the previously inserted dictation instead of pasting new text. +/// +/// Matching is deliberately strict: after trimming, casing, and punctuation +/// tolerance, the entire utterance must be one of a small set of phrases. +/// "Scratch that itch, please" is regular dictation, not a command. +enum ScratchCommandMatcher { + static func matches(_ transcript: String) -> Bool { + let normalized = normalize(transcript) + guard !normalized.isEmpty else { return false } + return phrases.contains(normalized) + } + + /// Lowercases and collapses every run of whitespace, punctuation, or + /// symbols into a single separator so "Scratch that." and "scratch, that!" + /// normalize to the same key. + private static func normalize(_ transcript: String) -> String { + transcript + .lowercased() + .components(separatedBy: separators) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + private static let phrases: Set = [ + "scratch that", + "scratch this", + "delete that", + "delete this" + ] + + private static let separators = CharacterSet.whitespacesAndNewlines + .union(.punctuationCharacters) + .union(.symbols) +} diff --git a/Tests/AppContextServiceTests.swift b/Tests/AppContextServiceTests.swift index 0f39dd3..377fdcd 100644 --- a/Tests/AppContextServiceTests.swift +++ b/Tests/AppContextServiceTests.swift @@ -15,6 +15,7 @@ struct AppContextServiceTests { TranscriptTidierTests.run() DictionaryStoreTests.run() WakePhraseMatcherTests.run() + ScratchCommandMatcherTests.run() print("MegaphoneTests passed") } diff --git a/Tests/ScratchCommandMatcherTests.swift b/Tests/ScratchCommandMatcherTests.swift new file mode 100644 index 0000000..e8cc5a0 --- /dev/null +++ b/Tests/ScratchCommandMatcherTests.swift @@ -0,0 +1,76 @@ +import Foundation + +enum ScratchCommandMatcherTests { + static func run() { + testWholeUtterancePhrases() + testPunctuationAndCasingVariants() + testNonCommandsAreRejected() + } + + private static func testWholeUtterancePhrases() { + let commands = [ + "scratch that", + "scratch this", + "delete that", + "delete this" + ] + for transcript in commands { + expect( + ScratchCommandMatcher.matches(transcript), + "Did not recognize \(transcript.debugDescription)" + ) + } + } + + private static func testPunctuationAndCasingVariants() { + let variants = [ + "Scratch that.", + "SCRATCH THAT!", + " scratch that… ", + "Scratch, that", + "scratch — that", + "Delete that?", + "“Delete this.”", + "Scratch that\n" + ] + for transcript in variants { + expect( + ScratchCommandMatcher.matches(transcript), + "Did not tolerate \(transcript.debugDescription)" + ) + } + } + + private static func testNonCommandsAreRejected() { + let rejected = [ + "", + " ", + "scratch", + "that", + "scratch that itch please", + "please scratch that", + "scratch that and start over", + "you can scratch that", + "delete that file from the repo", + "we should scratch that idea", + "scratched that", + "scratch thats" + ] + for transcript in rejected { + expect( + !ScratchCommandMatcher.matches(transcript), + "False positive for \(transcript.debugDescription)" + ) + } + } + + private static func expect( + _ condition: Bool, + _ message: String, + file: StaticString = #file, + line: UInt = #line + ) { + guard !condition else { return } + fatalError("\(file):\(line): \(message)") + } +} From aae950ad4e17f386d57c5001c09f9d41ee73a30e Mon Sep 17 00:00:00 2001 From: Kuber Mehta Date: Tue, 21 Jul 2026 14:23:04 +0530 Subject: [PATCH 2/2] feat: let "scratch that" delete the last dictation When a whole dictation is a scratch command, skip pasting: select the previously inserted dictation via selectTextImmediatelyBeforeCaret (the wake REPLACE_PREVIOUS safety check) and remove it with a synthesized Delete key press after the shortcut is released. A successful scratch invalidates the tracked previous text so a repeated command cannot double-delete; with nothing eligible the overlay shows "Nothing to scratch". Gated by a new default-on toggle next to the press-enter command (scratch_that_command_enabled). --- Sources/AppState.swift | 144 ++++++++++++++++++++++++++++++++++++- Sources/SettingsView.swift | 9 +++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/Sources/AppState.swift b/Sources/AppState.swift index 6026792..d00c1d9 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -238,6 +238,7 @@ final class AppState: ObservableObject, @unchecked Sendable { private let preserveExactWordingStorageKey = "preserve_exact_wording" private let keepDictationInClipboardHistoryStorageKey = "keep_dictation_in_clipboard_history" private let pressEnterVoiceCommandStorageKey = "press_enter_voice_command_enabled" + private let scratchThatCommandStorageKey = "scratch_that_command_enabled" private let alertSoundsEnabledStorageKey = "alert_sounds_enabled" private let soundVolumeStorageKey = "sound_volume" private let startSoundNameStorageKey = "start_sound_name" @@ -462,6 +463,12 @@ final class AppState: ObservableObject, @unchecked Sendable { } } + @Published var isScratchThatCommandEnabled: Bool { + didSet { + UserDefaults.standard.set(isScratchThatCommandEnabled, forKey: scratchThatCommandStorageKey) + } + } + @Published var alertSoundsEnabled: Bool { didSet { UserDefaults.standard.set(alertSoundsEnabled, forKey: alertSoundsEnabledStorageKey) @@ -598,6 +605,11 @@ final class AppState: ObservableObject, @unchecked Sendable { private var pendingMicrophonePermissionManualCommandRequested: Bool? private let postTranscriptionUpdateReminderDuration: TimeInterval = 7 private let wakeCommandPreviousTextWindow: TimeInterval = 120 + /// History entries recorded at or before this instant are ignored when + /// looking up the previously inserted dictation. Set after "scratch that" + /// deletes text so a repeated scratch (or a wake command) cannot act on + /// the already-deleted dictation again. + private var previousDictationInvalidatedAt: Date? init() { UserDefaults.standard.removeObject(forKey: "force_http2_transcription") @@ -658,6 +670,9 @@ final class AppState: ObservableObject, @unchecked Sendable { let isPressEnterVoiceCommandEnabled = UserDefaults.standard.object(forKey: pressEnterVoiceCommandStorageKey) == nil ? true : UserDefaults.standard.bool(forKey: pressEnterVoiceCommandStorageKey) + let isScratchThatCommandEnabled = UserDefaults.standard.object(forKey: scratchThatCommandStorageKey) == nil + ? true + : UserDefaults.standard.bool(forKey: scratchThatCommandStorageKey) let soundVolume: Float = UserDefaults.standard.object(forKey: soundVolumeStorageKey) != nil ? UserDefaults.standard.float(forKey: soundVolumeStorageKey) : 1.0 let alertSoundsEnabled = UserDefaults.standard.object(forKey: alertSoundsEnabledStorageKey) != nil @@ -725,6 +740,7 @@ final class AppState: ObservableObject, @unchecked Sendable { self.keepDictationInClipboardHistory = keepDictationInClipboardHistory self.dictationAudioInterruptionEnabled = dictationAudioInterruptionEnabled self.isPressEnterVoiceCommandEnabled = isPressEnterVoiceCommandEnabled + self.isScratchThatCommandEnabled = isScratchThatCommandEnabled self.alertSoundsEnabled = alertSoundsEnabled self.soundVolume = soundVolume self.startSoundName = startSoundName @@ -2342,6 +2358,9 @@ final class AppState: ObservableObject, @unchecked Sendable { pipelineHistory.first { item in let text = item.postProcessedTranscript.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return false } + if let invalidatedAt = previousDictationInvalidatedAt, item.timestamp <= invalidatedAt { + return false + } let age = now.timeIntervalSince(item.timestamp) guard age >= 0, age <= wakeCommandPreviousTextWindow else { return false } @@ -2627,12 +2646,21 @@ final class AppState: ObservableObject, @unchecked Sendable { pressEnterCommandEnabled: self.isPressEnterVoiceCommandEnabled ) try Task.checkCancellation() + let isScratchCommand: Bool + if case .dictation = sessionIntent { + isScratchCommand = self.isScratchThatCommandEnabled + && ScratchCommandMatcher.matches(parsedTranscript.transcript) + } else { + isScratchCommand = false + } // Capture the parsed raw transcript as lastTranscript before // post-processing runs. If anything after this throws or focus // shifts mid-paste, the Paste Again shortcut still has the raw // text instead of the previous dictation's stale value. + // Scratch commands never paste, so they keep the previous + // transcript available for Paste Again instead. let bootstrapTranscript = parsedTranscript.transcript.trimmingCharacters(in: .whitespacesAndNewlines) - if !bootstrapTranscript.isEmpty { + if !isScratchCommand, !bootstrapTranscript.isEmpty { await MainActor.run { [weak self] in self?.lastTranscript = bootstrapTranscript } @@ -2649,6 +2677,18 @@ final class AppState: ObservableObject, @unchecked Sendable { self.recentTextForWakeCommand(in: appContext, now: Date()) } try Task.checkCancellation() + if isScratchCommand { + await MainActor.run { + guard self.isTranscribing else { return } + self.completeScratchCommand( + previousText: previousText, + rawTranscript: parsedTranscript.transcript, + context: appContext, + audioFileName: savedAudioFile?.fileName + ) + } + return + } await MainActor.run { [weak self] in self?.debugStatusMessage = "Running post-processing" } @@ -2868,6 +2908,95 @@ final class AppState: ObservableObject, @unchecked Sendable { } } + /// Terminal handling for a whole-utterance "scratch that" dictation: the + /// utterance is never pasted; instead the previously inserted dictation + /// is deleted when it still sits immediately before the caret. + @MainActor + private func completeScratchCommand( + previousText: String?, + rawTranscript: String, + context: AppContext, + audioFileName: String? + ) { + lastContextSummary = context.contextSummary + lastContextScreenshotDataURL = nil + lastContextScreenshotStatus = "Not captured (local-only)" + lastContextAppName = context.appName ?? "" + lastContextBundleIdentifier = context.bundleIdentifier ?? "" + lastContextWindowTitle = context.windowTitle ?? "" + lastContextSelectedText = context.selectedText ?? "" + lastContextLLMPrompt = "" + lastPostProcessingPrompt = "" + lastRawTranscript = rawTranscript + lastPostProcessedTranscript = "" + transcriptionTask = nil + transcribingAudioFileName = nil + isTranscribing = false + endCriticalDictationActivity() + debugStatusMessage = "Done" + clearPendingOverlayDismissToken() + audioRecorder.cleanup() + refreshAvailableMicrophonesIfNeeded() + + guard let previousText else { + finishScratchCommand( + scratched: false, + rawTranscript: rawTranscript, + context: context, + audioFileName: audioFileName + ) + return + } + + // Selecting and deleting synthesizes key events, so wait for the + // dictation shortcut to be fully released — the same discipline the + // paste path follows. + performAfterShortcutReleased { [weak self] in + guard let self else { return } + let scratched = self.contextService.selectTextImmediatelyBeforeCaret(matching: previousText) + if scratched { + self.pressDelete() + // Forget the deleted dictation so a second "scratch that" + // cannot select-and-delete unrelated text that happens to + // match it. + self.previousDictationInvalidatedAt = Date() + } + self.finishScratchCommand( + scratched: scratched, + rawTranscript: rawTranscript, + context: context, + audioFileName: audioFileName + ) + } + } + + private func finishScratchCommand( + scratched: Bool, + rawTranscript: String, + context: AppContext, + audioFileName: String? + ) { + let status = scratched ? "Scratched last dictation" : "Nothing to scratch" + lastPostProcessingStatus = status + recordPipelineHistoryEntry( + rawTranscript: rawTranscript, + postProcessedTranscript: "", + postProcessingPrompt: "", + systemPrompt: Self.resolvedSystemPrompt(customSystemPrompt), + context: context, + processingStatus: status, + intent: .dictation, + audioFileName: audioFileName + ) + statusText = status + if scratched { + overlayManager.dismiss() + } else { + overlayManager.showError("Nothing to scratch") + } + scheduleReadyStatusReset(after: 3, matching: [status]) + } + /// Start streaming microphone audio into the on-device SpeechAnalyzer. /// PCM16 samples (24 kHz mono) are analyzed while the user is still /// speaking, so the transcript is essentially ready at stop time. Setup @@ -3138,6 +3267,19 @@ final class AppState: ObservableObject, @unchecked Sendable { keyUp?.post(tap: .cgSessionEventTap) } + /// Synthesizes a Delete (backspace, kVK_Delete = 51) key press. With the + /// previous dictation selected via `selectTextImmediatelyBeforeCaret`, a + /// single Delete removes the whole selection. + private func pressDelete() { + let source = CGEventSource(stateID: .hidSystemState) + + let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 51, keyDown: true) + keyDown?.post(tap: .cgSessionEventTap) + + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 51, keyDown: false) + keyUp?.post(tap: .cgSessionEventTap) + } + /// Writes the final transcript to the system pasteboard. /// Also handles appending necessary trailing spaces, declaring transient /// types for clipboard managers, and saving the clipboard state for later restoration. diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index ebf6601..89c73cc 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -981,6 +981,15 @@ struct GeneralSettingsView: View { Text("When the transcription ends with \"press enter\", \(AppName.displayName) removes those words before cleanup, pastes the remaining transcript, then presses Return.") .font(.caption) .foregroundStyle(.secondary) + + Divider() + .padding(.vertical, 2) + + Toggle("“Scratch that” deletes the last dictation", isOn: $appState.isScratchThatCommandEnabled) + + Text("When an entire dictation is \"scratch that\" or \"delete that\", \(AppName.displayName) deletes the dictation it just pasted instead of typing the words — as long as that text still sits right before your cursor.") + .font(.caption) + .foregroundStyle(.secondary) } }