diff --git a/CHANGELOG.md b/CHANGELOG.md index 9988bca9..a1b53953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ This project uses semantic versioning for public releases. Use `MAJOR.MINOR.PATC - `MINOR` changes add user-visible features and improvements. - `PATCH` changes fix bugs, polish existing behavior, or make small internal improvements. +## [Unreleased] + +### Added + +- Prompt Mode: optionally condense rambling dictation into a tight, intent-preserving prompt before pasting — set to Always, or Only in AI apps (auto-detects Claude, ChatGPT, Cursor, and similar tools from the active app and window title). Runs in the same cleanup pass, so it adds no latency. + ## [1.1.0] - 2026-06-03 ### Added diff --git a/README.md b/README.md index f7d389d9..5814402d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ FreeFlow is a free Mac dictation app inspired by [Wispr Flow](https://wisprflow. - **Custom shortcuts:** Customize both hold-to-talk and toggle dictation shortcuts. If your toggle shortcut extends your hold shortcut, you can start in hold mode and press the extra modifier keys to latch into tap mode without stopping the recording. - **Context-aware cleanup:** FreeFlow can read nearby app context so names, terms, and phrases are spelled correctly when you dictate into email, terminals, docs, and other apps. - **Custom vocabulary:** Add names, jargon, and project-specific words that FreeFlow should preserve during cleanup. +- **Prompt Mode:** Optionally condense rambling dictation into a tight prompt that preserves your full intent — always, or automatically when dictating into AI tools like Claude, ChatGPT, and Cursor. - **OpenAI-compatible providers:** Use Groq by default, or configure a custom model and API URL in settings. ## Edit Mode diff --git a/Sources/AppState.swift b/Sources/AppState.swift index c65bc809..a3ca9409 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -56,6 +56,17 @@ enum SettingsTab: String, CaseIterable, Identifiable { } } +/// How Prompt Mode decides whether to condense dictation into a tight +/// prompt before pasting (issue #196). +enum PromptModeSetting: String, CaseIterable { + /// Paste the cleaned transcript verbatim (default). + case off + /// Condense every dictation. + case always + /// Condense only when dictating into a recognized AI tool. + case auto +} + enum AppBuild { static var isDevBundle: Bool { (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String) == "FreeFlow Dev" @@ -227,6 +238,7 @@ final class AppState: ObservableObject, @unchecked Sendable { private let pressEnterVoiceCommandStorageKey = "press_enter_voice_command_enabled" private let alertSoundsEnabledStorageKey = "alert_sounds_enabled" private let soundVolumeStorageKey = "sound_volume" + private let promptModeStorageKey = "prompt_mode" private let voiceMacrosStorageKey = "voice_macros" private let commandModeEnabledStorageKey = "command_mode_enabled" private let commandModeStyleStorageKey = "command_mode_style" @@ -529,6 +541,15 @@ final class AppState: ObservableObject, @unchecked Sendable { } } + /// Prompt Mode condenses rambling dictation into a tight, + /// intent-preserving prompt before pasting (issue #196). + @Published var promptMode: PromptModeSetting { + didSet { + UserDefaults.standard.set(promptMode.rawValue, forKey: promptModeStorageKey) + } + } + + private var precomputedMacros: [PrecomputedMacro] = [] @Published var voiceMacros: [VoiceMacro] = [] { @@ -690,6 +711,9 @@ final class AppState: ObservableObject, @unchecked Sendable { let alertSoundsEnabled = UserDefaults.standard.object(forKey: alertSoundsEnabledStorageKey) != nil ? UserDefaults.standard.bool(forKey: alertSoundsEnabledStorageKey) : soundVolume > 0 + let promptMode = PromptModeSetting( + rawValue: UserDefaults.standard.string(forKey: promptModeStorageKey) ?? "" + ) ?? .off let initialMacros: [VoiceMacro] if let data = UserDefaults.standard.data(forKey: "voice_macros"), @@ -757,6 +781,7 @@ final class AppState: ObservableObject, @unchecked Sendable { self.isPressEnterVoiceCommandEnabled = isPressEnterVoiceCommandEnabled self.alertSoundsEnabled = alertSoundsEnabled self.soundVolume = soundVolume + self.promptMode = promptMode self.voiceMacros = initialMacros self.pipelineHistory = savedHistory self.hasAccessibility = initialAccessibility @@ -2449,6 +2474,38 @@ final class AppState: ObservableObject, @unchecked Sendable { } } + private func shouldCondenseForPrompt(context: AppContext) -> Bool { + switch promptMode { + case .off: + return false + case .always: + return true + case .auto: + return Self.isAITargetContext( + bundleIdentifier: context.bundleIdentifier, + windowTitle: context.windowTitle + ) + } + } + + /// Heuristic for "the user is dictating a prompt into an AI tool": + /// known AI desktop app bundle IDs, or a window title mentioning a + /// major AI product (covers browser tabs and terminal sessions). + static func isAITargetContext(bundleIdentifier: String?, windowTitle: String?) -> Bool { + let aiBundleIdentifiers: Set = [ + "com.anthropic.claudefordesktop", + "com.openai.chat", + "com.todesktop.230313mzl4w4u92", // Cursor + "com.exafunction.windsurf", + ] + if let bundleIdentifier, aiBundleIdentifiers.contains(bundleIdentifier) { + return true + } + let title = (windowTitle ?? "").lowercased() + let markers = ["claude", "chatgpt", "chat gpt", "gemini", "copilot", "perplexity"] + return markers.contains { title.contains($0) } + } + private func processTranscript( _ rawTranscript: String, intent: SessionIntent, @@ -2491,7 +2548,8 @@ final class AppState: ObservableObject, @unchecked Sendable { context: context, customVocabulary: customVocabulary, customSystemPrompt: customSystemPrompt, - outputLanguage: outputLanguage + outputLanguage: outputLanguage, + condenseToPrompt: shouldCondenseForPrompt(context: context) ) return (result.transcript, .postProcessingSucceeded, result.prompt) } catch { diff --git a/Sources/PostProcessingService.swift b/Sources/PostProcessingService.swift index 4465a061..d6ff50e1 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -105,7 +105,18 @@ Output hygiene: - Never prepend boilerplate such as "Here is the clean transcript". - If the transcript is empty or only filler, return exactly: EMPTY """ - static let defaultSystemPromptDate = "2026-05-13" + static let defaultSystemPromptDate = "2026-07-05" + /// Appended to the cleanup prompt when Prompt Mode is active (issue + /// #196): condense rambling dictation into a tight prompt in the same + /// LLM pass, so it adds no extra latency. + static let promptCondensationSection = """ +Prompt condensation mode is active: +- The speaker is dictating a prompt or instruction for an AI tool. After cleaning, condense the result into the tightest version that preserves the full intent. +- Keep every concrete requirement, constraint, name, number, file path, and code identifier. Never drop a requirement. +- Remove filler, repetition, hedging, thinking-out-loud, and meta-commentary. +- Keep the speaker's request framing and language. Do not answer, expand, or execute the request. +- Prefer one to three sentences; use a short list only when the dictation contains genuinely distinct items. +""" static let commandModeSystemPrompt = """ You transform highlighted text according to a spoken editing command. @@ -160,7 +171,8 @@ Behavior: context: AppContext, customVocabulary: String, customSystemPrompt: String = "", - outputLanguage: String = "" + outputLanguage: String = "", + condenseToPrompt: Bool = false ) async throws -> PostProcessingResult { let vocabularyTerms = mergedVocabularyTerms(rawVocabulary: customVocabulary) @@ -175,7 +187,8 @@ Behavior: contextSummary: context.contextSummary, customVocabulary: vocabularyTerms, customSystemPrompt: customSystemPrompt, - outputLanguage: outputLanguage + outputLanguage: outputLanguage, + condenseToPrompt: condenseToPrompt ) } @@ -252,7 +265,8 @@ Behavior: contextSummary: String, customVocabulary: [String], customSystemPrompt: String = "", - outputLanguage: String = "" + outputLanguage: String = "", + condenseToPrompt: Bool = false ) async throws -> PostProcessingResult { let primaryModel = resolvedPrimaryModel() let retryModel = resolvedRetryModel(for: primaryModel) @@ -263,7 +277,8 @@ Behavior: model: primaryModel, customVocabulary: customVocabulary, customSystemPrompt: customSystemPrompt, - outputLanguage: outputLanguage + outputLanguage: outputLanguage, + condenseToPrompt: condenseToPrompt ) } catch let error as PostProcessingError { let shouldFallback: Bool @@ -293,7 +308,8 @@ Behavior: model: retryModel, customVocabulary: customVocabulary, customSystemPrompt: customSystemPrompt, - outputLanguage: outputLanguage + outputLanguage: outputLanguage, + condenseToPrompt: condenseToPrompt ) } catch PostProcessingError.suspectedInstructionExecution { return PostProcessingResult( @@ -375,7 +391,8 @@ Behavior: model: String, customVocabulary: [String], customSystemPrompt: String = "", - outputLanguage: String = "" + outputLanguage: String = "", + condenseToPrompt: Bool = false ) async throws -> PostProcessingResult { var request = URLRequest(url: URL(string: "\(baseURL)/chat/completions")!) request.httpMethod = "POST" @@ -401,6 +418,9 @@ Use these spellings exactly in the output when relevant: if !trimmedOutputLanguage.isEmpty { systemPrompt = Self.applyOutputLanguage(systemPrompt, language: trimmedOutputLanguage) } + if condenseToPrompt { + systemPrompt += "\n\n" + Self.promptCondensationSection + } if !vocabularyPrompt.isEmpty { systemPrompt += "\n\n" + vocabularyPrompt } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index d639bf1b..b52eadf3 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -663,6 +663,9 @@ struct GeneralSettingsView: View { SettingsCard("Edit Mode", icon: "pencil") { commandModeSection } + SettingsCard("Prompt Mode", icon: "wand.and.stars") { + promptModeSection + } SettingsCard("Clipboard", icon: "doc.on.clipboard") { clipboardSection } @@ -1296,6 +1299,36 @@ struct GeneralSettingsView: View { } } + // MARK: Prompt Mode + + private var promptModeSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Condense rambling dictation into a tight prompt that preserves your full intent — useful when dictating into AI tools like Claude, ChatGPT, or Cursor.") + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Text("Condense dictation") + .font(.system(size: 13)) + Spacer() + Picker("", selection: $appState.promptMode) { + Text("Off").tag(PromptModeSetting.off) + Text("Always").tag(PromptModeSetting.always) + Text("Only in AI apps").tag(PromptModeSetting.auto) + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 240) + } + + if appState.promptMode == .auto { + Text("Detects Claude, ChatGPT, Cursor, and similar tools from the active app and window title.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + // MARK: Custom Vocabulary private var vocabularySection: some View {