Add opt-in prefix typo auto-correct via Apple Intelligence#235
Conversation
Adds a toggleable feature that, after the user pauses typing, asks Apple Intelligence to fix obvious spelling typos in the text they just typed and rewrites the focused field in place. Pipeline: - PrefixCorrectionFilter (Support/): pure safety net that only accepts "typo-shaped" rewrites — same token/separator structure, matching case shape, small per-word edit distance. Rejects rephrasing, repunctuation, and capitalization changes the model might sneak in. - FoundationModelPrefixCorrectionEngine: wraps Apple Intelligence with a tight instruction prompt and deterministic decoding. Apple-Intelligence only for v1 — the bundled llama model isn't reliable enough; an UnavailablePrefixCorrectionEngine stub covers older macOS / missing SDK. - PrefixCorrectionWriter: synthesizes backspace + Unicode keystroke events (mirrors SuggestionInserter) and registers them with the suppression controller so the writes don't re-trigger autocomplete. - PrefixCorrectionCoordinator: settled-pause state machine. Debounces focus snapshots 800ms, gates on toggle + per-app allowlist + secure-field + terminal + autocomplete-busy + prefix length, runs the engine, validates through the filter, then writes. Work-ID + live-snapshot re-check drops results when the user typed during the model round-trip. Settings: master toggle (off by default) plus an allowlist (empty by default — auto-correct only runs in apps the user explicitly enables). Menu surfaces the global toggle (disabled when Apple Intelligence is unavailable) and a per-app "Auto-Correct in <app>" toggle. Tests: 22 PrefixCorrectionFilter cases covering accepted typo fixes and rejected structural/case/length/distance changes.
Resolve conflicts in project.pbxproj, SuggestionSettingsModel, and MenuBarView, preserving the prefix auto-correct feature alongside main's Cotabby rename and custom-rules work. Rename prefix auto-correct UserDefaults keys and TabbyLogger references to the cotabby/CotabbyLogger convention introduced on main. Also address Greptile review: correct the token-budget comment (~3 chars/token) and the donut-to-doughnut test threshold comment.
The menu bar had the prefix auto-correct toggle but the (reorganized) Settings window did not. Add it to the General section, mirroring the menu's behavior: gated on Apple Intelligence availability with the same help text, since the feature uses the on-device model to rewrite typos.
| let bundleKey = snapshot.bundleIdentifier ?? "" | ||
| if lastSubmittedPrefix[bundleKey] == context.precedingText { | ||
| // Already asked about this exact prefix in this app — nothing to do. | ||
| return | ||
| } | ||
| lastSubmittedPrefix[bundleKey] = context.precedingText |
There was a problem hiding this comment.
Dedup entry written before the async call permanently suppresses retry on failure
lastSubmittedPrefix[bundleKey] = context.precedingText is written at line 127, before the Task is spawned. If runCorrection later exits through any error path (the catch block in the engine, or Task.isCancelled), the entry is never cleared. The next time the same prefix settles — e.g., after focus moves away and back while the user left the typo unchanged — handleSettled finds lastSubmittedPrefix[bundleKey] == context.precedingText and returns early, so the correction is permanently skipped for that session.
The intended guard is "we just wrote a correction and the publisher re-fired with the corrected text," but setting the entry before the call conflates that case with transient failures. Fix: move the write into runCorrection, setting it only after proposeCorrection returns (nil or a proposal), and skipping the write entirely on the catch path so errors are retryable.
The per-app allowlist gated correction via isCorrectionAllowedForBundle, but it defaulted empty — so with the global toggle on but no app explicitly allowed, auto-correct never fired (the reported 'not working'). Drop the allowlist entirely for now: storage + mutation/query methods on SuggestionSettingsModel, the coordinator's isCorrectionAllowedForBundle gate, and the menu's per-app toggle. Correction now runs whenever globally enabled (and AI-available, not a terminal/secure field). The global Auto-Correct Typos toggle is unchanged.
When -cotabby-debug is active, the bottom debug panel now flashes each applied prefix correction (original -> corrected, trailing slice) for ~6s. The coordinator exposes an onCorrectionApplied hook fired right after the write; app composition wires it to the overlay only when debug is enabled, so production does no extra work.
Summary
Adds a toggleable feature that, after the user pauses typing, asks Apple
Intelligence to fix obvious spelling typos in the prefix they just typed and
rewrites the focused field in place. Off by default; opt-in per app.
Pipeline (each piece mirrors an existing pattern):
PrefixCorrectionFilter(Support/) — pure safety net. Only accepts"typo-shaped" rewrites: same token/separator structure, matching case shape,
small per-word edit distance. Rejects rephrasing, repunctuation, and
capitalization changes the model might add. 22 unit tests.
FoundationModelPrefixCorrectionEngine— wraps Apple Intelligence with atight instruction prompt + deterministic decoding. Apple-Intelligence-only
for v1 (bundled llama isn't reliable for this);
UnavailablePrefixCorrectionEnginecovers older macOS / missing SDK.
PrefixCorrectionWriter— synthesizes backspace + Unicode keystroke eventslike
SuggestionInserter, registered withInputSuppressionControllerso thewrites don't re-trigger autocomplete.
PrefixCorrectionCoordinator— settled-pause state machine. Debounces focussnapshots 800ms, gates on toggle + per-app allowlist + secure-field + terminal +
autocomplete-busy + prefix length, runs the engine, validates through the filter,
then writes. Work-ID + live-snapshot re-check drops results if the user typed
during the model round-trip (same cancellation idiom as
SuggestionWorkController).Settings (
SuggestionSettingsModel): master toggle (off by default) + an allowlist(empty by default — inverted polarity from
disabledAppRulesbecause auto-correctrewrites user text). Menu surfaces the global toggle (disabled when Apple Intelligence
is unavailable) and a per-app "Auto-Correct in " toggle.
Validation
Not yet exercised end-to-end against a live Apple Intelligence field + real typo — the
filter (well-tested) gates the destructive write, and the engine/writer are thin wrappers,
but a manual pass in Notes/Mail is the recommended next step before enabling broadly.
Linked issues
None.
Risk / rollout notes
user explicitly opts in for a specific app.
the allowlist, prefixes under 12 / over 500 chars, and any time autocomplete is mid-flight.
v1 limitations (documented for follow-up): undo of a correction can re-trigger it; IME
composition is not specifically detected (relies on allowlist being opt-in); only the
most recent settled prefix is corrected, not pre-existing text the user didn't author.
UserDefaultskeys (tabbyPrefixAutoCorrectEnabled,tabbyPrefixAutoCorrectAllowedRules); both fall through to safe defaults when absent.project.pbxprojper repo convention.Greptile Summary
This PR adds an opt-in prefix typo auto-correct feature backed by Apple Intelligence. When enabled for a specific app, the coordinator debounces focus snapshots, sends the settled prefix to the Foundation Models engine, validates the proposal through a structural safety filter (
PrefixCorrectionFilter), and rewrites the field with synthetic backspace + retype events — all off by default and gated behind multiple safety checks.PrefixCorrectionCoordinatordrives the settled-pause state machine with work-ID cancellation, live-snapshot re-validation, and per-bundle dedup; a logic defect causes the dedup entry to be written before the async engine call, permanently blocking correction retries for a given prefix after any transient engine failure in the current session.PrefixCorrectionFilteris a pure safety net enforcing same-token-structure, same-case-shape, and small edit-distance constraints; 22 unit tests cover the key acceptance and rejection cases.FoundationModelPrefixCorrectionEngine/PrefixCorrectionWriterare thin wrappers that mirror existing engine and inserter patterns;SuggestionSettingsModeladds the master toggle and per-app allowlist with safe defaults.Confidence Score: 3/5
Safe to merge for current users (feature is off by default and requires per-app opt-in), but the coordinator contains a logic defect in its dedup mechanism that will silently suppress correction retries after any transient engine failure for the lifetime of the session.
The dedup entry is written in
handleSettledbefore the async engine call. Anycatchpath inrunCorrection— including transientGenerationErrors that don't flipisAvailable— returns without clearing the entry. The next time the identical prefix settles, the dedup guard fires and skips the attempt permanently for that session. Two additional issues from prior rounds (suppression budget pre-registered before events, live-snapshot re-check missing bundle-ID verification) are already flagged and unaddressed.Cotabby/App/Coordinators/PrefixCorrectionCoordinator.swift — the dedup write ordering and the bundle-ID staleness in the acceptance path both need attention.
Important Files Changed
strippedResponsecorrectly distinguishes U+201C/U+201D pairs.isPrefixAutoCorrectEnabledandprefixAutoCorrectAllowedRulesto persistent settings; allowlist/blocklist polarity is clearly documented and default-off behavior is correct.Sequence Diagram
sequenceDiagram participant FocusModel participant Coordinator as PrefixCorrectionCoordinator participant Engine as FoundationModelPrefixCorrectionEngine participant Filter as PrefixCorrectionFilter participant Writer as PrefixCorrectionWriter FocusModel->>Coordinator: snapshotPublisher emits Coordinator->>Coordinator: removeDuplicates + debounce 800ms Coordinator->>Coordinator: handleSettled() — bump workID, refreshNow() Coordinator->>Coordinator: passesGate() — enabled, allowlist, secure, terminal, busy, length, isAvailable Coordinator->>Coordinator: "lastSubmittedPrefix[bundle] = originalPrefix ⚠️ written before call" Coordinator->>Engine: proposeCorrection(for: originalPrefix) Engine-->>Coordinator: String? proposal (or throws) Coordinator->>Coordinator: "guard workID == latestWorkID" Coordinator->>Coordinator: refreshNow() — re-check live prefix Coordinator->>Filter: acceptedCorrection(original:proposed:) Filter-->>Coordinator: String? accepted Coordinator->>Coordinator: "lastSubmittedPrefix[bundle] = accepted" Coordinator->>Writer: replacePrefix(originalLength:with:) Writer->>Writer: registerSyntheticInsertion(budget) Writer->>Writer: postBackspace() x N Writer->>Writer: postUnicodeString(corrected)Comments Outside Diff (3)
Cotabby/Services/Suggestion/PrefixCorrectionWriter.swift, line 676-695 (link)suppressionController.registerSyntheticInsertion(expectedKeyDownCount: expectedKeyDowns)is called with the fulloriginalLength + 1budget before any events are actually posted. IfpostBackspace()fails on iteration N (orpostUnicodeStringfails after all backspaces succeed), the suppression controller retains the unspent count and will silently eat that many real user keystrokes after the method returns. ThepostUnicodeStringfailure case is especially harmful: alloriginalLengthcharacters have already been deleted from the field, the replacement is never typed, and the next user keystroke is swallowed as well.The fix is to register only for the events that were actually posted, or to cancel/decrement the remaining budget on early exit. A simpler guard is to only register once you know the event stream is going to complete — move the registration to just before the unicode-string post (after the backspace loop succeeds) and track the actual posted count separately.
Cotabby/Services/Runtime/FoundationModelPrefixCorrectionEngine.swift, line 531-534 (link)isAvailablegetter mutates state as a side effectrefresh()is called every time any code readsisAvailable— including the synchronous call fromPrefixCorrectionCoordinator.passesGate. IfFoundationModelAvailabilityService.refresh()does any non-trivial work (queryingSystemLanguageModel.availability, writing to@Publishedproperties), this runs it on the main actor unconditionally each settled event, even before the engine is ever invoked. Computed property getters are expected to be read-only; arefreshIfNeededguard or explicit refresh calls at the call site would make the side effect opt-in and easier to audit.Cotabby/App/Coordinators/PrefixCorrectionCoordinator.swift, line 203-224 (link)The staleness guard at lines 203–209 verifies that
precedingTextandselection.lengthare unchanged, but does not verify thatfocusModel.snapshot.bundleIdentifierstill matches the app in which the correction was approved. BecausepassesGatechecksisCorrectionAllowedForBundle(snapshot.bundleIdentifier)against the original snapshot, a write can escape to a non-allowlisted app if the user switches focus during the LLM call and the new app coincidentally holds the sameprecedingText— so long as the 800 ms debounce for the newSettledKeyhasn't yet fired to bumplatestWorkID.Compounding this, the
lastSubmittedPrefix[bundleKey] = acceptedon line 222 re-readsfocusModel.snapshot.bundleIdentifierfrom the live (possibly different) app, so the dedup entry lands under the wrong key.Fix: capture
snapshot.bundleIdentifierbefore theinflightTaskand pass it intorunCorrection, then addfocusModel.snapshot.bundleIdentifier == originalBundleIdentifierto the live-snapshot guard.Reviews (3): Last reviewed commit: "Add Auto-Correct Typos toggle to Setting..." | Re-trigger Greptile