Skip to content
Closed
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ 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]

### 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

- 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.

### 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

### Added
Expand Down
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <pkg>`
- 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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ 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.
- **Custom vocabulary:** Add names, jargon, and project-specific words that FreeFlow should preserve during cleanup.
- **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
Expand Down
58 changes: 51 additions & 7 deletions Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct FreeFlowApp: App {
MenuBarLabel()
.environmentObject(appDelegate.appState)
}
.menuBarExtraStyle(.window)
}
}

Expand All @@ -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)
Expand Down
186 changes: 183 additions & 3 deletions Sources/AppContextService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -82,14 +94,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)
}
Comment on lines +103 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find call sites of collectSelectionSnapshot and inspect surrounding dispatch context.
rg -nP -C8 '\bcollectSelectionSnapshot\s*\(' Sources

Repository: 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.swift

Repository: 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/*.swift

Repository: 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.swift

Repository: 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.

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<pid_t>()
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()

Expand All @@ -111,9 +179,15 @@ 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)
let cursorContext = cursorTextContext(from: appElement)
let screenshot = captureActiveWindowScreenshot(
processIdentifier: frontmostApp.processIdentifier,
appElement: appElement,
Expand Down Expand Up @@ -163,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
)
}

Expand Down Expand Up @@ -326,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..<end])
}

private func accessibilityRange(from element: AXUIElement, attribute: CFString) -> 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) {
Expand Down
Loading