Skip to content

Add opt-in prefix typo auto-correct via Apple Intelligence#235

Open
FuJacob wants to merge 8 commits into
mainfrom
prefix-autocorrect
Open

Add opt-in prefix typo auto-correct via Apple Intelligence#235
FuJacob wants to merge 8 commits into
mainfrom
prefix-autocorrect

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 25, 2026

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 a
    tight instruction prompt + deterministic decoding. Apple-Intelligence-only
    for v1 (bundled llama isn't reliable for this); UnavailablePrefixCorrectionEngine
    covers older macOS / missing SDK.
  • PrefixCorrectionWriter — synthesizes backspace + Unicode keystroke events
    like SuggestionInserter, registered with InputSuppressionController 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 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 disabledAppRules because auto-correct
rewrites user text). Menu surfaces the global toggle (disabled when Apple Intelligence
is unavailable) and a per-app "Auto-Correct in " toggle.

Validation

xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build
# ** BUILD SUCCEEDED **

xcodebuild test -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
# ** TEST SUCCEEDED **  292 tests, 0 failures

swiftlint lint --quiet
# no new warnings

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

  • Off by default, and per-app allowlist starts empty, so no behavior change until a
    user explicitly opts in for a specific app.
  • Hard skips baked into the coordinator gate: secure text fields, terminals, apps not on
    the allowlist, prefixes under 12 / over 500 chars, and any time autocomplete is mid-flight.
  • The write is "backspace N + retype corrected prefix" — bounded by the 500-char cap. Known
    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.
  • Two new UserDefaults keys (tabbyPrefixAutoCorrectEnabled,
    tabbyPrefixAutoCorrectAllowedRules); both fall through to safe defaults when absent.
  • New test file registered manually in project.pbxproj per 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.

  • PrefixCorrectionCoordinator drives 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.
  • PrefixCorrectionFilter is 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 / PrefixCorrectionWriter are thin wrappers that mirror existing engine and inserter patterns; SuggestionSettingsModel adds 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 handleSettled before the async engine call. Any catch path in runCorrection — including transient GenerationErrors that don't flip isAvailable — 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

Filename Overview
Cotabby/App/Coordinators/PrefixCorrectionCoordinator.swift New coordinator implementing the settled-pause typo-correction loop; contains a logic defect where the per-bundle dedup entry is written before the async engine call, permanently suppressing correction retries after any engine failure for that prefix.
Cotabby/Services/Runtime/FoundationModelPrefixCorrectionEngine.swift New Apple Intelligence adapter for prefix correction; correctly uses greedy/deterministic decoding, and the smart-quote stripping in strippedResponse correctly distinguishes U+201C/U+201D pairs.
Cotabby/Services/Suggestion/PrefixCorrectionWriter.swift New synthetic-event writer that backspaces the original prefix and retypes the corrected text; suppression budget pre-registration issue was flagged in a prior review round.
Cotabby/Support/PrefixCorrectionFilter.swift Pure safety-net filter for typo-shaped corrections; tokenization, case-shape checks, and Levenshtein gating all look correct and well-tested.
Cotabby/Models/SuggestionSettingsModel.swift Adds isPrefixAutoCorrectEnabled and prefixAutoCorrectAllowedRules to 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)
Loading

Comments Outside Diff (3)

  1. Cotabby/Services/Suggestion/PrefixCorrectionWriter.swift, line 676-695 (link)

    P1 Suppression budget pre-registered before events are posted

    suppressionController.registerSyntheticInsertion(expectedKeyDownCount: expectedKeyDowns) is called with the full originalLength + 1 budget before any events are actually posted. If postBackspace() fails on iteration N (or postUnicodeString fails after all backspaces succeed), the suppression controller retains the unspent count and will silently eat that many real user keystrokes after the method returns. The postUnicodeString failure case is especially harmful: all originalLength characters 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.

    Fix in Codex Fix in Claude Code

  2. Cotabby/Services/Runtime/FoundationModelPrefixCorrectionEngine.swift, line 531-534 (link)

    P2 isAvailable getter mutates state as a side effect

    refresh() is called every time any code reads isAvailable — including the synchronous call from PrefixCorrectionCoordinator.passesGate. If FoundationModelAvailabilityService.refresh() does any non-trivial work (querying SystemLanguageModel.availability, writing to @Published properties), 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; a refreshIfNeeded guard or explicit refresh calls at the call site would make the side effect opt-in and easier to audit.

    Fix in Codex Fix in Claude Code

  3. Cotabby/App/Coordinators/PrefixCorrectionCoordinator.swift, line 203-224 (link)

    P1 Live snapshot re-check omits bundle ID verification

    The staleness guard at lines 203–209 verifies that precedingText and selection.length are unchanged, but does not verify that focusModel.snapshot.bundleIdentifier still matches the app in which the correction was approved. Because passesGate checks isCorrectionAllowedForBundle(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 same precedingText — so long as the 800 ms debounce for the new SettledKey hasn't yet fired to bump latestWorkID.

    Compounding this, the lastSubmittedPrefix[bundleKey] = accepted on line 222 re-reads focusModel.snapshot.bundleIdentifier from the live (possibly different) app, so the dedup entry lands under the wrong key.

    Fix: capture snapshot.bundleIdentifier before the inflightTask and pass it into runCorrection, then add focusModel.snapshot.bundleIdentifier == originalBundleIdentifier to the live-snapshot guard.

    Fix in Codex Fix in Claude Code

Fix All in Codex Fix All in Claude Code

Reviews (3): Last reviewed commit: "Add Auto-Correct Typos toggle to Setting..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

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.
Comment thread CotabbyTests/PrefixCorrectionFilterTests.swift
FuJacob added 3 commits May 25, 2026 04:05
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.
Comment on lines +122 to +127
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
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.

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

Fix in Codex Fix in Claude Code

FuJacob added 4 commits May 25, 2026 05:53
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant