From 9e89b6e32e469027fb9e210913d661bd3cf0d006 Mon Sep 17 00:00:00 2001 From: Spencer Hedges Date: Sun, 5 Jul 2026 12:22:10 -0400 Subject: [PATCH 1/9] Honor configured timeouts instead of capping transfers at 30s The session-level timeoutIntervalForResource=30 silently overrode the transcription_timeout_seconds, post_processing_timeout_seconds, and context_request_timeout_seconds settings, killing long transfers to slow local models. Sessions are now cached per resource timeout derived from each request's configured timeout, preserving connection reuse. Uploads keep their fresh-session-per-call behavior. Fixes #253 --- Sources/LLMAPITransport.swift | 40 +++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/Sources/LLMAPITransport.swift b/Sources/LLMAPITransport.swift index e414d13b..698010c9 100644 --- a/Sources/LLMAPITransport.swift +++ b/Sources/LLMAPITransport.swift @@ -1,23 +1,49 @@ import Foundation enum LLMAPITransport { - private static let requestSession: URLSession = { - makeEphemeralSession() - }() + /// Floor for the whole-transfer budget so requests that never set an + /// explicit timeout still fail within a reasonable window. + private static let minimumResourceTimeout: TimeInterval = 30 - private static func makeEphemeralSession() -> URLSession { + /// Reusable sessions keyed by resource timeout, so connection reuse is + /// preserved while each request still gets a whole-transfer budget that + /// honors the caller's configured timeout. Only a handful of distinct + /// timeout values ever exist (one per timeout setting). + private static var sessionsByResourceTimeout: [TimeInterval: URLSession] = [:] + private static let sessionsLock = NSLock() + + private static func makeEphemeralSession(resourceTimeout: TimeInterval) -> URLSession { let configuration = URLSessionConfiguration.ephemeral configuration.requestCachePolicy = .reloadIgnoringLocalCacheData configuration.urlCache = nil configuration.timeoutIntervalForRequest = 20 - configuration.timeoutIntervalForResource = 30 + configuration.timeoutIntervalForResource = resourceTimeout return URLSession(configuration: configuration) } + /// timeoutIntervalForResource caps the entire transfer and has no + /// per-request override, so derive it from the caller's configured + /// timeout (e.g. post_processing_timeout_seconds) instead of a fixed cap. + private static func resourceTimeout(for request: URLRequest) -> TimeInterval { + max(request.timeoutInterval, minimumResourceTimeout) + } + + private static func sharedSession(for request: URLRequest) -> URLSession { + let timeout = resourceTimeout(for: request) + sessionsLock.lock() + defer { sessionsLock.unlock() } + if let existing = sessionsByResourceTimeout[timeout] { + return existing + } + let session = makeEphemeralSession(resourceTimeout: timeout) + sessionsByResourceTimeout[timeout] = session + return session + } + static func data( for request: URLRequest ) async throws -> (Data, URLResponse) { - try await requestSession.data(for: request) + try await sharedSession(for: request).data(for: request) } static func upload( @@ -26,7 +52,7 @@ enum LLMAPITransport { ) async throws -> (Data, URLResponse) { // Use a fresh session for each upload so a bad reused connection cannot // poison subsequent transcription uploads. - let session = makeEphemeralSession() + let session = makeEphemeralSession(resourceTimeout: resourceTimeout(for: request)) defer { session.finishTasksAndInvalidate() } return try await session.upload(for: request, from: bodyData) } From cc4659edf5774f8ea0b3707b28e67646f27c7f81 Mon Sep 17 00:00:00 2001 From: Spencer Hedges Date: Sun, 5 Jul 2026 12:22:10 -0400 Subject: [PATCH 2/9] Support mishearing-to-correction mappings in custom vocabulary Vocabulary entries can now map heard forms to the intended term with an arrow ("cloud code -> Claude Code"), with "|" separating multiple heard forms. Pairs are passed to the post-processing model as an explicit corrections block in both dictation and Edit Mode prompts; plain entries behave exactly as before. Closes #125 --- README.md | 2 +- Sources/PostProcessingService.swift | 92 ++++++++++++++++++++++------- 2 files changed, 73 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f7d389d9..65b25e40 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,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. +- **Custom vocabulary:** Add names, jargon, and project-specific words that FreeFlow should preserve during cleanup. Entries can also map specific mishearings to the intended term with an arrow — `cloud code -> Claude Code` — and multiple heard forms can share one correction: `cloud code | clod code -> Claude Code`. - **OpenAI-compatible providers:** Use Groq by default, or configure a custom model and API URL in settings. ## Edit Mode diff --git a/Sources/PostProcessingService.swift b/Sources/PostProcessingService.swift index 4465a061..b236b338 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -383,16 +383,7 @@ Behavior: request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.timeoutInterval = postProcessingTimeoutSeconds - let normalizedVocabulary = normalizedVocabularyText(customVocabulary) - let vocabularyPrompt = if !normalizedVocabulary.isEmpty { - """ -The following vocabulary must be treated as high-priority terms while rewriting. -Use these spellings exactly in the output when relevant: -\(normalizedVocabulary) -""" - } else { - "" - } + let vocabularyPrompt = vocabularyPromptSection(for: customVocabulary) var systemPrompt = customSystemPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Self.defaultSystemPrompt @@ -514,16 +505,7 @@ Model: \(model) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.timeoutInterval = postProcessingTimeoutSeconds - let normalizedVocabulary = normalizedVocabularyText(customVocabulary) - let vocabularyPrompt = if !normalizedVocabulary.isEmpty { - """ -The following vocabulary must be treated as high-priority terms while rewriting. -Use these spellings exactly in the output when relevant: -\(normalizedVocabulary) -""" - } else { - "" - } + let vocabularyPrompt = vocabularyPromptSection(for: customVocabulary) var systemPrompt = Self.commandModeSystemPrompt let trimmedOutputLanguage = outputLanguage.trimmingCharacters(in: .whitespacesAndNewlines) @@ -707,6 +689,76 @@ Model: \(model) }) } + /// A vocabulary list split into plain terms and explicit + /// heard-form -> correct-form correction pairs (issue #125). + private struct ParsedVocabulary { + var terms: [String] = [] + var corrections: [(heard: String, correct: String)] = [] + } + + /// Entries may use "heard form -> Correct Form" (or "=>") to teach the + /// model a specific mishearing. Multiple heard variants can share one + /// correction with "|": "cloud code | clod code -> Claude Code". + /// Entries without an arrow behave exactly as before. + private func parseVocabularyEntries(_ entries: [String]) -> ParsedVocabulary { + var parsed = ParsedVocabulary() + for entry in entries { + guard let arrow = entry.range(of: "->") ?? entry.range(of: "=>") else { + parsed.terms.append(entry) + continue + } + let correct = entry[arrow.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) + let heardForms = entry[..() + parsed.terms = parsed.terms.filter { seen.insert($0.lowercased()).inserted } + return parsed + } + + /// Builds the system-prompt section for custom vocabulary: a + /// high-priority term list plus, when mappings are present, explicit + /// mishearing corrections. Returns "" when there is no vocabulary. + private func vocabularyPromptSection(for customVocabulary: [String]) -> String { + let parsed = parseVocabularyEntries(customVocabulary) + var sections: [String] = [] + + let normalizedVocabulary = normalizedVocabularyText(parsed.terms) + if !normalizedVocabulary.isEmpty { + sections.append(""" +The following vocabulary must be treated as high-priority terms while rewriting. +Use these spellings exactly in the output when relevant: +\(normalizedVocabulary) +""") + } + + if !parsed.corrections.isEmpty { + let pairs = parsed.corrections + .map { "- \"\($0.heard)\" -> \"\($0.correct)\"" } + .joined(separator: "\n") + sections.append(""" +Known mishearings. When the transcript contains a left-hand form below (or a close phonetic variant of it) and the speaker clearly meant the right-hand term, output the right-hand form instead: +\(pairs) +Only apply a correction when the surrounding words make the intended term plausible; otherwise leave the transcript wording unchanged. +""") + } + + return sections.joined(separator: "\n\n") + } + private func mergedVocabularyTerms(rawVocabulary: String) -> [String] { let terms = rawVocabulary .split(whereSeparator: { $0 == "\n" || $0 == "," || $0 == ";" }) From a598fc59c4157ec28412725a20024a69a0cfb761 Mon Sep 17 00:00:00 2001 From: Spencer Hedges Date: Sun, 5 Jul 2026 12:22:17 -0400 Subject: [PATCH 3/9] Add configurable feedback sounds, menu bar panel redesign, changelog - Recording start, stop, and error sounds are now configurable in Settings with a picker and preview per event across the built-in macOS alert sound catalog (#107) - Settings hint text documenting the new vocabulary mapping syntax - Window-style menu bar panel redesign with live waveform icon while recording (in-progress work from the working tree) - Agent guardrails in CLAUDE.md and changelog entries --- CHANGELOG.md | 11 + CLAUDE.md | 25 + Sources/App.swift | 58 ++- Sources/AppState.swift | 46 +- Sources/MenuBarView.swift | 910 ++++++++++++++++++++++++------------- Sources/SettingsView.swift | 135 ++++-- 6 files changed, 839 insertions(+), 346 deletions(-) create mode 100644 CLAUDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9988bca9..e4ce087c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ 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 + +- Custom vocabulary entries can now map specific mishearings to the intended term with an arrow (`cloud code -> Claude Code`), and multiple heard forms can share one correction (`cloud code | clod code -> Claude Code`). Plain entries behave exactly as before. +- The recording start, stop, and error feedback sounds are now configurable in Settings, with a picker and preview button for each event across the full set of built-in macOS alert sounds. + +### Fixed + +- Fixed the configured transcription, post-processing, and context timeouts being silently capped at 30 seconds by a session-level resource timeout, which broke long transfers to slow local models. + ## [1.1.0] - 2026-06-03 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..79c492f6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ +## Agent Guardrails + +You are running as an agent. Do not run destructive commands without explicit permission. + +### Git +- Never run `git push --force`, `git push -f`, or rewrite shared history +- Never delete branches (local or remote) without explicit permission +- Never commit `.env*` files, API keys, tokens, or credentials + +### Filesystem +- Never run `rm -rf` on anything outside the current project directory +- Never modify files outside the current working directory without explicit permission +- Never edit `.env`, `.env.local`, or any `.env.*` file without explicit permission + +### Dependencies +- Ask before installing new packages or running `npm install ` +- Ask before upgrading major versions or modifying lockfiles by hand + +### Database +- Never run database migrations, drops, or schema changes without explicit permission + +### Behavior +- When uncertain, stop and ask rather than guess +- Summarize planned changes before applying anything non-trivial +- Don't disable, skip, or comment out tests, linting, or type checks to make things pass diff --git a/Sources/App.swift b/Sources/App.swift index 8230464e..fc12e75a 100644 --- a/Sources/App.swift +++ b/Sources/App.swift @@ -14,6 +14,7 @@ struct FreeFlowApp: App { MenuBarLabel() .environmentObject(appDelegate.appState) } + .menuBarExtraStyle(.window) } } @@ -22,28 +23,71 @@ struct MenuBarLabel: View { @EnvironmentObject var appState: AppState @ObservedObject var notificationManager = VocabularyNotificationManager.shared - private var iconName: String { - if appState.isRecording { return "record.circle" } - if appState.isTranscribing { return "ellipsis.circle" } - return "waveform" - } + /// Rolling window of recent input levels rendered as the live + /// menu bar waveform while recording. + @State private var levelHistory: [Float] = Array(repeating: 0, count: 5) var body: some View { HStack(spacing: 4) { if notificationManager.showCheckmark { Image(systemName: "checkmark") } - if AppBuild.isDevBundle && !appState.isRecording && !appState.isTranscribing { + if appState.isRecording { + Image(nsImage: LiveWaveMenuBarIcon.image(levels: levelHistory)) + .renderingMode(.template) + } else if appState.isTranscribing { + Image(systemName: "ellipsis.circle") + } else if AppBuild.isDevBundle { Image(nsImage: StampedMenuBarIcon.templateImage) .renderingMode(.template) } else { - Image(systemName: iconName) + Image(systemName: "waveform") + } + } + .onChange(of: appState.menuBarAudioLevel) { level in + guard appState.isRecording else { return } + levelHistory.removeFirst() + levelHistory.append(level) + } + .onChange(of: appState.isRecording) { recording in + if !recording { + levelHistory = Array(repeating: 0, count: 5) } } .animation(.easeInOut(duration: 0.2), value: notificationManager.showCheckmark) } } +/// Renders the recording-state menu bar icon: a bar per recent level +/// sample, so the icon itself moves with the user's voice. Template +/// image, so it stays legible in light and dark menu bars. +enum LiveWaveMenuBarIcon { + static func image(levels: [Float]) -> NSImage { + let size = NSSize(width: 18, height: 16) + let barWidth: CGFloat = 2 + let spacing: CGFloat = 1.5 + let image = NSImage(size: size, flipped: false) { rect in + let count = CGFloat(levels.count) + let totalWidth = count * barWidth + (count - 1) * spacing + var x = (rect.width - totalWidth) / 2 + NSColor.black.setFill() + for level in levels { + let clamped = CGFloat(min(max(level, 0), 1)) + let height = 3 + clamped * 10 + let y = (rect.height - height) / 2 + NSBezierPath( + roundedRect: NSRect(x: x, y: y, width: barWidth, height: height), + xRadius: barWidth / 2, yRadius: barWidth / 2 + ).fill() + x += barWidth + spacing + } + return true + } + image.isTemplate = true + return image + } +} + enum StampedMenuBarIcon { static let templateImage: NSImage = { let size = NSSize(width: 18, height: 16) diff --git a/Sources/AppState.swift b/Sources/AppState.swift index c65bc809..6f9688ca 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -227,6 +227,9 @@ 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 startSoundNameStorageKey = "start_sound_name" + private let stopSoundNameStorageKey = "stop_sound_name" + private let errorSoundNameStorageKey = "error_sound_name" private let voiceMacrosStorageKey = "voice_macros" private let commandModeEnabledStorageKey = "command_mode_enabled" private let commandModeStyleStorageKey = "command_mode_style" @@ -529,6 +532,24 @@ final class AppState: ObservableObject, @unchecked Sendable { } } + @Published var startSoundName: String { + didSet { + UserDefaults.standard.set(startSoundName, forKey: startSoundNameStorageKey) + } + } + + @Published var stopSoundName: String { + didSet { + UserDefaults.standard.set(stopSoundName, forKey: stopSoundNameStorageKey) + } + } + + @Published var errorSoundName: String { + didSet { + UserDefaults.standard.set(errorSoundName, forKey: errorSoundNameStorageKey) + } + } + private var precomputedMacros: [PrecomputedMacro] = [] @Published var voiceMacros: [VoiceMacro] = [] { @@ -557,6 +578,8 @@ final class AppState: ObservableObject, @unchecked Sendable { @Published var selectedSettingsTab: SettingsTab? = .general @Published var pipelineHistory: [PipelineHistoryItem] = [] @Published var debugStatusMessage = "Idle" + /// Live input level mirrored to the menu bar icon while recording. + @Published var menuBarAudioLevel: Float = 0 @Published var debugShowsUpdateReminderAfterDictation = false @Published var lastRawTranscript = "" @Published var lastPostProcessedTranscript = "" @@ -690,6 +713,9 @@ final class AppState: ObservableObject, @unchecked Sendable { let alertSoundsEnabled = UserDefaults.standard.object(forKey: alertSoundsEnabledStorageKey) != nil ? UserDefaults.standard.bool(forKey: alertSoundsEnabledStorageKey) : soundVolume > 0 + let startSoundName = UserDefaults.standard.string(forKey: startSoundNameStorageKey) ?? "Tink" + let stopSoundName = UserDefaults.standard.string(forKey: stopSoundNameStorageKey) ?? "Pop" + let errorSoundName = UserDefaults.standard.string(forKey: errorSoundNameStorageKey) ?? "Basso" let initialMacros: [VoiceMacro] if let data = UserDefaults.standard.data(forKey: "voice_macros"), @@ -757,6 +783,9 @@ final class AppState: ObservableObject, @unchecked Sendable { self.isPressEnterVoiceCommandEnabled = isPressEnterVoiceCommandEnabled self.alertSoundsEnabled = alertSoundsEnabled self.soundVolume = soundVolume + self.startSoundName = startSoundName + self.stopSoundName = stopSoundName + self.errorSoundName = errorSoundName self.voiceMacros = initialMacros self.pipelineHistory = savedHistory self.hasAccessibility = initialAccessibility @@ -1902,7 +1931,7 @@ final class AppState: ObservableObject, @unchecked Sendable { if triggerMode == .toggle { cancelPendingShortcutStart() } - playAlertSound(named: "Basso") + playErrorSound() scheduleReadyStatusReset(after: 2, matching: ["Select text to transform first"]) } @@ -1918,7 +1947,7 @@ final class AppState: ObservableObject, @unchecked Sendable { if triggerMode == .toggle { cancelPendingShortcutStart() } - playAlertSound(named: "Basso") + playErrorSound() scheduleReadyStatusReset(after: 2, matching: ["Fix Edit Mode modifier"]) } @@ -1999,7 +2028,7 @@ final class AppState: ObservableObject, @unchecked Sendable { activeRecordingTriggerMode = nil currentSessionIntent = .dictation shortcutSessionController.reset() - playAlertSound(named: "Basso") + playErrorSound() showScreenshotPermissionAlert(message: message) return false } @@ -2180,7 +2209,7 @@ final class AppState: ObservableObject, @unchecked Sendable { ) } overlayShown = true - self.playAlertSound(named: "Tink") + self.playStartSound() } } audioRecorder.onRecordingFailure = { [weak self] error in @@ -2207,6 +2236,7 @@ final class AppState: ObservableObject, @unchecked Sendable { .receive(on: DispatchQueue.main) .sink { [weak self] level in self?.overlayManager.updateAudioLevel(level) + self?.menuBarAudioLevel = level } } } catch { @@ -2412,6 +2442,10 @@ final class AppState: ObservableObject, @unchecked Sendable { sound?.play() } + func playStartSound() { playAlertSound(named: startSoundName) } + func playStopSound() { playAlertSound(named: stopSoundName) } + func playErrorSound() { playAlertSound(named: errorSoundName) } + private func findMatchingMacro(for transcript: String) -> VoiceMacro? { let normalizedTranscript = normalize(transcript) guard !normalizedTranscript.isEmpty else { return nil } @@ -2555,7 +2589,7 @@ final class AppState: ObservableObject, @unchecked Sendable { isTranscribing = true statusText = "Preparing audio..." errorMessage = nil - playAlertSound(named: "Pop") + playStopSound() overlayManager.showTranscribing() audioRecorder.stopRecording { [weak self] fileURL in guard let self else { return } @@ -2999,7 +3033,7 @@ final class AppState: ObservableObject, @unchecked Sendable { statusText = "Screenshot Required" overlayManager.dismiss() - playAlertSound(named: "Basso") + playErrorSound() showScreenshotPermissionAlert(message: message) } // Non-permission errors (transient failures) — continue recording without context diff --git a/Sources/MenuBarView.swift b/Sources/MenuBarView.swift index ee501854..1cb20417 100644 --- a/Sources/MenuBarView.swift +++ b/Sources/MenuBarView.swift @@ -1,425 +1,723 @@ import SwiftUI +// MARK: - Panel Dismissal + +/// Closes the window-style MenuBarExtra panel. SwiftUI provides no public +/// dismissal API on macOS 13, so this matches the private panel class by +/// name. Matching is intentionally narrow ("MenuBarExtra") so the status +/// item's own NSStatusBarWindow is never touched. +@MainActor +func dismissMenuBarPanel() { + for window in NSApp.windows where window.className.contains("MenuBarExtra") { + window.close() + } +} + +// MARK: - Menu Bar Panel + struct MenuBarView: View { @EnvironmentObject var appState: AppState @ObservedObject private var updateManager = UpdateManager.shared + @State private var copiedItemID: UUID? + @State private var copiedLastTranscript = false + private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" } private var recentHistoryItems: [PipelineHistoryItem] { - Array(appState.pipelineHistory.filter { !transcriptText(for: $0).isEmpty }.prefix(10)) + Array(appState.pipelineHistory.filter { !transcriptText(for: $0).isEmpty }.prefix(5)) } - private func transcriptText(for item: PipelineHistoryItem) -> String { - let cleaned = item.postProcessedTranscript.trimmingCharacters(in: .whitespacesAndNewlines) - if !cleaned.isEmpty { - return cleaned - } - return item.rawTranscript.trimmingCharacters(in: .whitespacesAndNewlines) - } + var body: some View { + VStack(spacing: 0) { + header + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 10) - private func transcriptFull(for item: PipelineHistoryItem) -> String { - if !item.postProcessedTranscript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return item.postProcessedTranscript + permissionBanners + + VStack(spacing: 10) { + dictateButton + + if let hotkeyError = appState.hotkeyMonitoringErrorMessage { + inlineError(hotkeyError) + } + if let error = appState.errorMessage { + inlineError(error) + } + + if !appState.lastTranscript.isEmpty && !appState.isRecording && !appState.isTranscribing { + lastTranscriptCard + } + } + .padding(.horizontal, 12) + .padding(.bottom, 12) + + if !recentHistoryItems.isEmpty { + sectionDivider + historySection + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + sectionDivider + controlsSection + .padding(.horizontal, 12) + .padding(.vertical, 10) + + updateBanner + + sectionDivider + footer + .padding(.horizontal, 10) + .padding(.vertical, 8) } - return item.rawTranscript + .frame(width: 320) } - private func transcriptSnippet(for item: PipelineHistoryItem) -> String { - let text = transcriptText(for: item) - .replacingOccurrences(of: "\n", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return "(no transcript)" } - return text.count > 48 ? String(text.prefix(48)) + "..." : text - } + // MARK: Header - private func copyTranscriptToPasteboard(_ transcript: String) { - guard !transcript.isEmpty else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(transcript, forType: .string) + private var statusColor: Color { + if appState.isRecording { return .red } + if appState.isTranscribing { return .orange } + return .green } - private func openRunLog() { - appState.selectedSettingsTab = .runLog - NotificationCenter.default.post(name: .showSettings, object: nil) + private var statusText: String { + if appState.isRecording { return "Recording…" } + if appState.isTranscribing { return appState.debugStatusMessage } + return appState.shortcutStatusText } - var body: some View { - VStack(spacing: 4) { - Text("\(AppName.displayName) v\(appVersion)") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 16) - .padding(.vertical, 4) + private var header: some View { + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill( + LinearGradient( + colors: [Color.accentColor, Color.accentColor.opacity(0.65)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 30, height: 30) + Image(systemName: "waveform") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 1) { + Text(AppName.displayName) + .font(.system(size: 13, weight: .semibold)) + HStack(spacing: 5) { + Circle() + .fill(statusColor) + .frame(width: 6, height: 6) + Text(statusText) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() - Divider() + Text("v\(appVersion)") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + } + .animation(.easeInOut(duration: 0.2), value: appState.isRecording) + .animation(.easeInOut(duration: 0.2), value: appState.isTranscribing) + } + // MARK: Permission Banners + + @ViewBuilder + private var permissionBanners: some View { + VStack(spacing: 6) { if !appState.hasScreenRecordingPermission { - Button { + PanelBannerButton( + title: "Screen Recording permission needed", + systemImage: "camera.viewfinder", + tint: .orange + ) { appState.requestScreenCapturePermission() - } label: { - Label("Screen Recording Permission Needed", systemImage: "camera.viewfinder") } - .buttonStyle(.plain) - .foregroundStyle(.white) - .font(.caption.weight(.semibold)) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(Color.orange) - - Divider() } - - // Accessibility warning if !appState.hasAccessibility { - Button { + PanelBannerButton( + title: "Accessibility access required", + systemImage: "exclamationmark.triangle.fill", + tint: .red + ) { appState.showAccessibilityAlert() - } label: { - Label("Accessibility Required", systemImage: "exclamationmark.triangle.fill") } - .buttonStyle(.plain) - .foregroundStyle(.white) - .font(.caption.weight(.semibold)) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(Color.red) - - Divider() } + } + .padding(.horizontal, 12) + .padding(.bottom, appState.hasAccessibility && appState.hasScreenRecordingPermission ? 0 : 10) + } - // Status - if appState.isRecording { - Label("Recording...", systemImage: "record.circle") - .foregroundStyle(.red) - .padding(.horizontal, 16) - .padding(.vertical, 6) - } else if appState.isTranscribing { - Label(appState.debugStatusMessage, systemImage: "ellipsis.circle") - .foregroundStyle(.secondary) - .padding(.horizontal, 16) - .padding(.vertical, 6) - } else { - Text(appState.shortcutStatusText) - .foregroundStyle(.secondary) - .font(.caption) - .padding(.horizontal, 16) - .padding(.vertical, 6) - } + // MARK: Primary Action - Divider() + private var dictateHint: String? { + guard !appState.isRecording else { return nil } + if !appState.holdShortcut.isDisabled { + return "Hold \(appState.holdShortcut.displayName)" + } + if !appState.toggleShortcut.isDisabled { + return "Tap \(appState.toggleShortcut.displayName)" + } + return nil + } - // Manual toggle - Button(appState.isRecording ? "Stop Recording" : "Start Dictating") { + private var dictateButton: some View { + Button { + let shouldStop = appState.isRecording + dismissMenuBarPanel() + if shouldStop { appState.toggleRecording() + } else { + // Hand focus back to the previous app before recording so the + // transcript pastes where the user was typing, not into + // FreeFlow's own panel. + NSApp.hide(nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + appState.toggleRecording() + } } - .disabled(appState.isTranscribing) - - if let hotkeyError = appState.hotkeyMonitoringErrorMessage { - Divider() - Text(hotkeyError) - .foregroundStyle(.red) - .font(.caption) - .padding(.horizontal, 16) - .lineLimit(3) + } label: { + HStack(spacing: 8) { + Image(systemName: appState.isRecording ? "stop.fill" : "mic.fill") + .font(.system(size: 12, weight: .semibold)) + Text(appState.isRecording ? "Stop Recording" : "Start Dictating") + .font(.system(size: 13, weight: .semibold)) + Spacer() + if let hint = dictateHint { + Text(hint) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.white.opacity(0.75)) + } } + .foregroundStyle(.white) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .frame(height: 38) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill( + LinearGradient( + colors: appState.isRecording + ? [Color.red, Color.red.opacity(0.8)] + : [Color.accentColor, Color.accentColor.opacity(0.8)], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + } + .buttonStyle(.plain) + .disabled(appState.isTranscribing) + .opacity(appState.isTranscribing ? 0.5 : 1) + } - if let error = appState.errorMessage { - Divider() - Text(error) - .foregroundStyle(.red) - .font(.caption) - .padding(.horizontal, 16) - .lineLimit(3) - } + private func inlineError(_ message: String) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 11)) + .foregroundStyle(.red) + .padding(.top, 1) + Text(message) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.red.opacity(0.08)) + ) + } - Divider() + // MARK: Last Transcript - if !appState.lastTranscript.isEmpty && !appState.isRecording && !appState.isTranscribing { - Button(appState.copyAgainShortcut.isDisabled - ? "Paste Again" - : "Paste Again (\(appState.copyAgainShortcut.displayName))") { + private var lastTranscriptCard: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("LAST TRANSCRIPT") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.tertiary) + .kerning(0.5) + Spacer() + Button { appState.copyLastTranscriptToPasteboard() + withAnimation(.easeInOut(duration: 0.15)) { copiedLastTranscript = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + withAnimation { copiedLastTranscript = false } + } + } label: { + HStack(spacing: 3) { + Image(systemName: copiedLastTranscript ? "checkmark" : "doc.on.doc") + .font(.system(size: 9, weight: .semibold)) + Text(pasteAgainLabel) + .font(.system(size: 10, weight: .medium)) + } + .foregroundStyle(copiedLastTranscript ? Color.green : Color.accentColor) } - - let truncatedTranscript = appState.lastTranscript.count > 35 - ? String(appState.lastTranscript.prefix(35)) + "…" - : appState.lastTranscript - Text("\u{201C}\(truncatedTranscript)\u{201D}") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 16) - .lineLimit(4) - .frame(maxWidth: 280, alignment: .leading) + .buttonStyle(.plain) } - Menu("History") { - if recentHistoryItems.isEmpty { - Text("No transcripts yet") - } else { - ForEach(recentHistoryItems) { item in - let transcript = transcriptText(for: item) - Button { - copyTranscriptToPasteboard(transcriptFull(for: item)) - } label: { - Text(transcriptSnippet(for: item)) - } - .disabled(transcript.isEmpty) - } + Text(appState.lastTranscript) + .font(.system(size: 12)) + .foregroundStyle(.primary) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.05)) + ) + } - Divider() - } + private var pasteAgainLabel: String { + if copiedLastTranscript { return "Copied" } + if appState.copyAgainShortcut.isDisabled { return "Paste Again" } + return "Paste Again \(appState.copyAgainShortcut.displayName)" + } - Button("Open Run Log") { - openRunLog() + // MARK: History + + private var historySection: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("RECENT") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.tertiary) + .kerning(0.5) + Spacer() + Button("View All") { + openSettingsTab(.runLog) } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(Color.accentColor) } - - Divider() - - Button("Paste Custom Word to Vocabulary") { - if appState.pasteWordToVocabulary() != nil { - VocabularyNotificationManager.shared.flashCheckmark() + .padding(.horizontal, 4) + + ForEach(recentHistoryItems) { item in + HistoryRow( + snippet: transcriptSnippet(for: item), + detail: historyDetail(for: item), + isCopied: copiedItemID == item.id + ) { + copyToPasteboard(transcriptFull(for: item)) + withAnimation(.easeInOut(duration: 0.15)) { copiedItemID = item.id } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + if copiedItemID == item.id { + withAnimation { copiedItemID = nil } + } + } } } + } + } - Divider() + private func historyDetail(for item: PipelineHistoryItem) -> String { + let time = Self.relativeFormatter.localizedString(for: item.timestamp, relativeTo: Date()) + if let app = item.contextAppName, !app.isEmpty { + return "\(time) · \(app)" + } + return time + } - Menu("Hold Shortcut") { - Button { - _ = appState.setShortcut(.disabled, for: .hold) - } label: { - if appState.holdShortcut.isDisabled { - Text("✓ Disabled") - } else { - Text(" Disabled") - } - } + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() - ForEach(ShortcutPreset.allCases) { preset in - Button { - _ = appState.setShortcut(preset.binding, for: .hold) - } label: { - if appState.holdShortcut == preset.binding { - Text("✓ \(preset.title)") - } else { - Text(" \(preset.title)") - } - } - .disabled(preset.binding == appState.toggleShortcut) - } + // MARK: Controls - if let savedCustomShortcut = appState.savedCustomShortcut(for: .hold) { - Divider() - Button { - _ = appState.setShortcut(savedCustomShortcut, for: .hold) - } label: { - if appState.holdShortcut == savedCustomShortcut { - Text("✓ Custom: \(savedCustomShortcut.displayName)") - } else { - Text(" Custom: \(savedCustomShortcut.displayName)") - } - } - } + private var controlsSection: some View { + VStack(spacing: 2) { + microphoneRow + shortcutRow(role: .hold, icon: "hand.tap", current: appState.holdShortcut) + shortcutRow(role: .toggle, icon: "cursorarrow.click.2", current: appState.toggleShortcut) + shortcutRow(role: .copyAgain, icon: "doc.on.clipboard", current: appState.copyAgainShortcut) + } + } - Divider() - Button("Customize…") { - appState.selectedSettingsTab = .general - NotificationCenter.default.post(name: .showSettings, object: nil) - } - } + private var selectedMicrophoneName: String { + if appState.selectedMicrophoneID == "default" || appState.selectedMicrophoneID.isEmpty { + return "System Default" + } + return appState.availableMicrophones.first { $0.uid == appState.selectedMicrophoneID }?.name + ?? "System Default" + } - Menu("Toggle Shortcut") { + private var microphoneRow: some View { + PanelControlRow(icon: "mic", title: "Microphone") { + Menu { Button { - _ = appState.setShortcut(.disabled, for: .toggle) + appState.selectedMicrophoneID = "default" } label: { - if appState.toggleShortcut.isDisabled { - Text("✓ Disabled") - } else { - Text(" Disabled") - } + menuChoiceLabel( + "System Default", + isSelected: appState.selectedMicrophoneID == "default" + || appState.selectedMicrophoneID.isEmpty + ) } - - ForEach(ShortcutPreset.allCases) { preset in + ForEach(appState.availableMicrophones) { device in Button { - _ = appState.setShortcut(preset.binding, for: .toggle) + appState.selectedMicrophoneID = device.uid } label: { - if appState.toggleShortcut == preset.binding { - Text("✓ \(preset.title)") - } else { - Text(" \(preset.title)") - } + menuChoiceLabel(device.name, isSelected: appState.selectedMicrophoneID == device.uid) } - .disabled(preset.binding == appState.holdShortcut) } + } label: { + Text(selectedMicrophoneName) + .font(.system(size: 11)) + .lineLimit(1) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + } - if let savedCustomShortcut = appState.savedCustomShortcut(for: .toggle) { - Divider() - Button { - _ = appState.setShortcut(savedCustomShortcut, for: .toggle) - } label: { - if appState.toggleShortcut == savedCustomShortcut { - Text("✓ Custom: \(savedCustomShortcut.displayName)") - } else { - Text(" Custom: \(savedCustomShortcut.displayName)") - } - } - } + private func conflictingBinding(for role: ShortcutRole) -> [ShortcutBinding] { + switch role { + case .hold: return [appState.toggleShortcut] + case .toggle: return [appState.holdShortcut] + case .copyAgain: return [appState.holdShortcut, appState.toggleShortcut] + } + } - Divider() - Button("Customize…") { - appState.selectedSettingsTab = .general - NotificationCenter.default.post(name: .showSettings, object: nil) - } - } + private func currentBinding(for role: ShortcutRole) -> ShortcutBinding { + switch role { + case .hold: return appState.holdShortcut + case .toggle: return appState.toggleShortcut + case .copyAgain: return appState.copyAgainShortcut + } + } - Menu("Paste Again Shortcut") { + private func shortcutRow(role: ShortcutRole, icon: String, current: ShortcutBinding) -> some View { + PanelControlRow(icon: icon, title: role.title) { + Menu { Button { - _ = appState.setShortcut(.disabled, for: .copyAgain) + _ = appState.setShortcut(.disabled, for: role) } label: { - if appState.copyAgainShortcut.isDisabled { - Text("✓ Disabled") - } else { - Text(" Disabled") - } + menuChoiceLabel("Disabled", isSelected: current.isDisabled) } ForEach(ShortcutPreset.allCases) { preset in Button { - _ = appState.setShortcut(preset.binding, for: .copyAgain) + _ = appState.setShortcut(preset.binding, for: role) } label: { - if appState.copyAgainShortcut == preset.binding { - Text("✓ \(preset.title)") - } else { - Text(" \(preset.title)") - } + menuChoiceLabel(preset.title, isSelected: current == preset.binding) } - .disabled(preset.binding == appState.holdShortcut || preset.binding == appState.toggleShortcut) + .disabled(conflictingBinding(for: role).contains(preset.binding)) } - if let savedCustomShortcut = appState.savedCustomShortcut(for: .copyAgain) { + if let custom = appState.savedCustomShortcut(for: role) { Divider() Button { - _ = appState.setShortcut(savedCustomShortcut, for: .copyAgain) + _ = appState.setShortcut(custom, for: role) } label: { - if appState.copyAgainShortcut == savedCustomShortcut { - Text("✓ Custom: \(savedCustomShortcut.displayName)") - } else { - Text(" Custom: \(savedCustomShortcut.displayName)") - } + menuChoiceLabel("Custom: \(custom.displayName)", isSelected: current == custom) } } Divider() Button("Customize…") { - appState.selectedSettingsTab = .general - NotificationCenter.default.post(name: .showSettings, object: nil) - } - } - - Menu("Microphone") { - Button { - appState.selectedMicrophoneID = "default" - } label: { - if appState.selectedMicrophoneID == "default" || appState.selectedMicrophoneID.isEmpty { - Text("✓ System Default") - } else { - Text(" System Default") - } - } - ForEach(appState.availableMicrophones) { device in - Button { - appState.selectedMicrophoneID = device.uid - } label: { - if appState.selectedMicrophoneID == device.uid { - Text("✓ \(device.name)") - } else { - Text(" \(device.name)") - } - } - } - } - - Button("Re-run Setup...") { - NotificationCenter.default.post(name: .showSetup, object: nil) - } - - Button("Settings") { - NotificationCenter.default.post(name: .showSettings, object: nil) - } - - Button { - Task { - await updateManager.checkForUpdates(userInitiated: true) + openSettingsTab(.general) } } label: { - HStack(spacing: 6) { - if updateManager.isChecking { - ProgressView() - .controlSize(.small) - } - Text(updateManager.isChecking ? "Checking for Updates..." : "Check for Updates") - } + Text(current.isDisabled ? "Off" : current.displayName) + .font(.system(size: 11)) + .lineLimit(1) } - .disabled(updateManager.isChecking) + .menuStyle(.borderlessButton) + .fixedSize() + } + } - if updateManager.updateAvailable { - Divider() + @ViewBuilder + private func menuChoiceLabel(_ title: String, isSelected: Bool) -> some View { + if isSelected { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + + // MARK: Update Banner + @ViewBuilder + private var updateBanner: some View { + if updateManager.updateAvailable { + sectionDivider + Group { switch updateManager.updateStatus { case .downloading: VStack(spacing: 4) { - Text("Downloading update... \(Int((updateManager.downloadProgress ?? 0) * 100))%") - .font(.caption.weight(.semibold)) - .foregroundStyle(.white) + Text("Downloading update… \(Int((updateManager.downloadProgress ?? 0) * 100))%") + .font(.system(size: 11, weight: .semibold)) ProgressView(value: updateManager.downloadProgress ?? 0) .progressViewStyle(.linear) - .tint(.white) } - .padding(.horizontal, 16) + .padding(.horizontal, 12) .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(Color.blue) case .installing, .readyToRelaunch: HStack(spacing: 6) { ProgressView() .controlSize(.small) - Text("Installing update...") - .font(.caption.weight(.semibold)) + Text("Installing update…") + .font(.system(size: 11, weight: .semibold)) } - .foregroundStyle(.white) - .padding(.horizontal, 16) .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(Color.blue) default: - Button { + PanelBannerButton( + title: "Update available — install now", + systemImage: "arrow.down.circle.fill", + tint: .blue + ) { + dismissMenuBarPanel() updateManager.showUpdateAlert() - } label: { - Label("Update available", systemImage: "arrow.down.circle.fill") } - .buttonStyle(.plain) - .foregroundStyle(.white) - .font(.caption.weight(.semibold)) - .padding(.horizontal, 16) + .padding(.horizontal, 12) .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(Color.blue) } } + } + } + + // MARK: Footer - Divider() + private var footer: some View { + HStack(spacing: 2) { + PanelFooterButton(title: "Settings", systemImage: "gearshape") { + dismissMenuBarPanel() + NotificationCenter.default.post(name: .showSettings, object: nil) + } + + Menu { + Button("Paste Custom Word to Vocabulary") { + if appState.pasteWordToVocabulary() != nil { + VocabularyNotificationManager.shared.flashCheckmark() + } + } + Button("Re-run Setup…") { + dismissMenuBarPanel() + NotificationCenter.default.post(name: .showSetup, object: nil) + } + Divider() + Button(updateManager.isChecking ? "Checking for Updates…" : "Check for Updates") { + Task { + await updateManager.checkForUpdates(userInitiated: true) + } + } + .disabled(updateManager.isChecking) + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 26) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() - Button("Quit \(AppName.displayName)") { + Spacer() + + PanelFooterButton(title: "Quit", systemImage: "power") { NSApplication.shared.terminate(nil) } .keyboardShortcut("q") } - .padding(4) + } + + // MARK: Helpers + + private func openSettingsTab(_ tab: SettingsTab) { + appState.selectedSettingsTab = tab + dismissMenuBarPanel() + NotificationCenter.default.post(name: .showSettings, object: nil) + } + + private var sectionDivider: some View { + Divider().opacity(0.5) + } + + private func transcriptText(for item: PipelineHistoryItem) -> String { + let cleaned = item.postProcessedTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + if !cleaned.isEmpty { + return cleaned + } + return item.rawTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func transcriptFull(for item: PipelineHistoryItem) -> String { + if !item.postProcessedTranscript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return item.postProcessedTranscript + } + return item.rawTranscript + } + + private func transcriptSnippet(for item: PipelineHistoryItem) -> String { + let text = transcriptText(for: item) + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return "(no transcript)" } + return text.count > 60 ? String(text.prefix(60)) + "…" : text + } + + private func copyToPasteboard(_ transcript: String) { + guard !transcript.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(transcript, forType: .string) + } +} + +// MARK: - Components + +/// Full-width tinted banner used for permission warnings and update prompts. +private struct PanelBannerButton: View { + let title: String + let systemImage: String + let tint: Color + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 7) { + Image(systemName: systemImage) + .font(.system(size: 11, weight: .semibold)) + Text(title) + .font(.system(size: 11, weight: .semibold)) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .opacity(0.6) + } + .foregroundStyle(tint) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(tint.opacity(isHovered ? 0.18 : 0.12)) + ) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + } +} + +/// History row: snippet plus relative time, copies on click with hover affordance. +private struct HistoryRow: View { + let snippet: String + let detail: String + let isCopied: Bool + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 1) { + Text(snippet) + .font(.system(size: 11.5)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(detail) + .font(.system(size: 9.5)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Spacer(minLength: 4) + if isCopied { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Color.green) + } else if isHovered { + Image(systemName: "doc.on.doc") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Color.primary.opacity(isHovered ? 0.06 : 0)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .help("Click to copy") + } +} + +/// Labeled control row with a trailing menu/value. +private struct PanelControlRow: View { + let icon: String + let title: String + @ViewBuilder let trailing: Trailing + + var body: some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .frame(width: 16) + Text(title) + .font(.system(size: 12)) + Spacer() + trailing + .foregroundStyle(.secondary) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + } +} + +/// Compact footer button with hover highlight. +private struct PanelFooterButton: View { + let title: String + let systemImage: String + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 5) { + Image(systemName: systemImage) + .font(.system(size: 10.5, weight: .medium)) + Text(title) + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .frame(height: 26) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(isHovered ? 0.07 : 0)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } } } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index d639bf1b..51dd3013 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -348,28 +348,43 @@ struct ProviderSettingsFields: View { struct SettingsView: View { @EnvironmentObject var appState: AppState + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + } + var body: some View { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 0) { + Text(AppName.displayName) + .font(.system(size: 12, weight: .semibold)) + Text("Version \(appVersion)") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 10) + .padding(.top, 6) + .padding(.bottom, 12) + ForEach(SettingsTab.visibleCases) { tab in - Button { + SettingsSidebarRow( + title: tab.title, + icon: tab.icon, + isSelected: appState.selectedSettingsTab == tab + ) { appState.selectedSettingsTab = tab - } label: { - SettingsSidebarRow(title: tab.title, icon: tab.icon) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(appState.selectedSettingsTab == tab - ? Color.accentColor.opacity(0.15) - : Color.clear) - ) } - .buttonStyle(.plain) } Spacer() } .padding(10) - .frame(width: 180) + .frame(width: 190) .background(Color(nsColor: .windowBackgroundColor)) Divider() @@ -396,22 +411,39 @@ struct SettingsView: View { private struct SettingsSidebarRow: View { let title: String let icon: String + let isSelected: Bool + let action: () -> Void + + @State private var isHovered = false var body: some View { - HStack(spacing: 8) { - Image(systemName: icon) - .font(.system(size: 13, weight: .regular)) - .frame(width: 16, height: 16, alignment: .center) - .foregroundStyle(.primary) + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 13, weight: .regular)) + .frame(width: 16, height: 16, alignment: .center) + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) - Text(title) - .font(.body) - .frame(maxWidth: .infinity, alignment: .leading) + Text(title) + .font(.system(size: 13, weight: isSelected ? .medium : .regular)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + isSelected + ? Color.accentColor.opacity(0.15) + : Color.primary.opacity(isHovered ? 0.06 : 0) + ) + ) + .contentShape(Rectangle()) } - .frame(height: 16) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 8) - .padding(.horizontal, 10) + .buttonStyle(.plain) + .onHover { isHovered = $0 } } } @@ -1274,7 +1306,7 @@ struct GeneralSettingsView: View { withAnimation(.easeInOut(duration: 0.2)) { showMutedHint = muted || (volume ?? 1) < 0.10 } - appState.playAlertSound(named: "Tink") + appState.playStartSound() } .font(.caption) .disabled(!appState.alertSoundsEnabled) @@ -1290,12 +1322,58 @@ struct GeneralSettingsView: View { .transition(.opacity) } } + + Divider() + + VStack(spacing: 8) { + soundPickerRow("Recording start", selection: $appState.startSoundName) + soundPickerRow("Recording stop", selection: $appState.stopSoundName) + soundPickerRow("Error", selection: $appState.errorSoundName) + } + .disabled(!appState.alertSoundsEnabled) + .opacity(appState.alertSoundsEnabled ? 1 : 0.5) } .onChange(of: appState.alertSoundsEnabled) { enabled in if !enabled { showMutedHint = false } } } + /// The stock macOS alert sounds in /System/Library/Sounds, all loadable + /// by name with NSSound(named:). + private static let systemSoundNames = [ + "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", + "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink", + ] + + private func soundPickerRow(_ label: String, selection: Binding) -> some View { + // Include an off-catalog value (e.g. set via `defaults write`) so the + // picker shows it instead of rendering blank. + var options = Self.systemSoundNames + if !options.contains(selection.wrappedValue) { + options.append(selection.wrappedValue) + } + return HStack(spacing: 8) { + Text(label) + .font(.caption) + Spacer() + Picker("", selection: selection) { + ForEach(options, id: \.self) { name in + Text(name).tag(name) + } + } + .labelsHidden() + .frame(width: 130) + Button { + appState.playAlertSound(named: selection.wrappedValue) + } label: { + Image(systemName: "play.circle") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Preview this sound") + } + } + // MARK: Custom Vocabulary private var vocabularySection: some View { @@ -1315,9 +1393,12 @@ struct GeneralSettingsView: View { appState.customVocabulary = newValue.trimmingCharacters(in: .whitespacesAndNewlines) } - Text("Separate entries with commas, new lines, or semicolons.") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text("Separate entries with commas, new lines, or semicolons.") + Text("Teach specific mishearings with an arrow: \"cloud code -> Claude Code\". Separate multiple heard forms with \"|\": \"cloud code | clod code -> Claude Code\".") + } + .font(.caption) + .foregroundStyle(.secondary) } } From 08506c97c8b10fb44f5ac92957973830a8593b49 Mon Sep 17 00:00:00 2001 From: Spencer Hedges Date: Sun, 5 Jul 2026 12:45:56 -0400 Subject: [PATCH 4/9] Prewarm API connections while recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transcription uploads deliberately use a fresh URLSession per upload, which put a full DNS + TCP + TLS handshake on the critical stop-to-paste path of every dictation. Now a one-shot session is created and its connection opened when recording starts, and the next upload to the same host consumes it — still one session per upload, so the connection-poisoning defense is unchanged. The shared data session is warmed the same way for post-processing and context requests. --- Sources/AppState.swift | 19 +++++++++ Sources/LLMAPITransport.swift | 60 ++++++++++++++++++++++++++++- Sources/PostProcessingService.swift | 25 +++++++++++- Sources/TranscriptionService.swift | 14 ++++++- 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/Sources/AppState.swift b/Sources/AppState.swift index 6f9688ca..f625a993 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -2160,6 +2160,24 @@ final class AppState: ObservableObject, @unchecked Sendable { automaticTerminationDisabled = false } + /// Opens connections to the transcription and post-processing hosts + /// while the user is speaking, so the stop-to-paste path starts on warm + /// TLS connections instead of paying handshake latency after release. + private func prewarmAPIConnections() { + if let url = URL(string: resolvedTranscriptionBaseURL), url.host != nil { + LLMAPITransport.prewarmUploadConnection( + to: url, + expectedRequestTimeout: TranscriptionService.configuredTimeoutSeconds + ) + } + if let url = URL(string: apiBaseURL), url.host != nil { + LLMAPITransport.prewarmSharedConnection( + to: url, + expectedRequestTimeout: PostProcessingService.configuredTimeoutSeconds + ) + } + } + private func beginRecording(triggerMode: RecordingTriggerMode) { os_log(.info, log: recordingLog, "beginRecording() entered") beginCriticalDictationActivity() @@ -2221,6 +2239,7 @@ final class AppState: ObservableObject, @unchecked Sendable { } startRealtimeStreamingIfEnabled() + prewarmAPIConnections() // Start engine on background thread so UI isn't blocked DispatchQueue.global(qos: .userInitiated).async { [weak self] in diff --git a/Sources/LLMAPITransport.swift b/Sources/LLMAPITransport.swift index 698010c9..2947c1b6 100644 --- a/Sources/LLMAPITransport.swift +++ b/Sources/LLMAPITransport.swift @@ -40,6 +40,59 @@ enum LLMAPITransport { return session } + // MARK: - Connection Pre-warming + + /// One-shot session opened while the user is still speaking so the + /// transcription upload does not pay DNS + TCP + TLS setup on the + /// critical stop-to-paste path. Guarded by sessionsLock. + private static var prewarmedUploadSession: (session: URLSession, host: String, resourceTimeout: TimeInterval)? + + /// Opens a connection to `baseURL`'s host on a fresh session and holds + /// that session for the next upload to the same host. Safe to call on + /// every recording start; failures are ignored — the response is + /// irrelevant (401/404 are fine), only the handshake matters. + static func prewarmUploadConnection(to baseURL: URL, expectedRequestTimeout: TimeInterval) { + let timeout = max(expectedRequestTimeout, minimumResourceTimeout) + let session = makeEphemeralSession(resourceTimeout: timeout) + + sessionsLock.lock() + let previous = prewarmedUploadSession?.session + prewarmedUploadSession = (session, baseURL.host ?? "", timeout) + sessionsLock.unlock() + previous?.finishTasksAndInvalidate() + + var request = URLRequest(url: baseURL) + request.httpMethod = "HEAD" + request.timeoutInterval = 10 + Task { + _ = try? await session.data(for: request) + } + } + + /// Warms the shared data session for `baseURL`'s host so the first + /// post-processing call after idle reuses an open connection. The + /// timeout must match the real requests' so the same session is used. + static func prewarmSharedConnection(to baseURL: URL, expectedRequestTimeout: TimeInterval) { + var request = URLRequest(url: baseURL) + request.httpMethod = "HEAD" + request.timeoutInterval = expectedRequestTimeout + Task { + _ = try? await data(for: request) + } + } + + private static func takePrewarmedUploadSession(for request: URLRequest) -> URLSession? { + sessionsLock.lock() + defer { sessionsLock.unlock() } + guard let candidate = prewarmedUploadSession, + candidate.host == request.url?.host, + candidate.resourceTimeout == resourceTimeout(for: request) else { + return nil + } + prewarmedUploadSession = nil + return candidate.session + } + static func data( for request: URLRequest ) async throws -> (Data, URLResponse) { @@ -50,9 +103,12 @@ enum LLMAPITransport { for request: URLRequest, from bodyData: Data ) async throws -> (Data, URLResponse) { - // Use a fresh session for each upload so a bad reused connection cannot + // Prefer the session prewarmed at recording start (skips the TLS + // handshake); otherwise use a fresh session. Either way the session + // serves exactly one upload, so a bad reused connection cannot // poison subsequent transcription uploads. - let session = makeEphemeralSession(resourceTimeout: resourceTimeout(for: request)) + let session = takePrewarmedUploadSession(for: request) + ?? makeEphemeralSession(resourceTimeout: resourceTimeout(for: request)) defer { session.finishTasksAndInvalidate() } return try await session.upload(for: request, from: bodyData) } diff --git a/Sources/PostProcessingService.swift b/Sources/PostProcessingService.swift index b236b338..e94e6999 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -1,4 +1,7 @@ import Foundation +import os.log + +private let postProcessingLog = OSLog(subsystem: "com.zachlatta.freeflow", category: "PostProcessing") enum PostProcessingError: LocalizedError { case requestFailed(Int, String) @@ -136,11 +139,15 @@ Behavior: private let defaultFallbackModel = "meta-llama/llama-4-scout-17b-16e-instruct" private let defaultModelReasoningEffort = "low" private let postProcessingMaxCompletionTokens = 4096 - private var postProcessingTimeoutSeconds: TimeInterval { + static var configuredTimeoutSeconds: TimeInterval { let override = UserDefaults.standard.double(forKey: "post_processing_timeout_seconds") return override > 0 ? override : 20 } + private var postProcessingTimeoutSeconds: TimeInterval { + Self.configuredTimeoutSeconds + } + init( apiKey: String, baseURL: String = "https://api.groq.com/openai/v1", @@ -450,7 +457,15 @@ Model: \(model) request.httpBody = try JSONSerialization.data(withJSONObject: payload, options: []) + let t0 = CFAbsoluteTimeGetCurrent() let (data, response) = try await LLMAPITransport.data(for: request) + os_log( + .info, + log: postProcessingLog, + "LLM request completed in %.0fms (model=%{public}@)", + (CFAbsoluteTimeGetCurrent() - t0) * 1000, + model + ) guard let httpResponse = response as? HTTPURLResponse else { throw PostProcessingError.invalidResponse("No HTTP response") } @@ -572,7 +587,15 @@ Model: \(model) request.httpBody = try JSONSerialization.data(withJSONObject: payload, options: []) + let t0 = CFAbsoluteTimeGetCurrent() let (data, response) = try await LLMAPITransport.data(for: request) + os_log( + .info, + log: postProcessingLog, + "LLM request completed in %.0fms (model=%{public}@)", + (CFAbsoluteTimeGetCurrent() - t0) * 1000, + model + ) guard let httpResponse = response as? HTTPURLResponse else { throw PostProcessingError.invalidResponse("No HTTP response") } diff --git a/Sources/TranscriptionService.swift b/Sources/TranscriptionService.swift index 94c2b43a..72e064a1 100644 --- a/Sources/TranscriptionService.swift +++ b/Sources/TranscriptionService.swift @@ -9,11 +9,15 @@ class TranscriptionService { private let transcriptionModel: String private let language: String? private let transcriptionResponseFormat = "verbose_json" - private var transcriptionTimeoutSeconds: TimeInterval { + static var configuredTimeoutSeconds: TimeInterval { let override = UserDefaults.standard.double(forKey: "transcription_timeout_seconds") return override > 0 ? override : 20 } + private var transcriptionTimeoutSeconds: TimeInterval { + Self.configuredTimeoutSeconds + } + init( apiKey: String, baseURL: String = "https://api.groq.com/openai/v1", @@ -119,7 +123,15 @@ class TranscriptionService { ) do { + let t0 = CFAbsoluteTimeGetCurrent() let (data, response) = try await LLMAPITransport.upload(for: request, from: body) + os_log( + .info, + log: transcriptionLog, + "upload+transcription completed in %.0fms (%lld bytes)", + (CFAbsoluteTimeGetCurrent() - t0) * 1000, + Int64(body.count) + ) return try validateTranscriptionResponse(data: data, response: response, fileURL: fileURL) } catch { let nsError = error as NSError From 462f7413838d511c234bf967247382056fb5e811 Mon Sep 17 00:00:00 2001 From: Spencer Hedges Date: Sun, 5 Jul 2026 12:45:56 -0400 Subject: [PATCH 5/9] Start transcription before capture session teardown AVCaptureSession.stopRunning() can take 100-300ms and ran before the stop completion fired, delaying the transcription upload. The audio cut-off point (sample buffer delegate removal) and the queued-tail drain are unchanged; only the slow session teardown now happens after the completion. --- Sources/AudioRecorder.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/AudioRecorder.swift b/Sources/AudioRecorder.swift index ec8632f6..cdcef4d6 100644 --- a/Sources/AudioRecorder.swift +++ b/Sources/AudioRecorder.swift @@ -680,7 +680,12 @@ final class AudioRecorder: NSObject, ObservableObject, AVCaptureAudioDataOutputS sessionQueue.async { self.cancelWatchdog() - self.teardownSessionLocked() + // Removing the delegate is the audio cut-off point (same as the + // full teardown used to be); the queued tail buffers are then + // drained into the file. The slow AVCaptureSession.stopRunning() + // is deferred until after the completion fires, so transcription + // starts without waiting on capture teardown. + self.audioDataOutput?.setSampleBufferDelegate(nil, queue: nil) let outputURL = self.finishAudioFileLocked(discard: false) self._recording.withLock { $0 = false } self.liveLevelNormalizerLock.withLock { $0.reset() } @@ -689,6 +694,7 @@ final class AudioRecorder: NSObject, ObservableObject, AVCaptureAudioDataOutputS self.audioLevel = 0.0 completion(outputURL) } + self.teardownSessionLocked() } } From 848b67c7ca9509dd884064baf8856692c2dc7af8 Mon Sep 17 00:00:00 2001 From: Spencer Hedges Date: Sun, 5 Jul 2026 12:45:56 -0400 Subject: [PATCH 6/9] Log transcription upload and LLM request durations --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ce087c..f8cacfb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ This project uses semantic versioning for public releases. Use `MAJOR.MINOR.PATC ## [Unreleased] +### Improved + +- Faster stop-to-paste latency: API connections to the transcription and post-processing hosts are now opened while you are still speaking, so the upload no longer pays DNS + TLS handshake time after you release the shortcut. +- Faster stop-to-paste latency: the transcription upload now starts immediately when recording stops, instead of waiting for the audio capture session to finish tearing down. +- Transcription uploads and post-processing requests now log their durations, making pipeline latency visible in Console. + ### Added - Custom vocabulary entries can now map specific mishearings to the intended term with an arrow (`cloud code -> Claude Code`), and multiple heard forms can share one correction (`cloud code | clod code -> Claude Code`). Plain entries behave exactly as before. From 9bb20c765e1a7bdfd2e4c8ff5dccd0212d1b3d61 Mon Sep 17 00:00:00 2001 From: Spencer Hedges Date: Sun, 5 Jul 2026 18:43:30 -0400 Subject: [PATCH 7/9] Activate lazy Chromium/Electron accessibility trees for Edit Mode Chromium-based apps expose no AXSelectedText until a client opts in, so Edit Mode silently fell back to dictation and overwrote selections in VS Code, Gmail-in-Chrome, and other Electron apps. Set Electron's AXManualAccessibility on the frontmost app before reading the selection (harmless where unsupported), plus Chromium's AXEnhancedUserInterface for known browsers only, since that attribute can interact badly with window-manager utilities. The first read after activation gets one 100ms retry because the tree builds asynchronously. Fixes #237 --- CHANGELOG.md | 1 + Sources/AppContextService.swift | 63 ++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8cacfb5..44a7f697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ This project uses semantic versioning for public releases. Use `MAJOR.MINOR.PATC ### Fixed +- Fixed Edit Mode silently falling back to dictation (overwriting the selection) in VS Code, Gmail-in-Chrome, and other Electron/Chromium apps. Those apps build their accessibility tree lazily, so FreeFlow now asks them to activate it before reading the selection. - Fixed the configured transcription, post-processing, and context timeouts being silently capped at 30 seconds by a session-level resource timeout, which broke long transfers to slow local models. ## [1.1.0] - 2026-06-03 diff --git a/Sources/AppContextService.swift b/Sources/AppContextService.swift index 17832dbf..62aa1fe6 100644 --- a/Sources/AppContextService.swift +++ b/Sources/AppContextService.swift @@ -82,14 +82,70 @@ Return only two sentences, no labels, no markdown, no extra commentary. } let appElement = AXUIElementCreateApplication(frontmostApp.processIdentifier) + let firstActivation = activateAccessibilityTreeIfNeeded( + appElement: appElement, + processIdentifier: frontmostApp.processIdentifier, + bundleIdentifier: frontmostApp.bundleIdentifier + ) + var selectedText = rawSelectedText(from: appElement) + if selectedText == nil && firstActivation { + // The app builds its accessibility tree asynchronously after + // activation; give it one brief chance on the first read so + // Edit Mode works on the very first dictation too. + usleep(100_000) + selectedText = rawSelectedText(from: appElement) + } return AppSelectionSnapshot( appName: frontmostApp.localizedName, bundleIdentifier: frontmostApp.bundleIdentifier, windowTitle: focusedWindowTitle(from: appElement) ?? frontmostApp.localizedName, - selectedText: rawSelectedText(from: appElement) + selectedText: selectedText ) } + // MARK: - Chromium/Electron accessibility activation + + /// PIDs whose lazy accessibility trees we have already asked to + /// activate. Guarded by activatedAccessibilityPIDsLock. + private var activatedAccessibilityPIDs = Set() + private let activatedAccessibilityPIDsLock = NSLock() + + private static let chromiumBrowserBundleIDPrefixes = [ + "com.google.Chrome", + "com.microsoft.edgemac", + "com.brave.Browser", + "com.vivaldi.Vivaldi", + "org.chromium.Chromium", + "company.thebrowser.Browser", + ] + + /// Chromium-based apps build their accessibility tree lazily: until a + /// client opts in, AXSelectedText reads fail, which made Edit Mode fall + /// back to dictation and overwrite selections in VS Code, Gmail-in-Chrome, + /// and other Electron apps (issue #237). AXManualAccessibility is + /// Electron's opt-in switch (unknown-attribute errors are harmless + /// elsewhere); AXEnhancedUserInterface is Chromium's, set only for known + /// browsers because it can interact badly with window-manager utilities. + /// Returns true the first time activation is attempted for this process. + @discardableResult + private func activateAccessibilityTreeIfNeeded( + appElement: AXUIElement, + processIdentifier: pid_t, + bundleIdentifier: String? + ) -> Bool { + activatedAccessibilityPIDsLock.lock() + let firstActivation = activatedAccessibilityPIDs.insert(processIdentifier).inserted + activatedAccessibilityPIDsLock.unlock() + guard firstActivation else { return false } + + AXUIElementSetAttributeValue(appElement, "AXManualAccessibility" as CFString, kCFBooleanTrue) + if let bundleIdentifier, + Self.chromiumBrowserBundleIDPrefixes.contains(where: bundleIdentifier.hasPrefix) { + AXUIElementSetAttributeValue(appElement, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) + } + return true + } + func collectContext() async -> AppContext { let contextSystemPrompt = resolveContextPrompt() @@ -111,6 +167,11 @@ Return only two sentences, no labels, no markdown, no extra commentary. let appName = frontmostApp.localizedName let bundleIdentifier = frontmostApp.bundleIdentifier let appElement = AXUIElementCreateApplication(frontmostApp.processIdentifier) + activateAccessibilityTreeIfNeeded( + appElement: appElement, + processIdentifier: frontmostApp.processIdentifier, + bundleIdentifier: bundleIdentifier + ) let windowTitle = focusedWindowTitle(from: appElement) ?? appName let selectedText = selectedText(from: appElement) From 500da9a46c8e7655dbd0aefe554d13805d8ee2cd Mon Sep 17 00:00:00 2001 From: Spencer Hedges Date: Sun, 5 Jul 2026 18:49:35 -0400 Subject: [PATCH 8/9] Make dictation aware of the text surrounding the cursor Spacing, capitalization, and punctuation were context-blind: dictating into the middle of a sentence produced a leading capital, a trailing period, and jammed against the existing text. Now the focused element's text around the insertion point (200 chars before, 80 after, read via AXStringForRange with an AXValue-slice fallback; secure fields are never read) is captured with the app context and: - flows into the post-processing prompt with rules to continue the sentence flow at the insertion point - drives a deterministic separating space when pasting directly after a word character or closing punctuation (skipped in Edit Mode) - suppresses the auto trailing space when the text after the cursor already provides separation Fixes #200 --- CHANGELOG.md | 1 + README.md | 2 +- Sources/AppContextService.swift | 123 +++++++++++++++++++++++++++- Sources/AppState.swift | 42 +++++++++- Sources/PostProcessingService.swift | 4 +- 5 files changed, 164 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44a7f697..52681936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ This project uses semantic versioning for public releases. Use `MAJOR.MINOR.PATC ### Added +- Dictation is now aware of the text surrounding the cursor: cleanup matches the sentence flow when inserting mid-sentence (no stray leading capitals or trailing periods), a separating space is added automatically when inserting directly after a word, and the trailing space after sentence punctuation is skipped when the following text already provides one. - Custom vocabulary entries can now map specific mishearings to the intended term with an arrow (`cloud code -> Claude Code`), and multiple heard forms can share one correction (`cloud code | clod code -> Claude Code`). Plain entries behave exactly as before. - The recording start, stop, and error feedback sounds are now configurable in Settings, with a picker and preview button for each event across the full set of built-in macOS alert sounds. diff --git a/README.md b/README.md index 65b25e40..31847093 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ FreeFlow is a free Mac dictation app inspired by [Wispr Flow](https://wisprflow. ## Features - **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. +- **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. It also reads the text around your cursor, so dictating into the middle of a sentence continues the sentence naturally with correct capitalization, punctuation, and spacing. - **Custom vocabulary:** Add names, jargon, and project-specific words that FreeFlow should preserve during cleanup. Entries can also map specific mishearings to the intended term with an arrow — `cloud code -> Claude Code` — and multiple heard forms can share one correction: `cloud code | clod code -> Claude Code`. - **OpenAI-compatible providers:** Use Groq by default, or configure a custom model and API URL in settings. diff --git a/Sources/AppContextService.swift b/Sources/AppContextService.swift index 62aa1fe6..43103d92 100644 --- a/Sources/AppContextService.swift +++ b/Sources/AppContextService.swift @@ -20,9 +20,21 @@ struct AppContext { let screenshotDataURL: String? let screenshotMimeType: String? let screenshotError: String? + /// Text immediately surrounding the insertion point in the focused + /// element, so cleanup can match capitalization, punctuation, and + /// sentence flow when dictating into the middle of existing text. + var textBeforeCursor: String? = nil + var textAfterCursor: String? = nil var contextSummary: String { - currentActivity + var summary = currentActivity + if let textBeforeCursor, !textBeforeCursor.isEmpty { + summary += "\nText immediately before the cursor: \"\(textBeforeCursor)\"" + } + if let textAfterCursor, !textAfterCursor.isEmpty { + summary += "\nText immediately after the cursor: \"\(textAfterCursor)\"" + } + return summary } } @@ -175,6 +187,7 @@ Return only two sentences, no labels, no markdown, no extra commentary. let windowTitle = focusedWindowTitle(from: appElement) ?? appName let selectedText = selectedText(from: appElement) + let cursorContext = cursorTextContext(from: appElement) let screenshot = captureActiveWindowScreenshot( processIdentifier: frontmostApp.processIdentifier, appElement: appElement, @@ -224,7 +237,9 @@ Return only two sentences, no labels, no markdown, no extra commentary. contextPrompt: contextPrompt, screenshotDataURL: screenshot.dataURL, screenshotMimeType: screenshot.mimeType, - screenshotError: screenshot.error + screenshotError: screenshot.error, + textBeforeCursor: cursorContext?.before, + textAfterCursor: cursorContext?.after ) } @@ -387,6 +402,110 @@ Selected text: \(selectedText ?? "None") return nil } + // MARK: - Cursor text context (issue #200) + + struct CursorTextContext { + let before: String + let after: String + } + + private static let cursorContextBeforeLimit = 200 + private static let cursorContextAfterLimit = 80 + /// Skip the whole-value fallback for huge documents; the parameterized + /// range read does not have this problem. + private static let cursorContextMaxFallbackLength = 1_000_000 + + /// Reads the text immediately surrounding the insertion point of the + /// focused element. Returns nil when the element does not expose a + /// selection range (or is a secure field, which is never read). + func cursorTextContext(from appElement: AXUIElement) -> CursorTextContext? { + guard let focused = accessibilityElement(from: appElement, attribute: kAXFocusedUIElementAttribute as CFString) else { + return nil + } + if let role = accessibilityRawString(from: focused, attribute: kAXRoleAttribute as CFString), + role == "AXSecureTextField" { + return nil + } + guard let selectedRange = accessibilityRange(from: focused, attribute: kAXSelectedTextRangeAttribute as CFString), + selectedRange.location != kCFNotFound, + selectedRange.location >= 0 else { + return nil + } + + let caret = selectedRange.location + let beforeStart = max(0, caret - Self.cursorContextBeforeLimit) + let beforeRange = CFRange(location: beforeStart, length: caret - beforeStart) + + let afterStart = caret + selectedRange.length + var afterLength = Self.cursorContextAfterLimit + if let totalLength = accessibilityInt(from: focused, attribute: "AXNumberOfCharacters" as CFString) { + afterLength = max(0, min(afterLength, totalLength - afterStart)) + } + let afterRange = CFRange(location: afterStart, length: afterLength) + + let before = accessibilityString(from: focused, range: beforeRange) + ?? fallbackSlice(from: focused, range: beforeRange) + let after = accessibilityString(from: focused, range: afterRange) + ?? fallbackSlice(from: focused, range: afterRange) + + guard before != nil || after != nil else { return nil } + return CursorTextContext(before: before ?? "", after: after ?? "") + } + + private func accessibilityString(from element: AXUIElement, range: CFRange) -> String? { + guard range.length > 0 else { return "" } + var mutableRange = range + guard let rangeValue = AXValueCreate(.cfRange, &mutableRange) else { return nil } + var value: CFTypeRef? + let result = AXUIElementCopyParameterizedAttributeValue( + element, + kAXStringForRangeParameterizedAttribute as CFString, + rangeValue, + &value + ) + guard result == .success, let stringValue = value as? String else { return nil } + return stringValue + } + + /// Some elements do not implement AXStringForRange; slice the full value + /// instead, using UTF-16 offsets to match accessibility range semantics. + private func fallbackSlice(from element: AXUIElement, range: CFRange) -> String? { + guard range.length > 0 else { return "" } + guard let fullValue = accessibilityRawString(from: element, attribute: kAXValueAttribute as CFString), + fullValue.utf16.count <= Self.cursorContextMaxFallbackLength else { + return nil + } + let utf16 = fullValue.utf16 + guard range.location <= utf16.count else { return nil } + let start = utf16.index(utf16.startIndex, offsetBy: range.location) + let end = utf16.index(start, offsetBy: min(range.length, utf16.count - range.location)) + return String(fullValue[start.. CFRange? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success, + let rawValue = value, + CFGetTypeID(rawValue) == AXValueGetTypeID() else { + return nil + } + let axValue = unsafeBitCast(rawValue, to: AXValue.self) + var range = CFRange() + guard AXValueGetType(axValue) == .cfRange, AXValueGetValue(axValue, .cfRange, &range) else { + return nil + } + return range + } + + private func accessibilityInt(from element: AXUIElement, attribute: CFString) -> Int? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute, &value) == .success, + let number = value as? NSNumber else { + return nil + } + return number.intValue + } + private func selectedText(from appElement: AXUIElement) -> String? { if let focusedElement = accessibilityElement(from: appElement, attribute: kAXFocusedUIElementAttribute as CFString), let selectedText = accessibilityString(from: focusedElement, attribute: kAXSelectedTextAttribute as CFString) { diff --git a/Sources/AppState.swift b/Sources/AppState.swift index f625a993..1425ff43 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -2778,7 +2778,15 @@ final class AppState: ObservableObject, @unchecked Sendable { } } - let pendingClipboardRestore = self.writeTranscriptToPasteboard(trimmedFinalTranscript) + let pasteText = Self.applyingSmartLeadingSpace( + to: trimmedFinalTranscript, + textBeforeCursor: appContext.textBeforeCursor, + isCommandMode: sessionIntent.isCommandMode + ) + let pendingClipboardRestore = self.writeTranscriptToPasteboard( + pasteText, + textAfterCursor: appContext.textAfterCursor + ) self.pasteAtCursorWhenShortcutReleased { if shouldPressEnterAfterPaste { self.pressEnterAfterPaste { @@ -3250,19 +3258,45 @@ final class AppState: ObservableObject, @unchecked Sendable { keyUp?.post(tap: .cgSessionEventTap) } + /// Prepends a space when the dictation is inserted directly after a + /// word character or closing punctuation, so mid-text insertions do not + /// jam against the existing text (issue #200). Skipped for Edit Mode, + /// where the pasted text replaces the selection in place. + static func applyingSmartLeadingSpace( + to transcript: String, + textBeforeCursor: String?, + isCommandMode: Bool + ) -> String { + guard !isCommandMode, + let before = textBeforeCursor, + let lastChar = before.last, + let firstChar = transcript.first else { + return transcript + } + guard !lastChar.isWhitespace else { return transcript } + let joinable = lastChar.isLetter || lastChar.isNumber || ".!?,:;)]}\"'".contains(lastChar) + guard joinable, firstChar.isLetter || firstChar.isNumber else { return transcript } + return " " + transcript + } + /// 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. /// - Parameter transcript: The text to be pasted. /// - Returns: A `PendingClipboardRestore` object if clipboard preservation is enabled, otherwise nil. - private func writeTranscriptToPasteboard(_ transcript: String) -> PendingClipboardRestore? { + private func writeTranscriptToPasteboard( + _ transcript: String, + textAfterCursor: String? = nil + ) -> PendingClipboardRestore? { let pasteboard = NSPasteboard.general let snapshot = preserveClipboard ? PreservedPasteboardSnapshot(pasteboard: pasteboard) : nil // Append a space when ending with sentence-ending punctuation so the - // next dictation does not jam against the prior period. + // next dictation does not jam against the prior period — unless the + // text already at the cursor provides that separation. let textToWrite: String - if let last = transcript.last, ".!?".contains(last) { + if let last = transcript.last, ".!?".contains(last), + !(textAfterCursor?.first.map { $0.isWhitespace || ".,;:!?)".contains($0) } ?? false) { textToWrite = transcript + " " } else { textToWrite = transcript diff --git a/Sources/PostProcessingService.swift b/Sources/PostProcessingService.swift index e94e6999..aa413157 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -56,6 +56,8 @@ Core behavior: - Preserve mixed-language text exactly as mixed. - Preserve commands, file paths, flags, identifiers, acronyms, and vocabulary terms exactly. - Use context only as a formatting hint and spelling reference for words already spoken. +- If the context includes "Text immediately before the cursor", the cleaned text will be inserted at exactly that position. Match the sentence flow: when the preceding text ends mid-sentence, continue in lowercase without a leading capital, and do not repeat words that already appear before the cursor. +- If the context includes "Text immediately after the cursor" and it continues the same sentence, do not end the cleaned text with sentence-ending punctuation. - If the context clearly shows email recipients or participants, use those visible names as a strong spelling reference for close phonetic or near-miss versions of names that were actually spoken. - In email greetings or body text, correct a near-match like "Aisha" to the visible recipient spelling "Aysha" when it is clearly the same intended person. - Do not introduce a recipient or participant name that was not spoken at all. @@ -108,7 +110,7 @@ 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" static let commandModeSystemPrompt = """ You transform highlighted text according to a spoken editing command. From 6e0bd948033e696cdd07041cc833693e7af3dcc9 Mon Sep 17 00:00:00 2001 From: Spencer Hedges Date: Sun, 5 Jul 2026 19:08:24 -0400 Subject: [PATCH 9/9] Add Prompt Mode: condense dictation into a tight prompt Off (default) / Always / Only in AI apps. Auto mode detects Claude, ChatGPT, Cursor, and similar tools from the frontmost app's bundle ID and window title. Condensation is a section appended to the cleanup system prompt in the same LLM pass, so it adds no latency, and the instruction-execution guard still applies. Edit Mode is unaffected. Closes #196 --- CHANGELOG.md | 1 + README.md | 1 + Sources/AppState.swift | 59 ++++++++++++++++++++++++++++- Sources/PostProcessingService.swift | 32 +++++++++++++--- Sources/SettingsView.swift | 33 ++++++++++++++++ 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52681936..e9e323fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ This project uses semantic versioning for public releases. Use `MAJOR.MINOR.PATC ### 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. - Dictation is now aware of the text surrounding the cursor: cleanup matches the sentence flow when inserting mid-sentence (no stray leading capitals or trailing periods), a separating space is added automatically when inserting directly after a word, and the trailing space after sentence punctuation is skipped when the following text already provides one. - Custom vocabulary entries can now map specific mishearings to the intended term with an arrow (`cloud code -> Claude Code`), and multiple heard forms can share one correction (`cloud code | clod code -> Claude Code`). Plain entries behave exactly as before. - The recording start, stop, and error feedback sounds are now configurable in Settings, with a picker and preview button for each event across the full set of built-in macOS alert sounds. diff --git a/README.md b/README.md index 31847093..8615a29c 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. It also reads the text around your cursor, so dictating into the middle of a sentence continues the sentence naturally with correct capitalization, punctuation, and spacing. - **Custom vocabulary:** Add names, jargon, and project-specific words that FreeFlow should preserve during cleanup. Entries can also map specific mishearings to the intended term with an arrow — `cloud code -> Claude Code` — and multiple heard forms can share one correction: `cloud code | clod code -> Claude Code`. +- **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 1425ff43..36723a0e 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 startSoundNameStorageKey = "start_sound_name" private let stopSoundNameStorageKey = "stop_sound_name" private let errorSoundNameStorageKey = "error_sound_name" @@ -532,6 +544,14 @@ 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) + } + } + @Published var startSoundName: String { didSet { UserDefaults.standard.set(startSoundName, forKey: startSoundNameStorageKey) @@ -713,6 +733,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 startSoundName = UserDefaults.standard.string(forKey: startSoundNameStorageKey) ?? "Tink" let stopSoundName = UserDefaults.standard.string(forKey: stopSoundNameStorageKey) ?? "Pop" let errorSoundName = UserDefaults.standard.string(forKey: errorSoundNameStorageKey) ?? "Basso" @@ -783,6 +806,7 @@ final class AppState: ObservableObject, @unchecked Sendable { self.isPressEnterVoiceCommandEnabled = isPressEnterVoiceCommandEnabled self.alertSoundsEnabled = alertSoundsEnabled self.soundVolume = soundVolume + self.promptMode = promptMode self.startSoundName = startSoundName self.stopSoundName = stopSoundName self.errorSoundName = errorSoundName @@ -2502,6 +2526,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, @@ -2544,7 +2600,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 aa413157..71060063 100644 --- a/Sources/PostProcessingService.swift +++ b/Sources/PostProcessingService.swift @@ -111,6 +111,17 @@ Output hygiene: - If the transcript is empty or only filler, return exactly: EMPTY """ 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. @@ -169,7 +180,8 @@ Behavior: context: AppContext, customVocabulary: String, customSystemPrompt: String = "", - outputLanguage: String = "" + outputLanguage: String = "", + condenseToPrompt: Bool = false ) async throws -> PostProcessingResult { let vocabularyTerms = mergedVocabularyTerms(rawVocabulary: customVocabulary) @@ -184,7 +196,8 @@ Behavior: contextSummary: context.contextSummary, customVocabulary: vocabularyTerms, customSystemPrompt: customSystemPrompt, - outputLanguage: outputLanguage + outputLanguage: outputLanguage, + condenseToPrompt: condenseToPrompt ) } @@ -261,7 +274,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) @@ -272,7 +286,8 @@ Behavior: model: primaryModel, customVocabulary: customVocabulary, customSystemPrompt: customSystemPrompt, - outputLanguage: outputLanguage + outputLanguage: outputLanguage, + condenseToPrompt: condenseToPrompt ) } catch let error as PostProcessingError { let shouldFallback: Bool @@ -302,7 +317,8 @@ Behavior: model: retryModel, customVocabulary: customVocabulary, customSystemPrompt: customSystemPrompt, - outputLanguage: outputLanguage + outputLanguage: outputLanguage, + condenseToPrompt: condenseToPrompt ) } catch PostProcessingError.suspectedInstructionExecution { return PostProcessingResult( @@ -384,7 +400,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 @@ Behavior: 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 51dd3013..1b51f147 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -695,6 +695,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 } @@ -1374,6 +1377,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 {