Skip to content

Suppress completions on typo'd word and offer context-aware correction#353

Open
FuJacob wants to merge 1 commit into
mainfrom
feat/typo-suppression
Open

Suppress completions on typo'd word and offer context-aware correction#353
FuJacob wants to merge 1 commit into
mainfrom
feat/typo-suppression

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 28, 2026

Summary

If the user's last word looks misspelled, Cotabby now either suppresses the continuation entirely or — depending on the user's preference — switches into a single-call correction mode whose accept path replaces the typo'd word with the corrected one in one shot. NSSpellChecker gates the branch (sub-millisecond per word, unique document tag per app) so the LLM only sees one prompt template per turn and never has to detect typos itself.

Two new Settings toggles ship on by default: "Hide Suggestions on Typo" and "Offer Corrections on Typo" (the second is gated on the first in the UI). When a correction is offered the ghost text renders in green so the user can tell at a glance that pressing the accept key will replace, not extend.

Validation

swiftlint lint --quiet
# One residual warning: cyclomatic complexity 11/10 on generateFromCurrentFocus, same shape as the
# pre-existing FocusTracker.swift:488 (complexity 12) warning the project already ships.

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

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

Local test execution hits the documented Team ID / signing mismatch from CLAUDE.md; relying on build-for-testing succeeding per that section.

Branch was rebased onto the latest origin/main mid-development (conflicts in OverlayController and the settings publisher composition resolved by hand); xcodegen regenerated to reconcile the .xcodeproj.

Linked issues

Refs #326

Risk / rollout notes

  • Real-world quality depends on the LLM. Small open-source models will sometimes echo the typo back unchanged. The correction prompt says "if you can't confidently improve, repeat the word verbatim and we'll drop the suggestion" but the dropping itself isn't wired yet — if result.text.trimmed == typo.trimmed we still render and accept. Worth filtering in a follow-up; for now relying on the user just not pressing Tab when the suggestion is identical to their input.
  • Synthetic backspace events touch a sensitive code path. SuggestionInserter.insert(_:replacingLastCharacters:) arms InputSuppressionController for deleteCount + 1 keydowns before emitting any events, so our own backspaces shouldn't re-trigger the input monitor. This can't be unit tested — needs a manual smoke (type a typo in Notes or Safari, watch green ghost text appear, press Tab, confirm the typo gets replaced).
  • Settings UserDefaults migration is invisible. Two new keys (cotabbySuppressCompletionsOnTypo, cotabbyOfferTypoCorrections) both default to true when absent, so existing installs get the new behaviour by default. If we want a quieter rollout we can flip the defaults to false and surface them in a release note.
  • Per-app overrides not threaded yet. The toggles are global; per-app opt-out lives in the disabled-bundle list for the whole suggestion pipeline. A follow-up could add per-app spellcheck toggles for users who write code-style text in one app and prose in another.

Greptile Summary

This PR adds a typo-detection gate to the completion pipeline using NSSpellChecker. When the user's last word looks misspelled, Cotabby either suppresses the continuation or — with a second toggle — switches into a single-call correction mode that offers the fix as green ghost text; Tab then replaces the typo with the corrected word using synthetic backspace events.

  • Two new UserDefaults-backed toggles ("Hide Suggestions on Typo", "Offer Corrections on Typo") default to true, so the feature activates for all existing users on first launch.
  • CurrentWordSpellChecker wraps NSSpellChecker with a stable per-app document tag; CorrectionPromptRenderer renders a dedicated single-word prompt; SuggestionInserter now synthesizes backspace events before inserting the replacement word.
  • The acceptance path in SuggestionCoordinator+Acceptance branches on SuggestionKind.correction so typo replacements commit the whole word atomically rather than using the partial-accept logic.

Confidence Score: 3/5

The correction acceptance path works correctly in the happy path, but the suppression controller refactor introduces a regression where real user keystrokes can be silently swallowed for up to one second if CGEvent creation fails during a correction acceptance.

