Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 59 additions & 1 deletion Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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] = [] {
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String> = [
"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) }
}

Comment on lines +2477 to +2508

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What is the current bundle identifier of the ChatGPT desktop app for macOS?

💡 Result:

The current bundle identifier for the official ChatGPT desktop application on macOS is com.openai.chat [1][2][3]. This identifier is consistently used in the application's configuration, package management, and system integration workflows [2][3][4].

Citations:


Tighten the copilot window-title marker.
com.openai.chat is the correct ChatGPT bundle ID, so the bundle list looks fine. The broad copilot title match can still fire on unrelated windows and cause unintended condensation; require a stronger signal or narrow the marker.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AppState.swift` around lines 2477 - 2508, The window-title marker in
isAITargetContext is too broad because “copilot” can match unrelated windows;
replace it with a more specific Copilot identifier or require stronger
contextual matching, while preserving the existing AI bundle checks and other
title markers.

private func processTranscript(
_ rawTranscript: String,
intent: SessionIntent,
Expand Down Expand Up @@ -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 {
Expand Down
34 changes: 27 additions & 7 deletions Sources/PostProcessingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand All @@ -175,7 +187,8 @@ Behavior:
contextSummary: context.contextSummary,
customVocabulary: vocabularyTerms,
customSystemPrompt: customSystemPrompt,
outputLanguage: outputLanguage
outputLanguage: outputLanguage,
condenseToPrompt: condenseToPrompt
)
}

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -293,7 +308,8 @@ Behavior:
model: retryModel,
customVocabulary: customVocabulary,
customSystemPrompt: customSystemPrompt,
outputLanguage: outputLanguage
outputLanguage: outputLanguage,
condenseToPrompt: condenseToPrompt
)
} catch PostProcessingError.suspectedInstructionExecution {
return PostProcessingResult(
Expand Down Expand Up @@ -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"
Expand All @@ -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
}
Expand Down
33 changes: 33 additions & 0 deletions Sources/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Comment on lines +1310 to +1322

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add an accessibility label to the Prompt Mode picker.

The picker uses .labelsHidden() with an empty string label, so VoiceOver users hear only the selected value without context. The overlayDisplaySection picker (line 1109) addresses this with .accessibilityLabel("Show on"). Apply the same pattern here.

♿ Proposed fix
                 Picker("", selection: $appState.promptMode) {
                     Text("Off").tag(PromptModeSetting.off)
                     Text("Always").tag(PromptModeSetting.always)
                     Text("Only in AI apps").tag(PromptModeSetting.auto)
                 }
                 .labelsHidden()
+                .accessibilityLabel("Condense dictation")
                 .pickerStyle(.menu)
                 .frame(maxWidth: 240)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
}
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()
.accessibilityLabel("Condense dictation")
.pickerStyle(.menu)
.frame(maxWidth: 240)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/SettingsView.swift` around lines 1310 - 1322, Add an accessibility
label to the Prompt Mode Picker in the HStack containing Text("Condense
dictation"), matching the existing overlayDisplaySection pattern by applying
.accessibilityLabel("Prompt mode") after .labelsHidden().


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 {
Expand Down