Chameleon — dictation fits where you drop it. (Implementation of advanced adaptive context-aware smart spacing, capitalization, punctuation, and text insertion logic) - #251
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThis PR adds macOS Accessibility-based surrounding-text capture, integrates context-aware formatting and prompt construction into dictation, persists the resulting metadata, and expands Smart Paste diagnostics and history. ChangesContext-aware dictation formatting and persistence
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant AppState
participant AccessibilityWakeManager
participant AccessibilityTextReader
participant AppContextService
participant PostProcessingService
participant ContextualFormattingService
participant PipelineHistoryStore
participant SettingsView
AppState->>AccessibilityWakeManager: wakeUpCurrentApp()
AppState->>AccessibilityTextReader: readSurroundingTextWithSync()
AccessibilityTextReader-->>AppState: SurroundingTextSnapshot
AppState->>AppContextService: collectContext(selectionSnapshot:)
AppContextService-->>AppState: AppContext with surrounding text
AppState->>PostProcessingService: process with bounded context
PostProcessingService->>ContextualFormattingService: format transcript with context
ContextualFormattingService-->>AppState: formattedText and ruleApplied
AppState->>PipelineHistoryStore: persist resolved context and formatted transcript
PipelineHistoryStore-->>SettingsView: stored history metadata
AppState->>AccessibilityWakeManager: sleepCurrentApp()
Possibly related PRs
🚥 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 |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
Sources/AppState.swift (2)
2733-2741: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy liftResolve fallback context before post-processing.
processTranscriptreceivesappContextbefore the start-of-recording fallback is applied, so the LLM can still see blind surrounding text even whenstartSnaplater supplies usable context for formatting/history. Settle the start/JIT context first, then pass that resolved context into post-processing.Also applies to: 2835-2855
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/AppState.swift` around lines 2733 - 2741, Resolve the context passed into processTranscript after the start-of-recording fallback is applied, since appContext is currently forwarded before startSnap/JIT fallback can supply usable surrounding text. Update the call site in AppState around processTranscript so the resolved context is chosen first, then passed into post-processing, and make the same adjustment in the other matching block mentioned by the review.
1952-1970: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winAvoid waking AX before early-return validation.
wakeUpCurrentApp()runs beforeprepareRecordingStartandensureMicrophoneAccess; if either returns false, there is no matching sleep, soAXManualAccessibilitycan stay enabled. Move the wake/start-read after the guards succeed, or explicitly sleep on every early return after waking.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/AppState.swift` around lines 1952 - 1970, The current start sequence in AppState’s recording flow wakes accessibility before the early-return checks, which can leave AX enabled if prepareRecordingStart or ensureMicrophoneAccess fails. Move the AccessibilityWakeManager.shared.wakeUpCurrentApp call, and the related start-read behavior, to after both guards succeed in the recording-start path, or add matching sleep/cleanup on every failure path after waking. Use the surrounding prepareRecordingStart, ensureMicrophoneAccess, beginRecording, and startGentleStartContextRead flow to place the change correctly.
🧹 Nitpick comments (1)
Sources/AppState.swift (1)
2772-2775: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winDon’t await the start read unless it is needed.
Line 2772 waits for
startSurroundingHandleeven when stop-time context already has real surrounding text. For short dictations or a slow target app, this can delay paste by the AX read timeout; only await it when the stop read is blind/unknown.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/AppState.swift` around lines 2772 - 2775, The start-time AX read in the surrounding text flow is being awaited unconditionally even when stop-time context already provides real text. Update the logic around startSurroundingHandle in AppState so the await only happens when it is actually needed, using finalCursorPos, stop-time context, and the existing hasRealText check to decide whether to skip the start read. Keep the change localized to the start/stop surrounding-text handling path and preserve the current fallback behavior when the stop read is blind or unknown.
🤖 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/AccessibilityTextReader.swift`:
- Around line 151-158: The readSurroundingText(sync) and related cursor handling
treat every (0,0) selection as fraudulent, which breaks legitimate native “caret
at start” cases. Update the fraud guard to only apply to web or otherwise
unreliable elements using the existing kind/appKind checks, and for normal
native controls return an empty precedingText, the computed followingText slice,
and cursorPosition .start instead of .unknown.
- Around line 578-589: The index-based fallback in markerReadViaIndex currently
treats the selection start as the boundary for followingText, which can leak
selected text into the “following” context during replacements. Update
markerReadViaIndex to derive both start and end markers from the selection,
compare their AXIndexForTextMarker values to determine the true range, and use
the selection end index as the starting point for the following slice while
keeping the preceding slice anchored at the selection start. Keep the existing
nil/empty checks intact when adjusting the slicing logic.
In `@Sources/AppContextService.swift`:
- Around line 143-146: Validate the selectionSnapshot before using its
selectedText in AppContextService so it is only reused when it still matches the
current frontmost app/window context. In the logic around windowTitle and
selectedText, compare the snapshot’s bundle/window identity against the current
appElement context, and if it does not match, fall back to
rawSelectedText(from:) instead of combining stale snapshot text with a new
window.
In `@Sources/ContextualFormattingService.swift`:
- Around line 117-122: Remove the blind trailing-space insertion in
ContextualFormattingService’s spacing logic: when both precedingText and
followingText are nil, do not append a space after sentence-ending punctuation
in the `spaced` result. Update the conditional around the blind-apps case so
smart paste never adds an ungrounded trailing space at the end of a field, while
keeping the normal context-aware spacing behavior in the rest of the formatter.
- Around line 199-215: In ContextualFormattingService, the punctuation-collision
logic only removes identical duplicates and trailing periods, so mixed terminal
pairs like “?.” can still be emitted. Update the punctuation handling around the
existing P2/P3 rules to detect conflicting terminal punctuation combinations
between the accumulated result and the incoming character f, and normalize them
by keeping the appropriate terminal mark while dropping the redundant one. Keep
the abbreviation safeguard via isLikelyAbbreviation, and preserve the existing
duplicate-mark behavior while extending it to cover all terminal punctuation
pairs.
In `@Sources/PipelineHistoryStore.swift`:
- Around line 306-314: The read path in PipelineHistoryStore is forcing
isBlindApp to false, which breaks the legacy nil fallback used by
PipelineHistoryItem and RunLogEntryView. Update the mapping around
entity.isBlindApp so unset values stay nil instead of being coerced, while still
converting explicit stored values to Bool. Keep the fix localized to the
PipelineHistoryStore decode/build logic that populates isBlindApp from the
persisted entity.
In `@Sources/PostProcessingService.swift`:
- Around line 438-453: The prompt assembly in PostProcessingService is still
using static inline delimiters and quoted interpolation for data fields, which
makes it vulnerable to boundary collisions if user text contains the same
markers or quotes. Update the prompt-building logic around the userMessage
construction to use the same collision-resistant helper for RAW_TRANSCRIPTION,
PRECEDING_TEXT, FOLLOWING_TEXT, VOICE_COMMAND, and SELECTED_TEXT instead of
embedding those values directly. Keep the helper centralized so all prompt
fields are wrapped with unique, generated boundaries and can’t be escaped by app
content.
In `@Sources/SettingsView.swift`:
- Around line 2335-2421: The “Smart Paste Formatting” section in SettingsView’s
PipelineStepView is shown for all history items, even when smart paste doesn’t
apply. Update this block to render only for dictation entries by checking
item.intent before showing the Step 4 content, matching the gating used in
PipelineDebugContentView. Keep the existing transcript/context fields and
ContextDebugLabels usage inside that conditional so non-dictation history items
don’t display misleading nil state.
---
Outside diff comments:
In `@Sources/AppState.swift`:
- Around line 2733-2741: Resolve the context passed into processTranscript after
the start-of-recording fallback is applied, since appContext is currently
forwarded before startSnap/JIT fallback can supply usable surrounding text.
Update the call site in AppState around processTranscript so the resolved
context is chosen first, then passed into post-processing, and make the same
adjustment in the other matching block mentioned by the review.
- Around line 1952-1970: The current start sequence in AppState’s recording flow
wakes accessibility before the early-return checks, which can leave AX enabled
if prepareRecordingStart or ensureMicrophoneAccess fails. Move the
AccessibilityWakeManager.shared.wakeUpCurrentApp call, and the related
start-read behavior, to after both guards succeed in the recording-start path,
or add matching sleep/cleanup on every failure path after waking. Use the
surrounding prepareRecordingStart, ensureMicrophoneAccess, beginRecording, and
startGentleStartContextRead flow to place the change correctly.
---
Nitpick comments:
In `@Sources/AppState.swift`:
- Around line 2772-2775: The start-time AX read in the surrounding text flow is
being awaited unconditionally even when stop-time context already provides real
text. Update the logic around startSurroundingHandle in AppState so the await
only happens when it is actually needed, using finalCursorPos, stop-time
context, and the existing hasRealText check to decide whether to skip the start
read. Keep the change localized to the start/stop surrounding-text handling path
and preserve the current fallback behavior when the stop read is blind or
unknown.
🪄 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: aeebf4ad-d2e0-41e6-ac8f-19a0e171e30f
📒 Files selected for processing (14)
Sources/AccessibilityTextReader.swiftSources/AccessibilityWakeManager.swiftSources/AppContextService.swiftSources/AppState.swiftSources/ContextDebugLabels.swiftSources/ContextReadMetrics.swiftSources/ContextualFormattingService.swiftSources/PipelineDebugContentView.swiftSources/PipelineDebugPanelView.swiftSources/PipelineHistoryItem.swiftSources/PipelineHistoryStore.swiftSources/PostProcessingService.swiftSources/SettingsView.swiftSources/SurroundingTextLimiter.swift
|
@coderabbitai Addressed in 0f19433:
Not changed, by design:
|
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
gh pr diff 251 --repo zachlatta/freeflow > /tmp/pr251.diff || true
wc -l /tmp/pr251.diff
grep -n "markerReadViaIndex" /tmp/pr251.diff | head -20Length of output: 839 🏁 Script executed: #!/bin/bash
set -euo pipefail
git fetch origin pull/251/head:pr251 2>&1 | tail -5
git checkout pr251 2>&1 | tail -5
echo "---markerReadViaIndex---"
sed -n '570,650p' Sources/AccessibilityTextReader.swift
echo "---isBlindApp store---"
grep -n "isBlindApp" Sources/PipelineHistoryStore.swift
echo "---Smart Paste hidden condition---"
grep -n "Smart Paste\|hasText\|readFailed" Sources/SettingsView.swift | head -30
echo "---snapshot bundle validation---"
grep -n "bundleIdentifier" Sources/AppState.swift | head -20
echo "---wake after guards---"
grep -n "wakeUpCurrentApp" Sources/AppState.swiftLength of output: 7539
I didn't find anything to dispute in the design-rationale items — the (0,0)-caret degrade-to-unknown policy, the blind-trailing-space compatibility behavior, preserving user-dictated (๑˃̵ᴗ˂̵)و good work |
…it raw Dictated text now adapts to what is around the cursor — casing, spacing, and punctuation — via a deterministic post-LLM formatting stage fed by a purely observational Accessibility read (TextMarkers-first in web/Electron). Closes zachlatta#200. Modified files: - Sources/AccessibilityTextReader.swift (new) - Sources/AccessibilityWakeManager.swift (new) - Sources/AppContextService.swift - Sources/AppState.swift - Sources/ContextDebugLabels.swift (new) - Sources/ContextReadMetrics.swift (new) - Sources/ContextualFormattingService.swift (new) - Sources/PipelineDebugContentView.swift - Sources/PipelineDebugPanelView.swift - Sources/PipelineHistoryItem.swift - Sources/PipelineHistoryStore.swift - Sources/PostProcessingService.swift - Sources/SettingsView.swift - Sources/SurroundingTextLimiter.swift (new)
- markerReadViaIndex: the following slice now starts at the selection END (the selected text no longer leaks into followingText on replacements). - collectContext: the start-time selection snapshot is used only when it came from the same app (bundle check); otherwise a live read is taken. - PipelineHistoryStore: isBlindApp keeps nil (legacy rows) distinct from false on both write and read, so the history view's legacy fallback works. - Run Log: the Smart Paste step renders only for entries that ran the smart-paste stage (command-mode/legacy rows no longer show nil rows). - startRecording: the Accessibility wake runs only after the start guards succeed, so a failed start can't leave AXManualAccessibility enabled. - Paste path: the start snapshot is awaited only when the stop read came back unknown, so a slow AX read can't delay the paste needlessly. Modified files: - Sources/AccessibilityTextReader.swift - Sources/AppContextService.swift - Sources/AppState.swift - Sources/PipelineHistoryStore.swift - Sources/SettingsView.swift
…OM fields)
Reported on Google's AI-mode search box (Safari): its content is hidden from the
marker system (shadow DOM), so the marker read silently captured a page status
message ("A resposta do Modo IA está pronta") as the text before the cursor —
the model received wrong context and the insertion could glue to the wrong seam.
The read now cross-checks itself: when the focused box exposes its own
trustworthy text (kAXValue + honest caret), a marker read is used only if it
matches that text right next to the cursor (compared ignoring whitespace, since
a faithful marker read differs from the plain value only in how line breaks are
represented — that is exactly why markers stay primary). Anything that doesn't
match — like text from elsewhere on the page — is discarded in favor of the
box's own self-scoped text. The snapshot also carries whether the marker bounds
were genuinely field-scoped, as a first cheap signal.
Offline-proven (8-case seam harness + the 19-case formatter harness): the
Blink break-representation case still prefers markers; the Google page-leak
case now routes to the box's own text on either seam side.
Modified files:
- Sources/AccessibilityTextReader.swift
… unit The upstream test target hardcodes its source list; this branch's AppContextService.swift now calls AccessibilityTextReader, so the test runner failed to compile. Declared as a separate prerequisite line (prerequisites accumulate in make) and switched the recipe to $^ so the compile list always mirrors the declared prerequisites. Verified: make test builds and passes. Modified files: - Makefile
5f25f81 to
c618e44
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (2)
Makefile (1)
78-85: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsolidate target prerequisites.
While defining the same target multiple times to append prerequisites is valid in Make (and correctly captured by
$^on line 85), scattering them across the file for a simple compilation rule can reduce readability.Consider consolidating
Sources/AccessibilityTextReader.swiftinto this main target definition and removing lines 18-19.💡 Proposed refactor
-$(TEST_RUNNER): Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift +$(TEST_RUNNER): Sources/AccessibilityTextReader.swift Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift `@mkdir` -p "$(BUILD_DIR)" swiftc \ -parse-as-library \ -o "$(TEST_RUNNER)" \ -sdk $(shell xcrun --show-sdk-path) \ -target $(ARCH)-apple-macosx13.0 \ $^🤖 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 `@Makefile` around lines 78 - 85, Consolidate the $(TEST_RUNNER) prerequisites by adding Sources/AccessibilityTextReader.swift to the main target definition shown before the swiftc recipe, then remove the separate prerequisite-only definition on lines 18–19. Keep the existing $^ compilation behavior and recipe unchanged.Source: Linters/SAST tools
Sources/PostProcessingService.swift (1)
820-849: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoffVerbatim path bypasses the LLM circuit breaker.
translateVerbatimWithFallbacknever callsLLMCooldownManager.shared.effectivePrimary(...)before dispatching, andtranslateVerbatim’s 429 branch (Lines 913-916) surfacesrequestFailedwithout registering a cooldown. BothprocessWithFallbackandprocessCommandTransformWithFallbackdo both. As a result this path sends requests to a model that is already cooling down and doesn’t feed the breaker on its own 429s. Consider aligning it with the other two fallback paths for consistent rate-limit behavior.🤖 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/PostProcessingService.swift` around lines 820 - 849, Update translateVerbatimWithFallback to resolve the primary model through LLMCooldownManager.shared.effectivePrimary(...) before dispatch and record cooldown state when translateVerbatim receives a 429 requestFailed result. Align both fallback decisions with processWithFallback and processCommandTransformWithFallback so cooling models are skipped and this path’s rate limits feed the circuit breaker.
🤖 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.
Nitpick comments:
In `@Makefile`:
- Around line 78-85: Consolidate the $(TEST_RUNNER) prerequisites by adding
Sources/AccessibilityTextReader.swift to the main target definition shown before
the swiftc recipe, then remove the separate prerequisite-only definition on
lines 18–19. Keep the existing $^ compilation behavior and recipe unchanged.
In `@Sources/PostProcessingService.swift`:
- Around line 820-849: Update translateVerbatimWithFallback to resolve the
primary model through LLMCooldownManager.shared.effectivePrimary(...) before
dispatch and record cooldown state when translateVerbatim receives a 429
requestFailed result. Align both fallback decisions with processWithFallback and
processCommandTransformWithFallback so cooling models are skipped and this
path’s rate limits feed the circuit breaker.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 4875f75b-783a-4d2d-9203-42e65a280860
📒 Files selected for processing (15)
MakefileSources/AccessibilityTextReader.swiftSources/AccessibilityWakeManager.swiftSources/AppContextService.swiftSources/AppState.swiftSources/ContextDebugLabels.swiftSources/ContextReadMetrics.swiftSources/ContextualFormattingService.swiftSources/PipelineDebugContentView.swiftSources/PipelineDebugPanelView.swiftSources/PipelineHistoryItem.swiftSources/PipelineHistoryStore.swiftSources/PostProcessingService.swiftSources/SettingsView.swiftSources/SurroundingTextLimiter.swift
🚧 Files skipped from review as they are similar to previous changes (12)
- Sources/ContextReadMetrics.swift
- Sources/ContextDebugLabels.swift
- Sources/PipelineHistoryItem.swift
- Sources/PipelineHistoryStore.swift
- Sources/AccessibilityWakeManager.swift
- Sources/SurroundingTextLimiter.swift
- Sources/SettingsView.swift
- Sources/PipelineDebugPanelView.swift
- Sources/PipelineDebugContentView.swift
- Sources/AppContextService.swift
- Sources/AccessibilityTextReader.swift
- Sources/AppState.swift
Times (17h59.), versions (v2.) and measures (5km.) end sentences; the abbreviation detector no longer suppresses capitalization after them. Modified files: - Sources/ContextualFormattingService.swift
Chameleon — context-aware dictation that fits where you drop it
Dictated text now adapts to its surroundings — capitalization, spacing, and punctuation — instead of being pasted in raw at the cursor. It reads the text around the cursor without ever disturbing it, and the cleanup model uses that context as a hint too. The new post-LLM step happens entirely locally and instantly.
Closes #200.
What it does, in plain terms
Today, when the user dictates, the cleaned‑up text is dropped at the cursor exactly as the model produced it: the first word capitalized, a space tacked onto the end, and nothing else adjusted for what is actually around the cursor. That is fine when it is starting a clean new line, but at the moment we are anywhere else (mid‑sentence, right before a question mark, inside parentheses, in the middle of a list, or replacing a selection) the result comes out messy: a stray capital letter, missing or doubled spaces, and punctuation that clashes with what was already there.
This change reads the few characters around the cursor and reshapes the inserted text so it reads naturally in that exact spot: it fixes the first-letter case, adds or removes the space on each side, cleans up clashing punctuation, recognizes real paragraph breaks, and keeps your custom spellings.
It works in normal Mac apps and inside web / Electron apps (chat apps, editors, browsers), where the cursor information is normally unreliable.
See it in action
In each pair,
│is the cursor. "Before" is how it works today (first word capitalized, a trailing space, nothing else touched); "After" is with this change.Mid-sentence — at
I think we should │, you say "go home now"I think we should Go home now.(stray capital G, plus a leftover space at the end)I think we should go home now.Right before a question mark — at
Did you finish│?, you say "the report yesterday"Did you finishThe report yesterday. ?(glued to the word, capital T, a period, a space, then the ?)Did you finish the report yesterday?In the middle of a list — at
I bought apples│, oranges, and pears., you say "bananas"I bought applesBananas. , oranges, and pears.(glued, capital B, a period and a space dumped before the comma)I bought apples bananas, oranges, and pears.Right before an existing comma — at
we met on Tuesday│, then left, you say "early" and the model adds its own comma/periodwe met on Tuesday Early., then left(the model's trailing mark collides with the comma already there)we met on Tuesday early, then left(the document's comma wins; the model's trailing mark is dropped)Two sentences in a row — at
I finished the task.│, you say "let's review it tomorrow"I finished the task.Let's review it tomorrow.(no space after the first period, leftover space at the end)I finished the task. Let's review it tomorrow.Inside a line of code — at
print(│), you say "hello world"print(Hello world. )(capital H, a period, and a space before the closing parenthesis)print(hello world)Replacing a selection — in
the old replace, you selectoldand say "seamless"the Seamless. replace(capital S, a stray period, and a double space)the seamless replaceCustom vocabulary awareness, mid-sentence — vocabulary
GitHub, API; atwe use │ daily, you say "github and the api"we use Github and the api. daily(wrong casing, a period mid-sentence, a double space)we use GitHub and the API dailyWhat the Run Log shows
This feature also turns the FreeFlow's setting tab Run Log into a full, readable trace of every dictation, with a completely new stage 4 of the pipeline, logging exactly how a piece of text travelled from your voice to the cursor. Most of this is new:
The labels are faithful: the "how it was read" line reflects the method that actually produced the context (so if the non-destructive start-of-recording snapshot was used, that is what it says — not a stale value).
That summary of the fourth stage reads like this:
),?, or comma.What's new
Context-aware capitalization. The first letter follows the sentence: uppercase at the start of a field, after
./?/!/…, after a line/paragraph break, after a bullet, or when opening a quote/parenthesis that begins a sentence — lowercase when you are continuing one. Abbreviations (Dr.,i.e.,etc.-shaped tokens), the pronounIand its contractions (I'm,I'll…), and all-caps acronyms (API,NASA) are never mangled, and a period that ends an abbreviation is not treated as a sentence boundary.Smart spacing at the seams. Today a single space is always tacked onto the end of every dictated sentence, no matter the context. This change drops that blanket trailing space and instead adds or omits a space on each side based on what is actually there — words and numbers get a space, opening/closing punctuation does not — and double spaces are never produced.
Punctuation cleanup. Removes punctuation that clashes with what follows: a period before a closing
)/,/?, a duplicated terminal mark (!!,,,), a mid-sentence period before lowercase text, a stray ellipsis dropped into the middle of a sentence, and — when you are inserting right before an existing comma — any terminal mark the model appended (,.;:!?…), so the comma already in the document stays the single separator (abbreviation periods are preserved).Correct replacement of selected text. When you dictate over a selection (ordinary dictation, not the AI "edit selected text" command mode), the spaces that belonged to the selection are restored, so the replacement does not glue itself to the neighboring words. The selected text is read untrimmed so those edge spaces survive.
Custom-vocabulary casing. Any word you defined (a name, a brand, an acronym) is restored to your exact spelling wherever it appears in the dictation — not just the first word — matched as a whole word so it never corrupts substrings (
MacOSstaysMacOS).Reliable, purely observational context reading in web / Electron apps. Uses the WebKit/Chromium text-position API (TextMarkers) as the primary read: it addresses the real document position, so it is immune to the lying integer cursor (Chromium reports a fraudulent
(0,0)) and it preserves the paragraph breaks that the plain value read flattens to a space — including a caret sitting on a blank line, which is reconstructed as a real line start. A self-scoped form-control read, a tree search, and the start-of-recording snapshot are the fallbacks. The accessibility tree is woken just-in-time so the very first dictation in an app still works. Reading context never types, selects, or moves the caret — on any path.The cleanup model sees a bounded slice of the surrounding text. The post-processing model now also receives the text immediately before and after the cursor as a reference — so it can match the spelling, casing, and tone of what is already there (for example, a recipient's name visible just before the cursor). That slice is bounded (a couple of sentences before, one after, capped by length, flattened to one line) and the prompt explicitly instructs the model never to copy the surrounding text into its output — together this prevents the model from "restoring" already-typed text.
Recognizes real paragraph / page breaks. Line, paragraph, and page breaks are normalized to a canonical newline before the formatter looks at them, so capitalization after a true paragraph break works regardless of how the app encodes the break (CR, CRLF, Unicode line/paragraph separators, next-line, or form-feed).
Full pipeline visibility in the Run Log. Each dictation is now traced end to end — what was read around the cursor (and by which method), what the cleanup model received and returned, what the new deterministic formatting stage did (in plain English), and the final inserted text — plus a per-session metric of how often the reads succeed versus come back blind.
How it works (the short version)
<textarea>/<input>) whose leaf doesn't host TextMarkers is read from its own self-scoped value instead, a breadth-first search finds the field when the app hands over the wrong element, and a snapshot taken when recording started covers a blind stop-time read.How it works (the technical version)
1. Reading the surrounding text — an explicit extraction ladder
Context is read by
AccessibilityTextReader, which tries methods in priority order and stops at the first that returns usable text. Every rung is purely observational (nothing is ever typed, selected, or scrolled), and the ladder branches on whether the focused element is a native control or a web/Electron view (detected byAXDOM*/AXWeb*attribute names on the element, or — since Chromium does not guarantee those prefixes on every focused leaf — by anAXWebAreaancestor):Native (AppKit): read
kAXValue+kAXSelectedTextRangeand slice around the selection. Reliable; nothing else is needed; the destructive fallback is never reached for native apps.Web / Electron:
AXTextMarker*parameterized attributes, with the underlying C functions (AXTextMarkerRangeCopyStartMarker/…EndMarker/AXTextMarkerRangeCreate) resolved at runtime viadlsym. TextMarkers address positions by opaque markers instead of integer offsets, so they are immune to the fraudulent(0,0)cursor Chromium reports throughkAXSelectedTextRange, and they preserve structural paragraph breaks — a web app often renders a line break as a DOM boundary with no literal newline in the plain value, so an offset read cannot see it and would treat a fresh line as mid-sentence. When the caret sits at the start of its line — including a blank line, whose degenerate line-head range is recognized as a line start — the missing newline is reconstructed, so capitalization after a break comes out right. The read is scoped to the focused field (AXTextMarkerRangeForUIElement, asked for the original leaf even when the markers are hosted on theAXWebAreaancestor; if field-scoping is unavailable it clamps to the caret's current line) so it does not reach back into unrelated page content (e.g. a live-region "response ready" status just above a chat input).<textarea>/<input>is exposed by Safari/Chromium as an accessibility leaf (AXTextField/AXTextArea) whose text is its ownkAXValue+kAXSelectedTextRange— the same contract a native macOS field uses, and one that can never leak neighbouring page text. It covers leaves that don't host TextMarkers. The gate is site-agnostic — it keys on the element's accessibility contract (plain leaf text control + honest caret), never on per-website markup — and bails for anything that is not a plain control or that reports the fraudulent(0,0)cursor.(0,0)guard — in both the async and the synchronous pre-paste read, so a lying caret can never fabricate a confident field-start.AXWebArea(when the app hands us the wrong element from its root).A built-in cross-check keeps the web reads honest: when the focused box exposes its own trustworthy text, a marker read is only used if it matches that text right next to the cursor — if it doesn't (say, a status banner from elsewhere on the page), the box's own text wins. This was added after a real-world catch on Google's AI-mode search box, whose content is invisible to the marker system: without the check, a page message ("the AI response is ready") was silently mistaken for the text before the cursor. The comparison ignores whitespace on purpose, because a faithful marker read differs from the plain value only in how line breaks are represented — which is exactly why markers stay the primary read.
The accessibility tree is woken just-in-time by setting the private
AXManualAccessibilityattribute (Chromium keeps its tree asleep for performance, and builds it lazily). This replaced an older trick that nudged the tree awake by synthesizing arrow keys — which is exactly what could shift the caret — so that key-based wake and its dead parameters were removed.Robustness when the machine is busy. Reading another app's text over the Accessibility API is a cross-process request, so a CPU-starved or unresponsive target app could otherwise stall the read. Three safeguards bound that: (a) a per-request timeout (
AXUIElementSetMessagingTimeout, 2 s) so a single Accessibility call can't block for the ~6 s system default — a slow app degrades to a fallback instead of hanging; (b) the heavy read runs off the main thread, so the app's own UI never freezes while waiting; and (c) the just-in-time Accessibility wake is held for a grace period and until the in-flight paste pipeline finishes (teardown is skipped entirely if a newer dictation re-armed the wake, and the privateAXManualAccessibilityattribute is switched back off at teardown), so a slow paste can't have the tree torn down underneath it. These bound latency, not success: a non-empty read is never guaranteed for any cross-process Accessibility read, but the pipeline always completes and the dictation is never dropped — at worst it falls back to a plain insertion.2. Bounding the context sent to the cleanup model (
SurroundingTextLimiter)Feeding the model the full surrounding text invites it to echo that text back into its answer (duplicating already-typed content). A new, self-contained
SurroundingTextLimiterproduces a small, bounded slice for the prompt only:This affects the prompt only — the deterministic formatter still receives the full, unflattened text with its real line breaks. The user-facing prompt was also strengthened: the surrounding text is framed as reference-only, with a positive role description plus an explicit "never copy, repeat, continue, or complete the surrounding text" instruction.
Prompt hygiene. The prompt labels its sections with uppercase tokens (
PRECEDING_TEXT,FOLLOWING_TEXT, …) and the model is explicitly told never to copy or emit them. The surrounding-text values are sentinel-fenced exactly like the transcript itself (<<<PRECEDING_TEXT … PRECEDING_TEXT), so a quote character in the on-screen text cannot break the framing and screen content gets the same injection hardening as dictated audio. Because a model can otherwise narrate an empty labeled section, emptyPRECEDING_TEXT/FOLLOWING_TEXTsections are omitted from the prompt entirely (no blank labeled field to comment on). Leaks are prevented at the prompt level rather than scrubbed from the output.3. The deterministic formatter (
ContextualFormattingService), four ordered phases\r,\r\n, U+2028, U+2029, U+0085, U+000C) to\nso the later phases recognize a real break regardless of encoding..?!…, after a line/paragraph break, after a bullet, after a sentence-opening quote/parenthesis → uppercase; mid-sentence → lowercase), while preserving abbreviations, the pronounI/contractions, and all-caps acronyms, and treating an abbreviation-ending period as not a boundary.“ ” « ») are unambiguous by Unicode, and an ambiguous straight quote ("') is classified opening/closing by its neighbouring character — English and Portuguese conventions both come out right. Slices are snapped to composed-character boundaries, so a capped read can never split an emoji or accent into a replacement character.The cursor-position semantics were also tightened: when only one side of the cursor can be read, the position is reported as
middle/unknownrather than incorrectly claimingend.4. Faithful diagnostics
ContextDebugLabelsis the single source of labels so the live debug panel and the saved history can never disagree, andContextReadMetricstallies per session how often each read method won. The recorded extraction method always reflects the rung that actually produced the context, every read is purpose-tagged in the logs ([start]/[stop]/[jit]/[paste]) so a trace attributes each line to its pipeline step, and a missing cursor position is treated as "unknown" so the pre-paste rescue is never silently skipped.5. Merge-safety / additivity
The shared backbone files are touched additively so this branch stays merge-compatible with other in-flight work regardless of merge order: the cleanup-prompt change is new defaulted parameters added as new lines inside otherwise-untouched call sites (contested lines stay byte-identical to upstream); the surrounding-text capture lives in the feature's own region of
collectContext; and the heavy logic lives in new, feature-owned files. The two largest files (AccessibilityTextReader,ContextualFormattingService) andSurroundingTextLimiterexist only on this branch.Files
New
ContextualFormattingService.swiftAccessibilityTextReader.swiftdlsym, field-scoped, structural line breaks preserved), the self-scoped read for real<textarea>/<input>leaves, the offset API for native controls, and a web-area tree search. Purely observational on every path.SurroundingTextLimiter.swiftAccessibilityWakeManager.swiftAXManualAccessibility(and observes focus/selection) so the first read in an app is reliable — without synthesizing any keystrokes.ContextDebugLabels.swiftContextReadMetrics.swiftModified
AppState.swiftAppContextService.swiftPostProcessingService.swiftSurroundingTextLimiter), with a strengthened never-copy instruction — added through new defaulted parameters so the change is purely additive and stays merge-compatible with other work on this file.PipelineHistoryItem.swiftPipelineHistoryStore.swiftPipelineDebugContentView.swiftPipelineDebugPanelView.swiftSettingsView.swiftNotes
AXManualAccessibilitywake and the WebKit/Chromium TextMarker SPIs — are isolated to clearly-labeled rungs of one explicit extraction ladder, documented, bounded by a per-request timeout, and used only when the clean native path doesn't apply. The deterministic formatter is plain, testable string logic with no platform tricks, and every path degrades gracefully. The goal was exactly the "clean implementation without too much voodoo magic" raised in Spacing, capitalization, and punctuation are context-blind — no awareness of text surrounding the cursor #200: the cleverness is fenced off, layered, and reversible rather than spread through the codebase.Reviewer notes — known non-issues
An earlier automated review raised a few items that we investigated and found to be intentional, pre-existing, or cosmetic. Noting them here so a re-review doesn't re-raise them:
collectContextis unchanged upstream code. It is not added or modified by this PR (it sits just below a new Accessibility-read block). It feeds the activity-inference model, which is orthogonal to the surrounding-text read, so it is intentionally not gated on whether surrounding text was found.<<<PRECEDING_TEXT …), the same way the dictated transcript is fenced, and the model is told to return text without surrounding quotes. What shows in the prompt view is the input framing, not quoted output.Summary by CodeRabbit