The suppression controller is now armed unconditionally before the CGEvent creation guards in SuggestionInserter. In the original code, registration happened only after a successful guard. A failure mid-backspace-loop or at the insert-event guard returns false with the controller still counting down from backspaceCount + 1, meaning real keystrokes the user types in the next ~1 second are treated as synthetic and eaten. CGEvent failures are unusual but not impossible under resource pressure, and the affected code path is keyboard-level input handling, so the impact when it does occur is jarring. The other two findings are quality gaps with no blocking runtime consequence.

Cotabby/Services/Suggestion/SuggestionInserter.swift is the file to focus on — the suppression registration order needs to move after all guards. The acceptance coordinator and correction prompt renderer have lower-priority gaps worth a follow-up but are safe to ship.

Important Files Changed

Filename Overview
Cotabby/Services/Suggestion/SuggestionInserter.swift Refactored insert to support replacement via synthetic backspaces; the suppression controller is now armed before the CGEvent creation guards, leaving it live on any failure path — a regression from the original ordering.
Cotabby/Support/CurrentWordExtractor.swift New pure helper that extracts the trailing natural-language word from caret context; well-structured and conservative, but the Result.characterCount field is never used downstream.
Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift New acceptCorrection path correctly branches on SuggestionKind.correction; lacks a guard to drop the suggestion when the LLM echoes the typo back unchanged, which the prompt's own fallback instruction implies should happen.
Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift Typo gate cleanly extracted into resolveTypoDecision; requestKind threads through apply() to session correctly.
Cotabby/Services/Spelling/CurrentWordSpellChecker.swift Clean NSSpellChecker wrapper with stable per-app document tag; sets automaticallyIdentifiesLanguages globally on the shared singleton, which is an intentional design choice but a permanent app-wide side effect.
Cotabby/Support/CorrectionPromptRenderer.swift New correction prompt renderer; the fallback instruction ('repeat verbatim and we'll drop the suggestion') is not enforced by the acceptance path.
Cotabby/Support/SuggestionRequestFactory.swift Correctly forks into correction vs continuation mode; precedingTextWithoutTrailingWord uses hasSuffix which is case-sensitive but the extracted word is always from the same source string so this is consistent.
Cotabby/Models/SuggestionModels.swift Clean additions of SuggestionKind, TypoContext, and isCorrection geometry flag; kind is properly propagated through session copy helpers.
Cotabby/Models/SuggestionSettingsModel.swift Two new settings correctly persisted with true defaults; CombineLatest nesting to work around the 4-publisher limit is functional but slightly obscures the publisher graph.
Cotabby/UI/SettingsView.swift Toggles wired correctly; 'Offer Corrections on Typo' is gated on the suppression toggle being enabled via .disabled().
Cotabby/Services/UI/OverlayController.swift Green tint for correction mode is cleanly isolated and intentionally bypasses the custom color setting with a documented rationale.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Keystroke → generateFromCurrentFocus] --> B[resolveTypoDecision]
    B --> C{suppressCompletionsOnTypo?}
    C -- false --> D[Normal continuation request]
    C -- true --> E[CurrentWordExtractor.extract]
    E --> F{Word found?}
    F -- no --> D
    F -- yes --> G[CurrentWordSpellChecker.isTypo]
    G --> H{Is typo?}
    H -- no --> D
    H -- yes --> I{offerTypoCorrections?}
    I -- false --> J[Suppress: clearSuggestion + idle]
    I -- true --> K[Build TypoContext with word + NSSpellChecker hints]
    K --> L[SuggestionRequestFactory: CorrectionPromptRenderer, kind = .correction, maxTokens = 6]
    L --> M[LLM call]
    M --> N[apply result with requestKind = .correction]
    N --> O[SuggestionInteractionState.startSession kind=.correction]
    O --> P[OverlayController: render ghost text in green]
    P --> Q{User presses accept key}
    Q --> R[acceptCorrection: trim correctedText]
    R --> S{correctedText empty?}
    S -- yes --> T[passTabThrough]
    S -- no --> U[SuggestionInserter.insert replacingLastCharacters: wordLength]
    U --> V[Arm InputSuppressionController]
    V --> W[Post N backspace events]
    W --> X[Post replacement Unicode event]
    X --> Y[recordAcceptedWords + schedulePrediction]
    D --> Z[LLM call → normal continuation]
Loading

