Suppress completions on typo'd word and offer context-aware correction#353
Suppress completions on typo'd word and offer context-aware correction#353FuJacob wants to merge 1 commit into
Conversation
…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
| @@ -35,13 +67,18 @@ final class SuggestionInserter { | |||
| } | |||
There was a problem hiding this comment.
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.
| 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) | |
| } |
| 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 | ||
| } |
There was a problem hiding this comment.
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!
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.
NSSpellCheckergates 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
Local
testexecution hits the documented Team ID / signing mismatch from CLAUDE.md; relying onbuild-for-testingsucceeding per that section.Branch was rebased onto the latest origin/main mid-development (conflicts in
OverlayControllerand the settings publisher composition resolved by hand); xcodegen regenerated to reconcile the.xcodeproj.Linked issues
Refs #326
Risk / rollout notes
result.text.trimmed == typo.trimmedwe 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.SuggestionInserter.insert(_:replacingLastCharacters:)armsInputSuppressionControllerfordeleteCount + 1keydowns 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).cotabbySuppressCompletionsOnTypo,cotabbyOfferTypoCorrections) both default totruewhen absent, so existing installs get the new behaviour by default. If we want a quieter rollout we can flip the defaults tofalseand surface them in a release note.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.UserDefaults-backed toggles ("Hide Suggestions on Typo", "Offer Corrections on Typo") default totrue, so the feature activates for all existing users on first launch.CurrentWordSpellCheckerwrapsNSSpellCheckerwith a stable per-app document tag;CorrectionPromptRendererrenders a dedicated single-word prompt;SuggestionInserternow synthesizes backspace events before inserting the replacement word.SuggestionCoordinator+Acceptancebranches onSuggestionKind.correctionso 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
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]Comments Outside Diff (1)
Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift, line 133-136 (link)CorrectionPromptRenderertells the model: "if you can't confidently improve the word, repeat it verbatim and we'll drop the suggestion." ButacceptCorrectiononly guards against an emptycorrectedText— if the model echoes the typo unchanged, the code synthesizesreplacingLastWordOfLengthbackspaces 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.Reviews (1): Last reviewed commit: "Suppress completions on a typo'd current..." | Re-trigger Greptile