Timeout fix, Edit Mode in Electron apps, cursor-aware cleanup, Prompt Mode, vocabulary mappings, custom sounds, faster stop-to-paste#254
Conversation
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 zachlatta#253
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 zachlatta#125
- 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 (zachlatta#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
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.
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.
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 zachlatta#237
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 zachlatta#200
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 zachlatta#196
📝 WalkthroughWalkthroughThis PR adds Prompt Mode (dictation condensation), cursor-aware cleanup and paste spacing, custom vocabulary mishearing mappings, configurable feedback sounds, a redesigned menu bar panel with live waveform icon, connection prewarming and timeout fixes for lower latency, and an Edit Mode accessibility fix for Electron/Chromium apps. ChangesLatency and Timeout Improvements
Edit Mode Selection Fix and Cursor Context
Prompt Mode Feature
Custom Vocabulary Mishearing Mapping
Configurable Feedback Sounds
Menu Bar and Settings UI Redesign
Documentation Updates
Estimated code review effort: 4 (Complex) | ~75 minutes Sequence Diagram(s)sequenceDiagram
participant AppState
participant AudioRecorder
participant TranscriptionService
participant LLMAPITransport
participant PostProcessingService
AppState->>LLMAPITransport: prewarmAPIConnections() at recording start
AppState->>AudioRecorder: stopRecording(completion)
AudioRecorder->>AudioRecorder: detach delegate, drain tail buffers
AudioRecorder-->>AppState: completion(outputURL)
AppState->>TranscriptionService: transcribeAudioWithURLSession
TranscriptionService->>LLMAPITransport: upload(for:from:) using prewarmed session
LLMAPITransport-->>TranscriptionService: response, duration logged
AppState->>PostProcessingService: postProcess(condenseToPrompt)
PostProcessingService->>LLMAPITransport: data(for:) via cached session
LLMAPITransport-->>PostProcessingService: response, duration logged
PostProcessingService-->>AppState: cleaned/condensed text
sequenceDiagram
participant AppState
participant AppContextService
participant AccessibilityTree
AppState->>AppContextService: collectContext()
AppContextService->>AccessibilityTree: activate accessibility tree
AppContextService->>AccessibilityTree: read selectedText
AccessibilityTree-->>AppContextService: empty selection (Electron/Chromium)
AppContextService->>AccessibilityTree: retry selectedText after activation
AccessibilityTree-->>AppContextService: selection returned
AppContextService->>AccessibilityTree: read cursorTextContext (before/after)
AppContextService-->>AppState: AppContext with selection and cursor text
Possibly related PRs
Estimated code review effort: 4 (Complex) | ~75 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
For what it's worth, I've been daily driving this build as my main dictation setup since making these changes — dictating into Claude Code, email, and browser text fields all day. The latency improvements are noticeable, and Prompt Mode + the vocabulary mappings have held up well in real use. Happy to split this into smaller PRs if that's easier to review. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
Sources/SettingsView.swift (2)
1362-1367: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMissing accessibility labels on new hidden-label pickers.
Both the Prompt Mode picker and the three
soundPickerRowpickers use.labelsHidden()without an.accessibilityLabel, unlike the existingoverlayDisplaySectionpicker in this same file which explicitly sets.accessibilityLabel("Show on"). VoiceOver users will hear these controls without a description of what they control.♿ Proposed fix
Picker("", selection: $appState.promptMode) { Text("Off").tag(PromptModeSetting.off) Text("Always").tag(PromptModeSetting.always) Text("Only in AI apps").tag(PromptModeSetting.auto) } .labelsHidden() + .accessibilityLabel("Condense dictation") .pickerStyle(.menu)Picker("", selection: selection) { ForEach(options, id: \.self) { name in Text(name).tag(name) } } .labelsHidden() + .accessibilityLabel(label) .frame(width: 130)Also applies to: 1392-1400
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/SettingsView.swift` around lines 1362 - 1367, The hidden-label pickers in SettingsView are missing accessibility descriptions, so add explicit .accessibilityLabel values to the Prompt Mode picker and each soundPickerRow picker while keeping .labelsHidden(). Use the existing overlayDisplaySection picker’s accessibilityLabel as the pattern, and make sure the labels clearly describe what each Picker controls by updating the relevant Picker views in SettingsView.
351-353: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDuplicate
appVersioncomputed property.This
appVersion(usinginfoDictionary?[...]) duplicatesGeneralSettingsView.appVersion(Line 525-527, usingobject(forInfoDictionaryKey:)) in the same file. Consider extracting a single shared helper to avoid two slightly different implementations of the same lookup drifting apart.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/SettingsView.swift` around lines 351 - 353, The file has two separate `appVersion` computed properties with slightly different Bundle lookup implementations, so consolidate them into one shared source of truth. Update `SettingsView` and `GeneralSettingsView` to use a single helper for version lookup, keeping only one implementation of the `Bundle.main` version access and removing the duplicate property so both views reference the same symbol.Sources/MenuBarView.swift (1)
3-14: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueFragile private-API window matching — consider a maintained library.
Matching windows by
className.contains("MenuBarExtra")depends on undocumented AppKit internals that could change across macOS releases (the comment already acknowledges this). Since.window-styleMenuBarExtrahas no first-party dismissal/introspection API, the community packageMenuBarExtraAccess(introspectMenuBarExtraWindow) exists specifically to solve this more robustly than string-matching a private class name.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/MenuBarView.swift` around lines 3 - 14, The window dismissal logic in dismissMenuBarPanel relies on fragile private AppKit class-name matching via NSApp.windows and window.className.contains("MenuBarExtra"), which can break across macOS releases. Replace this string-based private-API check with the maintained MenuBarExtraAccess approach, using its introspectMenuBarExtraWindow helper to obtain and dismiss the MenuBarExtra panel more robustly while keeping the status-bar window untouched.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sources/AppContextService.swift`:
- Around line 103-109: The first-read retry in collectSelectionSnapshot() is
blocking the main queue because it uses a synchronous sleep during the initial
selectedText fallback. Replace that firstActivation retry with an async delayed
re-read instead of usleep, keeping the logic in
AppContextService.collectSelectionSnapshot() and the rawSelectedText(from:)
fallback but moving the wait off the UI path so the main-queue shortcut start
flow stays responsive.
In `@Sources/MenuBarView.swift`:
- Around line 177-225: The dictateButton action is using a fixed DispatchQueue
delay after NSApp.hide(nil), which can start recording before focus actually
returns to the previous app. Update the start-dictation path in
dictateButton/appState.toggleRecording flow to wait for the next frontmost app
activation (or poll until NSWorkspace.shared.frontmostApplication changes)
before calling toggleRecording(), so prepareRecordingStart() and
collectSelectionSnapshot() see the correct app.
---
Nitpick comments:
In `@Sources/MenuBarView.swift`:
- Around line 3-14: The window dismissal logic in dismissMenuBarPanel relies on
fragile private AppKit class-name matching via NSApp.windows and
window.className.contains("MenuBarExtra"), which can break across macOS
releases. Replace this string-based private-API check with the maintained
MenuBarExtraAccess approach, using its introspectMenuBarExtraWindow helper to
obtain and dismiss the MenuBarExtra panel more robustly while keeping the
status-bar window untouched.
In `@Sources/SettingsView.swift`:
- Around line 1362-1367: The hidden-label pickers in SettingsView are missing
accessibility descriptions, so add explicit .accessibilityLabel values to the
Prompt Mode picker and each soundPickerRow picker while keeping .labelsHidden().
Use the existing overlayDisplaySection picker’s accessibilityLabel as the
pattern, and make sure the labels clearly describe what each Picker controls by
updating the relevant Picker views in SettingsView.
- Around line 351-353: The file has two separate `appVersion` computed
properties with slightly different Bundle lookup implementations, so consolidate
them into one shared source of truth. Update `SettingsView` and
`GeneralSettingsView` to use a single helper for version lookup, keeping only
one implementation of the `Bundle.main` version access and removing the
duplicate property so both views reference the same symbol.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: ae1c5161-4394-4b5a-8cf3-da19e531aec4
📒 Files selected for processing (12)
CHANGELOG.mdCLAUDE.mdREADME.mdSources/App.swiftSources/AppContextService.swiftSources/AppState.swiftSources/AudioRecorder.swiftSources/LLMAPITransport.swiftSources/MenuBarView.swiftSources/PostProcessingService.swiftSources/SettingsView.swiftSources/TranscriptionService.swift
| 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) | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find call sites of collectSelectionSnapshot and inspect surrounding dispatch context.
rg -nP -C8 '\bcollectSelectionSnapshot\s*\(' SourcesRepository: zachlatta/freeflow
Length of output: 5301
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the relevant type/actor annotations and surrounding call paths.
sed -n '1,220p' Sources/AppContextService.swift
printf '\n---- APPSTATE TOP ----\n'
sed -n '1,220p' Sources/AppState.swift
printf '\n---- APPSTATE CALL SITES ----\n'
sed -n '1848,2090p' Sources/AppState.swiftRepository: zachlatta/freeflow
Length of output: 26858
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find where scheduleShortcutStart/startRecording/prepareRecordingStart are invoked
# and whether those call sites are on the main actor / main queue.
rg -n -C6 '\b(scheduleShortcutStart|startRecording|prepareRecordingStart|ensureMicrophoneAccess)\s*\(' Sources
printf '\n---- MAIN-ACTOR / DISPATCH HINTS ----\n'
rg -n -C4 '`@MainActor`|DispatchQueue\.main|MainActor\.run|Task\s*\{' Sources/AppState.swift Sources/*.swiftRepository: zachlatta/freeflow
Length of output: 50375
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the hotkey callback path and thread/queue handoff.
rg -n -C6 '\bonAction\b|handle.*Shortcut|CGEventTap|NSEvent\.addGlobalMonitor|DispatchQueue\.global|DispatchQueue\.main|Task\s*\{' Sources/HotkeyManager.swift Sources/DictationShortcutSessionController.swift Sources/AppState.swift Sources/SetupTestHotkeyHarness.swift
printf '\n---- HOTKEY MANAGER ----\n'
sed -n '1,260p' Sources/HotkeyManager.swift
printf '\n---- SHORTCUT SESSION CONTROLLER ----\n'
sed -n '1,260p' Sources/DictationShortcutSessionController.swiftRepository: zachlatta/freeflow
Length of output: 29897
Move the first-read retry off the main queue collectSelectionSnapshot() is reached from AppState’s main-queue shortcut start path, so the 100 ms usleep blocks the UI on the first dictation into each app. Replace it with an async retry instead.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AppContextService.swift` around lines 103 - 109, The first-read retry
in collectSelectionSnapshot() is blocking the main queue because it uses a
synchronous sleep during the initial selectedText fallback. Replace that
firstActivation retry with an async delayed re-read instead of usleep, keeping
the logic in AppContextService.collectSelectionSnapshot() and the
rawSelectedText(from:) fallback but moving the wait off the UI path so the
main-queue shortcut start flow stays responsive.
| 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) | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
set -euo pipefail
printf '\n== File list ==\n'
git ls-files | rg 'Sources/(MenuBarView|AppContextService)\.swift$'
printf '\n== Outline MenuBarView.swift ==\n'
ast-grep outline Sources/MenuBarView.swift --view expanded || true
printf '\n== Outline AppContextService.swift ==\n'
ast-grep outline Sources/AppContextService.swift --view expanded || true
printf '\n== Relevant matches ==\n'
rg -n 'didActivateApplicationNotification|frontmostApplication|collectSelectionSnapshot|toggleRecording|hide\\(nil\\)|DispatchQueue\\.main\\.asyncAfter|NSWorkspace' Sources -SRepository: zachlatta/freeflow
Length of output: 5315
🏁 Script executed:
set -euo pipefail
printf '\n== MenuBarView around dictateButton ==\n'
sed -n '160,210p' Sources/MenuBarView.swift | cat -n
printf '\n== AppContextService collectSelectionSnapshot ==\n'
sed -n '80,130p' Sources/AppContextService.swift | cat -n
printf '\n== AppState toggleRecording and recording start path ==\n'
sed -n '1778,1888p' Sources/AppState.swift | cat -n
printf '\n== AppState frontmost app checks around context capture ==\n'
sed -n '1990,2095p' Sources/AppState.swift | cat -n
printf '\n== AppState frontmost app near line 3026 ==\n'
sed -n '3008,3045p' Sources/AppState.swift | cat -nRepository: zachlatta/freeflow
Length of output: 18251
🏁 Script executed:
set -euo pipefail
printf '\n== Activation helper in AppContextService.swift ==\n'
rg -n 'activateAccessibilityTreeIfNeeded|didActivateApplicationNotification|frontmostApplication|sleep|usleep|retry' Sources/AppContextService.swift -S
printf '\n== Activation helper body ==\n'
sed -n '120,230p' Sources/AppContextService.swift | cat -n
printf '\n== collectContext / stop-time snapshot flow ==\n'
sed -n '150,220p' Sources/AppState.swift | cat -n
sed -n '2040,2105p' Sources/AppState.swift | cat -nRepository: zachlatta/freeflow
Length of output: 12846
🏁 Script executed:
set -euo pipefail
printf '\n== startRecording definition ==\n'
rg -n '^ private func startRecording|^ func startRecording|startRecording\\(' Sources/AppState.swift -n -S
printf '\n== startRecording body around definition ==\n'
sed -n '1740,1875p' Sources/AppState.swift | cat -n
printf '\n== context capture task entry points ==\n'
rg -n 'contextCaptureTask|collectContext\\(|collectSelectionSnapshot\\(|fallbackContextAtStop\\(' Sources/AppState.swift Sources/AppContextService.swift -SRepository: zachlatta/freeflow
Length of output: 390
🏁 Script executed:
set -euo pipefail
printf '\n== startRecording locations ==\n'
rg -n 'startRecording\(' Sources/AppState.swift -S
printf '\n== startRecording nearby body ==\n'
sed -n '1860,2015p' Sources/AppState.swift | cat -n
printf '\n== context capture task references ==\n'
rg -n 'contextCaptureTask|pendingSelectionSnapshot|collectContext\(' Sources/AppState.swift -SRepository: zachlatta/freeflow
Length of output: 10056
Replace the fixed 250ms sleep with an activation check. appState.toggleRecording() reads NSWorkspace.shared.frontmostApplication immediately via prepareRecordingStart()/collectSelectionSnapshot(), so if NSApp.hide(nil) has not fully handed focus back yet, Edit Mode can capture the wrong app. Wait for the next frontmost-app activation (or poll until it changes) before starting dictation.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/MenuBarView.swift` around lines 177 - 225, The dictateButton action
is using a fixed DispatchQueue delay after NSApp.hide(nil), which can start
recording before focus actually returns to the previous app. Update the
start-dictation path in dictateButton/appState.toggleRecording flow to wait for
the next frontmost app activation (or poll until
NSWorkspace.shared.frontmostApplication changes) before calling
toggleRecording(), so prepareRecordingStart() and collectSelectionSnapshot() see
the correct app.
|
Thanks for offering to split this up. Reviewing commit-by-commit helps, but independent PRs would give us clean review, merge, and revert boundaries. Could you reduce this PR to the timeout fix for #253, then open separate PRs for:
CLAUDE.md should also be a separate documentation-only PR, or omitted from this series. Please move each README/changelog entry into the PR that introduces that behavior. The main concern is the Add configurable feedback sounds, menu bar panel redesign, changelog commit: it currently combines sounds, the menu-bar overhaul, the settings-sidebar redesign, vocabulary documentation, changelog changes, and agent guardrails. Those should not share one review or merge boundary. The existing review findings can then be addressed in their corresponding PRs. This should let us land the small bug fixes quickly while reviewing the larger UI and performance changes independently. |
This PR bundles a set of fixes and features developed while dogfooding FreeFlow daily. Each change is a separate commit with a descriptive message, so it can be reviewed commit-by-commit — and I'm happy to split any subset into standalone PRs if that's easier to review or land.
Fixes
Honor configured timeouts instead of capping transfers at 30s (fixes #253)
LLMAPITransportpinnedtimeoutIntervalForResource = 30at the session level, silently overriding the README-documentedtranscription_timeout_seconds/post_processing_timeout_seconds/context_request_timeout_secondsoverrides — any transfer over 30s was killed regardless of settings, breaking slow local models. Sessions now derive their whole-transfer budget from each request's configured timeout (30s floor), cached per timeout value so TLS connection reuse is preserved. Uploads keep their fresh-session-per-call poisoning defense.Edit Mode no longer silently overwrites selections in Electron/Chromium apps (fixes #237)
Chromium-based apps build their accessibility tree lazily, so
AXSelectedTextreads failed and Edit Mode fell back to dictation — overwriting the user's selection in VS Code, Gmail-in-Chrome, etc. FreeFlow now sets Electron'sAXManualAccessibilityopt-in on the frontmost app before reading the selection (harmless no-op elsewhere), plus Chromium'sAXEnhancedUserInterfacefor known browsers only (scoped narrowly since that attribute can interact badly with window-manager utilities). The first read after activation gets one 100 ms retry because the tree builds asynchronously.Features
Mishearing → correction mappings in custom vocabulary (closes #125,
help wanted)Vocabulary entries can map heard forms 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. Pairs are passed to the post-processing model as an explicit corrections block (with a guard against over-applying); plain entries behave exactly as before. Parser verified against plain entries, single/multi-variant mappings, mixed comma-separated lines,=>, and malformed input.Cursor-aware spacing, capitalization, and punctuation (fixes #200)
Dictating into the middle of a sentence used to produce a stray leading capital, a trailing period, and text jammed against existing words. FreeFlow now reads up to 200 chars before / 80 after the insertion point (via
AXStringForRangewith anAXValue-slice fallback; secure fields are never read) and uses it three ways: the cleanup prompt gets the surrounding text with rules to continue the sentence flow; a separating space is prepended deterministically when pasting directly after a word character or closing punctuation (skipped in Edit Mode); and the auto trailing space after sentence punctuation is suppressed when the following text already provides separation.Prompt Mode (closes #196)
Off (default) / Always / Only in AI apps. Condenses rambling dictation into a tight, intent-preserving prompt before pasting. 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; the instruction-execution guard still applies, and Edit Mode is unaffected.
Configurable feedback sounds (closes #107)
The hardcoded Tink/Pop/Basso start/stop/error sounds are now per-event settings with pickers and preview buttons covering all 14 built-in macOS alert sounds. Defaults match the old behavior.
Performance
Prewarm API connections while recording
Transcription uploads deliberately use a fresh URLSession per upload, which put a full DNS + TCP + TLS handshake (~100–300 ms) on the critical stop-to-paste path of every dictation. A one-shot session is now created and its connection opened when recording starts; the next upload to the same host consumes it — still exactly one session per upload, so the poisoning defense is unchanged. The shared data session is warmed the same way for post-processing/context requests.
Start transcription before capture teardown
AVCaptureSession.stopRunning()can take 100–300 ms and ran before the stop completion fired. The audio cut-off point (sample-buffer delegate removal) and the queued-tail drain are byte-for-byte unchanged; only the slow teardown now happens after the completion, so the upload starts immediately.Latency instrumentation
Transcription uploads and LLM requests now log durations to the existing
com.zachlatta.freeflowos_log subsystem, so pipeline latency is visible in Console.UI
Menu bar panel redesign + live waveform icon
The menu bar extra now uses a window-style panel, and while recording the menu bar icon renders a small live waveform driven by the input level.
Notes
makebuilds throughout; pure logic (vocabulary parser, smart-spacing) covered by standalone test scripts; running as my daily driver since building it.Summary by CodeRabbit
New Features
Bug Fixes