Comments Outside Diff (1)

  1. Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift, line 133-136 (link)

    P2 "Repeat verbatim" fallback is not guarded at acceptance time

    CorrectionPromptRenderer tells the model: "if you can't confidently improve the word, repeat it verbatim and we'll drop the suggestion." But acceptCorrection only guards against an empty correctedText — if the model echoes the typo unchanged, the code synthesizes replacingLastWordOfLength backspaces and then re-inserts the identical word. The user sees a green ghost, presses Tab, watches the text flicker, and ends up exactly where they started. This is called out in the PR risk notes as a known gap, but worth wiring the guard here so the prompt's contract is actually honoured.

    Fix in Codex Fix in Claude Code

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Suppress completions on a typo'd current..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

…re correction

If the user's last word is misspelled, the normal continuation path is the wrong product behaviour: completing a broken word piles more text on top of a mistake. The new pipeline checks the current word with NSSpellChecker before we build a request and either suppresses the continuation entirely or switches into a single-call correction mode whose accept path replaces the typo'd word in one shot.

NSSpellChecker gates the branch (sub-ms on one word, unique document tag per app) so the LLM only sees one prompt template at a time and never has to detect typos itself. The correction prompt is small and bounded — six tokens, surrounding context, NSSpellChecker's own guesses passed in as a non-binding hint — and the result rides through the existing request/session/overlay pipeline with a new SuggestionKind that flips acceptance into a backspace-then-insert path. The overlay tints green and the inserter arms suppression for every synthetic key it emits so our own backspaces never re-trigger the input monitor.

Two new Settings toggles ship on by default: "Hide Suggestions on Typo" and "Offer Corrections on Typo" (the second is gated on the first in the UI). Current-word extraction skips digits, code-like punctuation, and ALL-CAPS acronyms to keep false positives low on technical text.

Refs #326
Comment on lines 45 to 67
@@ -35,13 +67,18 @@ final class SuggestionInserter {
}
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 Suppression controller is armed before the CGEvent creation guards, so any failure in the backspace loop or the insert-event guard leaves the controller armed with unconsumed events. Real user keystrokes typed in the next ~1 second would then be counted against that budget and swallowed. In the original insert(_:) code the registerSyntheticInsertion call was placed after a successful guard; moving it before the loop introduced this regression. Building all events first and arming only after all creation succeeds restores the original guarantee.

Suggested change
let backspaceCount = max(deleteCount, 0)
// Build every CGEvent before arming suppression so a creation failure can return false
// without leaving the controller counting real user keystrokes as synthetic events.
var backspaceEvents: [(CGEvent, CGEvent)] = []
for _ in 0..<backspaceCount {
guard let downEvent = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(51), keyDown: true),
let upEvent = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(51), keyDown: false) else {
lastErrorMessage = "Unable to create a synthetic backspace event."
CotabbyLogger.suggestion.error("Failed to create synthetic backspace event for correction insertion")
return false
}
backspaceEvents.append((downEvent, upEvent))
}
guard let keyDownEvent = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true),
let keyUpEvent = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: false) else {
lastErrorMessage = "Unable to create a synthetic keyboard event."
CotabbyLogger.suggestion.error("Failed to create synthetic keyboard events for insertion")
return false
}
// All events created — arm suppression only now, matching the original guard-then-arm order.
suppressionController.registerSyntheticInsertion(expectedKeyDownCount: backspaceCount + 1)
for (downEvent, upEvent) in backspaceEvents {
downEvent.post(tap: .cghidEventTap)
upEvent.post(tap: .cghidEventTap)
}

Fix in Codex Fix in Claude Code

Comment on lines +11 to +16
struct Result: Equatable, Sendable {
let word: String
/// Number of extended grapheme clusters in the word — what the inserter uses to know how
/// many backspace events to synthesize when replacing the typo with a correction.
let characterCount: Int
}
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.

P2 characterCount field is never read downstream

Result.characterCount is documented as "what the inserter uses to know how many backspace events to synthesize," but resolveTypoDecision only accesses currentWord.word and discards currentWord.characterCount. SuggestionRequestFactory independently recomputes the same value as typoContext.typoWord.count. The field is dead code and the comment creates a false impression about how the deletion count is wired.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

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