From 901e5724ea04577eca58e10dc41b1009ecc923cf Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Thu, 28 May 2026 02:41:02 -0700 Subject: [PATCH] Suppress completions on a typo'd current word and offer a context-aware correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cotabby.xcodeproj/project.pbxproj | 20 +++++ .../SuggestionCoordinator+Acceptance.swift | 71 +++++++++++++++- .../SuggestionCoordinator+Prediction.swift | 69 +++++++++++++-- .../Coordinators/SuggestionCoordinator.swift | 5 ++ Cotabby/App/Core/CotabbyAppEnvironment.swift | 6 +- Cotabby/Models/SuggestionEngineModels.swift | 7 ++ Cotabby/Models/SuggestionModels.swift | 53 +++++++++++- Cotabby/Models/SuggestionSettingsModel.swift | 57 ++++++++++++- .../Models/SuggestionSubsystemContracts.swift | 7 ++ .../Spelling/CurrentWordSpellChecker.swift | 60 +++++++++++++ .../Suggestion/SuggestionInserter.swift | 41 ++++++++- .../SuggestionInteractionState.swift | 10 ++- Cotabby/Services/UI/OverlayController.swift | 15 +++- .../Support/CorrectionPromptRenderer.swift | 72 ++++++++++++++++ Cotabby/Support/CurrentWordExtractor.swift | 71 ++++++++++++++++ .../Support/SuggestionRequestFactory.swift | 85 +++++++++++++++---- Cotabby/UI/SettingsView.swift | 27 ++++++ CotabbyTests/CotabbyTestFixtures.swift | 11 ++- CotabbyTests/LlamaPromptRendererTests.swift | 3 +- 19 files changed, 647 insertions(+), 43 deletions(-) create mode 100644 Cotabby/Services/Spelling/CurrentWordSpellChecker.swift create mode 100644 Cotabby/Support/CorrectionPromptRenderer.swift create mode 100644 Cotabby/Support/CurrentWordExtractor.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 31c21465..e3395b0e 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 25D4FC8D191A50F63E6391F9 /* ModelAndPresentationValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03766F6253FF17639230C0F6 /* ModelAndPresentationValueTests.swift */; }; 25F91CEF38400FD1ADB6B1AF /* CompletionRenderModePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D504BEB224E0C176F5FCFF6E /* CompletionRenderModePolicyTests.swift */; }; 26E0331E9E2F92FAE531BDEE /* ActivationIndicatorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D84D4528EEC9EFEB8AE8E318 /* ActivationIndicatorController.swift */; }; + 274E4154E941BFA3FBAC6961 /* CorrectionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EDE4B9C30127C2019224E6 /* CorrectionPromptRenderer.swift */; }; 2C6159231472A849F15BD0AE /* ScreenFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5484C8A04B9C00CF79D589EB /* ScreenFrameReader.swift */; }; 2DF5A3826AAB99C279EBB8DE /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81DD30EB657368AACE9625A /* InputMonitor.swift */; }; 2EE05B312C990104BE934772 /* GhostFontSizeStabilizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BF59EE80F3A0143B79740 /* GhostFontSizeStabilizerTests.swift */; }; @@ -128,12 +129,14 @@ B0828FF0D7EE110C0B23DB94 /* TagsInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C0F017699EE44C81C095CA /* TagsInputView.swift */; }; B0B115C6EBAC37FF6115B4BE /* SuggestionCoordinator+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */; }; B2F7589B8D32ACF97BB642AB /* HuggingFaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */; }; + B6703DAE949C7FB034634424 /* CurrentWordExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247561C626843957CFB4B632 /* CurrentWordExtractor.swift */; }; B93AB7E845086F6FBB068369 /* SuggestionRequestFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */; }; BB6325CA50F97B18B9725918 /* SuggestionTextNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B424E2AC97C99D335B0D5751 /* SuggestionTextNormalizer.swift */; }; BBE22CE4EF43247F8775B25D /* FocusPollBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */; }; BFCA7FAFDAEBF586AB615567 /* ClipboardRelevanceFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B0D133AB77A2503FB08827 /* ClipboardRelevanceFilterTests.swift */; }; C2C958D6E5F5FE1CCC414BCE /* SuggestionSubsystemContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB16474A67CE1D210B944C9 /* SuggestionSubsystemContracts.swift */; }; C4C6734678797669055988E0 /* AppUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9573F3504CAE6891DF9B7D /* AppUpdateManager.swift */; }; + C56ABA04AE27A9943368035C /* CurrentWordSpellChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733BF6287BDE599B02A12271 /* CurrentWordSpellChecker.swift */; }; C71B594433F3B411CAE5DE7E /* FocusCapabilityResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F6D5F94B238F7B4BE7C247 /* FocusCapabilityResolverTests.swift */; }; CA5B2D226FBAA5419E78F14F /* SuggestionSessionReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0AA0503128B0FC3951D700 /* SuggestionSessionReconciler.swift */; }; CB65A79F164269991FABC32E /* SuggestionStateHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19DB9558F4D3AFB108D71649 /* SuggestionStateHelperTests.swift */; }; @@ -200,6 +203,7 @@ 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeCoordinator.swift; sourceTree = ""; }; 220CD4AFA1E96A37BC4514AD /* LaunchAtLoginService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginService.swift; sourceTree = ""; }; 22544F4B756E3E4144497D17 /* SuggestionCoordinator+Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Input.swift"; sourceTree = ""; }; + 247561C626843957CFB4B632 /* CurrentWordExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWordExtractor.swift; sourceTree = ""; }; 262BE2F1E97389FE8D7A5FB9 /* Cotabby.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cotabby.app; sourceTree = BUILT_PRODUCTS_DIR; }; 264CA64B2AB1611F82E5B760 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 273B4DC844F79B4BE2C8910F /* FocusPollBackoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPollBackoffTests.swift; sourceTree = ""; }; @@ -224,6 +228,7 @@ 54EF3C7F5D9D6F3FA50FD51C /* ContextBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextBuffer.swift; sourceTree = ""; }; 5664E34B23FBDF69292FEF43 /* FoundationModelSuggestionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelSuggestionEngine.swift; sourceTree = ""; }; 58C0F017699EE44C81C095CA /* TagsInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsInputView.swift; sourceTree = ""; }; + 58EDE4B9C30127C2019224E6 /* CorrectionPromptRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionPromptRenderer.swift; sourceTree = ""; }; 5976600F428C1265121D4C0C /* SystemSettingsWindowLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSettingsWindowLocator.swift; sourceTree = ""; }; 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTextExtractor.swift; sourceTree = ""; }; 5A03E565A11581FD2150B142 /* CompletionRenderMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderMode.swift; sourceTree = ""; }; @@ -241,6 +246,7 @@ 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityResolver.swift; sourceTree = ""; }; 711293EA57808B9428C7B908 /* CotabbyAppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyAppEnvironment.swift; sourceTree = ""; }; 72B13136DF7318F3E96DF0D3 /* SuggestionCoordinator+Acceptance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Acceptance.swift"; sourceTree = ""; }; + 733BF6287BDE599B02A12271 /* CurrentWordSpellChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWordSpellChecker.swift; sourceTree = ""; }; 74BD1D4DB27D5D96D1E06096 /* DisplayCoordinateConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverter.swift; sourceTree = ""; }; 77B0121E7BB173F8A2B0B108 /* WindowScreenshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowScreenshotService.swift; sourceTree = ""; }; 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Lifecycle.swift"; sourceTree = ""; }; @@ -453,6 +459,14 @@ path = Focus; sourceTree = ""; }; + 6495533696D4461386265D05 /* Spelling */ = { + isa = PBXGroup; + children = ( + 733BF6287BDE599B02A12271 /* CurrentWordSpellChecker.swift */, + ); + path = Spelling; + sourceTree = ""; + }; 717EB52365B64107A6FB1126 /* Coordinators */ = { isa = PBXGroup; children = ( @@ -595,6 +609,7 @@ 1A748DD787833F29034EF2EA /* Input */, 1F3BF5BD8958D0D0C2E34AF1 /* Permission */, FFF55D3CE9FF3B16845823F7 /* Runtime */, + 6495533696D4461386265D05 /* Spelling */, 3B1B0D8E3400BE4FC26AF6B5 /* Suggestion */, E2E02780CEFBC8027DEEB398 /* UI */, 41435C2343611783BD33423E /* Utilities */, @@ -613,7 +628,9 @@ 96495E4147D828C0B1B22765 /* ClipboardContentDistiller.swift */, D3A2AC525DC664DB540D4F19 /* ClipboardRelevanceFilter.swift */, 53CF416511099C6818110F01 /* CompletionRenderModePolicy.swift */, + 58EDE4B9C30127C2019224E6 /* CorrectionPromptRenderer.swift */, C7B2D34A6F3AC9DFD61350F7 /* CotabbyDebugOptions.swift */, + 247561C626843957CFB4B632 /* CurrentWordExtractor.swift */, 74BD1D4DB27D5D96D1E06096 /* DisplayCoordinateConverter.swift */, CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */, 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */, @@ -772,9 +789,12 @@ 7C94725B4837DEC9ECF1BC54 /* CompletionRenderMode.swift in Sources */, 3985F0F2B3178DBB945B1064 /* CompletionRenderModePolicy.swift in Sources */, 8B2DFC860803C0A7C4D34A36 /* ContextBuffer.swift in Sources */, + 274E4154E941BFA3FBAC6961 /* CorrectionPromptRenderer.swift in Sources */, AA2E09FF7E430D66ECA8ECD5 /* CotabbyApp.swift in Sources */, FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */, 0D8241CD31942A25EC4E0EE4 /* CotabbyDebugOptions.swift in Sources */, + B6703DAE949C7FB034634424 /* CurrentWordExtractor.swift in Sources */, + C56ABA04AE27A9943368035C /* CurrentWordSpellChecker.swift in Sources */, 0431AE1DBEE36C90C7F39C19 /* CustomRulesCatalog.swift in Sources */, 4B4DDB569CAD806F765224DE /* CustomRulesEditor.swift in Sources */, 1003373E13779882503C0E9D /* DisplayCoordinateConverter.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index 459f96fc..d850aff7 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -43,6 +43,18 @@ extension SuggestionCoordinator { return passTabThrough(reason: "Key passed through because no valid suggestion was ready.") } + // Corrections always commit as a unit — Tab and the full-accept key both swap the typo'd + // word for the corrected one in one gesture. Partial acceptance would be incoherent here + // because the corrected word and the typo can disagree on prefix length. + if let session = interactionState.activeSession, + case let .correction(replacingLastWordOfLength) = session.kind { + return acceptCorrection( + session: session, + replacingLastWordOfLength: replacingLastWordOfLength, + keyName: keyName + ) + } + let preparation = fullText ? interactionState.prepareFullAcceptance(from: rawContext, overlayState: overlayState) : interactionState.prepareAcceptance( @@ -147,6 +159,59 @@ extension SuggestionCoordinator { } } + /// Commits a correction by replacing the trailing typo with the corrected word in one shot. + /// Returns true on success so the active accept tap consumes the key event; false routes + /// `Tab` (or whatever key was bound) back to the host app via `passTabThrough`. + private func acceptCorrection( + session: ActiveSuggestionSession, + replacingLastWordOfLength: Int, + keyName: String + ) -> Bool { + let correctedText = session.fullText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !correctedText.isEmpty else { + return passTabThrough(reason: "Key passed through because the correction text was empty.") + } + + let success = suggestionInserter.insert( + correctedText, + replacingLastCharacters: replacingLastWordOfLength + ) + guard success else { + let message = suggestionInserter.lastErrorMessage ?? "Correction insertion failed." + cancelPredictionWork() + clearSuggestion(clearDiagnostics: true) + hideOverlay(reason: "Overlay hidden because correction insertion failed.") + state = .idle + logStage( + "correction-insert-failed", + workID: currentWorkID, + generation: session.baseContext.generation, + message: message, + normalizedOutput: correctedText + ) + return false + } + + recordAcceptedWords(from: correctedText) + cancelPredictionWork() + latestGenerationNumber = session.baseContext.generation + clearSuggestion(clearDiagnostics: false) + hideOverlay(reason: "Overlay hidden because \(keyName) accepted a typo correction.") + latestAcceptanceAction = "Accepted typo correction with \(keyName)." + state = .idle + logStage( + "\(keyName)-accepted-correction", + workID: currentWorkID, + generation: session.baseContext.generation, + message: "Replaced the user's last word with the corrected version.", + normalizedOutput: correctedText + ) + // Re-arm prediction so the next keystroke can produce a fresh continuation now that the + // typo is gone — the user is likely to keep typing immediately after accepting. + schedulePrediction() + return true + } + /// Returns control of `Tab` to the host app and clears stale suggestion UI. func passTabThrough(reason: String) -> Bool { let generation = latestGenerationNumber @@ -335,7 +400,8 @@ extension SuggestionCoordinator { text: String, at caretRect: CGRect, context: FocusedInputContext, - isRightToLeft: Bool = false + isRightToLeft: Bool = false, + isCorrection: Bool = false ) { let geometry = SuggestionOverlayGeometry( caretRect: caretRect, @@ -343,7 +409,8 @@ extension SuggestionCoordinator { caretQuality: context.caretQuality, observedCharWidth: context.observedCharWidth, isRightToLeft: isRightToLeft, - focusChangeSequence: context.focusChangeSequence + focusChangeSequence: context.focusChangeSequence, + isCorrection: isCorrection ) if let message = overlayPresenter.present( text: text, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index b57b3798..7e97f667 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -70,6 +70,23 @@ extension SuggestionCoordinator { return } + // Typo gate runs synchronously off the AX snapshot. `NSSpellChecker` is sub-millisecond + // on one word, so the cost is negligible and we always know up front whether to switch + // into correction mode, suppress entirely, or proceed with a normal continuation. + let typoDecision = resolveTypoDecision(for: rawContext.precedingText) + if typoDecision.shouldSuppress { + clearSuggestion() + hideOverlay(reason: "Overlay hidden because the current word looks misspelled.") + state = .idle + logStage( + "typo-suppressed", + workID: workID, + message: "Skipped generation because the current word looks misspelled." + ) + return + } + let typoContext = typoDecision.typoContext + let context = interactionState.materializeContext(from: rawContext) let visualContextSummary = visualContextCoordinator.excerpt(for: context) let rawClipboard = settingsSnapshot.isClipboardContextEnabled @@ -91,7 +108,8 @@ extension SuggestionCoordinator { settings: settingsSnapshot, configuration: configuration, clipboardContext: clipboardContext, - visualContextSummary: visualContextSummary + visualContextSummary: visualContextSummary, + typoContext: typoContext ) latestGenerationNumber = context.generation latestPromptPreview = requestBuildResult.promptPreview @@ -118,7 +136,7 @@ extension SuggestionCoordinator { return } - await apply(result: result, workID: workID) + await apply(result: result, workID: workID, requestKind: request.kind) } catch SuggestionClientError.cancelled { return } catch { @@ -132,7 +150,9 @@ extension SuggestionCoordinator { } /// Promotes a generated result to `ready` only when it is still fresh for the current field. - func apply(result: SuggestionResult, workID: UInt64) async { + /// `requestKind` carries forward the request's kind so the session knows whether to behave + /// as a continuation or a correction without us round-tripping the original `SuggestionRequest`. + func apply(result: SuggestionResult, workID: UInt64, requestKind: SuggestionKind = .continuation) async { guard workController.isCurrent(workID) else { @@ -213,10 +233,13 @@ extension SuggestionCoordinator { latestLatencyMilliseconds = Int(result.latency * 1000) latestGenerationNumber = liveContext.generation + // Carry the request's kind into the session so the acceptance path can branch on + // continuation vs correction without needing the original request object back. let session = interactionState.startSession( fullText: result.text, liveContext: liveContext, - latency: result.latency + latency: result.latency, + kind: requestKind ) applySessionDiagnostics(session, acceptanceAction: "Generated new suggestion.") state = .ready(text: session.remainingText, latency: session.latency) @@ -225,7 +248,8 @@ extension SuggestionCoordinator { text: session.remainingText, at: liveContext.caretRect, context: liveContext, - isRightToLeft: TextDirectionDetector.isRightToLeft(liveContext.precedingText) + isRightToLeft: TextDirectionDetector.isRightToLeft(liveContext.precedingText), + isCorrection: requestKind.isCorrection ) logStage( "ready", @@ -237,6 +261,38 @@ extension SuggestionCoordinator { ) } + /// Result of the typo gate. `shouldSuppress` is true only when the user has the suppression + /// toggle on, has the corrections toggle off, and we detected a typo on the current word — + /// the one combination where we should drop the request without generating anything. + struct TypoDecision { + let shouldSuppress: Bool + let typoContext: TypoContext? + } + + /// Checks the trailing word for a typo and returns the appropriate gate decision. Extracted + /// from `generateFromCurrentFocus` to keep that function's cyclomatic complexity reasonable. + func resolveTypoDecision(for precedingText: String) -> TypoDecision { + guard settingsSnapshot.suppressCompletionsOnTypo else { + return TypoDecision(shouldSuppress: false, typoContext: nil) + } + guard let currentWord = CurrentWordExtractor.extract(from: precedingText) else { + return TypoDecision(shouldSuppress: false, typoContext: nil) + } + guard spellChecker.isTypo(currentWord.word) else { + return TypoDecision(shouldSuppress: false, typoContext: nil) + } + if settingsSnapshot.offerTypoCorrections { + return TypoDecision( + shouldSuppress: false, + typoContext: TypoContext( + typoWord: currentWord.word, + nativeCorrections: spellChecker.nativeCorrections(for: currentWord.word) + ) + ) + } + return TypoDecision(shouldSuppress: true, typoContext: nil) + } + /// Converts a runtime or engine failure into visible coordinator state and clears stale UI. func applyFailure(_ message: String, workID: UInt64) async { guard workController.isCurrent(workID) else { @@ -333,7 +389,8 @@ extension SuggestionCoordinator { text: reconciledSession.remainingText, at: liveContext.caretRect, context: liveContext, - isRightToLeft: TextDirectionDetector.isRightToLeft(liveContext.precedingText) + isRightToLeft: TextDirectionDetector.isRightToLeft(liveContext.precedingText), + isCorrection: reconciledSession.kind.isCorrection ) } if let advancement { diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/SuggestionCoordinator.swift index 4bde6b8a..1f8bf108 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator.swift @@ -50,6 +50,9 @@ final class SuggestionCoordinator: ObservableObject { let userDefaults: UserDefaults let overlayPresenter: SuggestionOverlayPresenter let logger: SuggestionDebugLogger + /// Drives the typo gate before each prediction. Owned at app scope so the same + /// `NSSpellChecker` document tag persists across the coordinator's lifetime. + let spellChecker: CurrentWordSpellChecker static let totalTabAcceptedWordCountDefaultsKey = "cotabbyTotalAcceptedWordCount" @@ -76,6 +79,7 @@ final class SuggestionCoordinator: ObservableObject { interactionState: SuggestionInteractionState, workController: SuggestionWorkController, configuration: SuggestionConfiguration, + spellChecker: CurrentWordSpellChecker, userDefaults: UserDefaults = .standard ) { let storedTotalTabAcceptedWordCount = userDefaults.integer( @@ -94,6 +98,7 @@ final class SuggestionCoordinator: ObservableObject { self.interactionState = interactionState self.workController = workController self.configuration = configuration + self.spellChecker = spellChecker self.userDefaults = userDefaults settingsSnapshot = suggestionSettings.snapshot // These collaborators isolate "how overlay/logging works" from "when the coordinator diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 13d2acd4..9d97f63e 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -133,6 +133,9 @@ final class CotabbyAppEnvironment { let interactionState = SuggestionInteractionState() let workController = SuggestionWorkController() + // Constructed once at app scope so the underlying `NSSpellChecker` document tag survives + // across coordinator state transitions instead of churning per-keystroke. + let spellChecker = CurrentWordSpellChecker() let suggestionCoordinator = SuggestionCoordinator( permissionManager: permissionManager, focusModel: focusModel, @@ -146,7 +149,8 @@ final class CotabbyAppEnvironment { visualContextCoordinator: visualContextCoordinator, interactionState: interactionState, workController: workController, - configuration: configuration + configuration: configuration, + spellChecker: spellChecker ) self.permissionManager = permissionManager diff --git a/Cotabby/Models/SuggestionEngineModels.swift b/Cotabby/Models/SuggestionEngineModels.swift index 3681fbce..dcab0721 100644 --- a/Cotabby/Models/SuggestionEngineModels.swift +++ b/Cotabby/Models/SuggestionEngineModels.swift @@ -76,4 +76,11 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable { /// based on caret geometry quality). Travels in the snapshot so consumers can react to changes /// without subscribing to the settings model directly. let mirrorPreference: MirrorPreference + /// When true, Cotabby checks the user's last word with `NSSpellChecker` and skips the normal + /// continuation request when the word looks misspelled. Avoids extending broken words. + let suppressCompletionsOnTypo: Bool + /// When true and `suppressCompletionsOnTypo` is also true, Cotabby switches into correction + /// mode on the same typo trigger: it asks the model for a context-aware fix and offers it as + /// a replace-the-typo suggestion. No effect when suppression is off. + let offerTypoCorrections: Bool } diff --git a/Cotabby/Models/SuggestionModels.swift b/Cotabby/Models/SuggestionModels.swift index f0367e10..cf04bf93 100644 --- a/Cotabby/Models/SuggestionModels.swift +++ b/Cotabby/Models/SuggestionModels.swift @@ -179,6 +179,34 @@ struct FocusedInputContext: Equatable, Sendable { } } +/// Distinguishes a normal forward continuation from a typo-correction reply. +/// +/// `.correction(replacingLastWordOfLength:)` tells the acceptance path how many trailing +/// characters of the host field to erase before inserting the corrected word, so we can express +/// "replace the typo" with the same suggestion machinery that handles plain continuations. +enum SuggestionKind: Equatable, Sendable { + case continuation + case correction(replacingLastWordOfLength: Int) + + var isCorrection: Bool { + if case .correction = self { + return true + } + return false + } +} + +/// Context the request factory needs when it's switching into correction mode. Built by the +/// coordinator from `CurrentWordExtractor` + `CurrentWordSpellChecker` and threaded into +/// `SuggestionRequestFactory.buildRequest` only when both the toggle is on and a typo was +/// detected on the current word. +struct TypoContext: Equatable, Sendable { + let typoWord: String + /// NSSpellChecker's own ranked guesses for the typo, best first. The LLM uses these as a + /// hint but is free to ignore them when surrounding context points to a different correction. + let nativeCorrections: [String] +} + /// One generation request sent from the coordinator into the suggestion engine. struct SuggestionRequest: Equatable, Sendable { let context: FocusedInputContext @@ -221,6 +249,9 @@ struct SuggestionRequest: Equatable, Sendable { let visualContextSummary: String? /// When enabled, the normalizer keeps multiple lines instead of truncating to the first line. let isMultiLineEnabled: Bool + /// Marks this request as a typo correction (with the length of the word to be replaced) or a + /// plain continuation. Defaults to `.continuation` so existing call sites stay unaffected. + let kind: SuggestionKind } /// The engine's normalized response, including raw model text for debugging. @@ -242,17 +273,23 @@ struct ActiveSuggestionSession: Equatable, Sendable { let fullText: String let consumedCharacterCount: Int let latency: TimeInterval + /// `.continuation` for normal forward suggestions; `.correction(replacingLastWordOfLength:)` + /// when the active session represents a typo-fix the user can accept. The acceptance path + /// branches on this so corrections always commit the whole word and replace the typo. + let kind: SuggestionKind init( baseContext: FocusedInputContext, fullText: String, consumedCharacterCount: Int = 0, - latency: TimeInterval + latency: TimeInterval, + kind: SuggestionKind = .continuation ) { self.baseContext = baseContext self.fullText = fullText self.consumedCharacterCount = min(max(consumedCharacterCount, 0), fullText.count) self.latency = latency + self.kind = kind } var acceptedText: String { @@ -284,7 +321,8 @@ struct ActiveSuggestionSession: Equatable, Sendable { baseContext: baseContext, fullText: fullText, consumedCharacterCount: self.consumedCharacterCount + max(consumedCharacters, 0), - latency: latency + latency: latency, + kind: kind ) } @@ -295,7 +333,8 @@ struct ActiveSuggestionSession: Equatable, Sendable { baseContext: baseContext, fullText: fullText, consumedCharacterCount: consumedCharacters, - latency: latency + latency: latency, + kind: kind ) } } @@ -361,6 +400,10 @@ struct SuggestionOverlayGeometry: Equatable, Sendable { /// per-session font-size stabilization on this value, so a field switch (or focus loss) starts /// a fresh size baseline. Defaults to 0 for tests that do not exercise session-scoped behavior. let focusChangeSequence: UInt64 + /// When `true`, the overlay is rendering a typo correction rather than a forward continuation. + /// `OverlayController` switches to a green tint on this signal so users can tell at a glance + /// that pressing the accept key will replace their last word, not extend it. + let isCorrection: Bool init( caretRect: CGRect, @@ -368,7 +411,8 @@ struct SuggestionOverlayGeometry: Equatable, Sendable { caretQuality: CaretGeometryQuality, observedCharWidth: CGFloat?, isRightToLeft: Bool, - focusChangeSequence: UInt64 = 0 + focusChangeSequence: UInt64 = 0, + isCorrection: Bool = false ) { self.caretRect = caretRect self.inputFrameRect = inputFrameRect @@ -376,6 +420,7 @@ struct SuggestionOverlayGeometry: Equatable, Sendable { self.observedCharWidth = observedCharWidth self.isRightToLeft = isRightToLeft self.focusChangeSequence = focusChangeSequence + self.isCorrection = isCorrection } } diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index 573a5b88..56e80173 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -38,6 +38,14 @@ final class SuggestionSettingsModel: ObservableObject { @Published private(set) var fullAcceptanceKeyCode: CGKeyCode @Published private(set) var fullAcceptanceKeyModifiers: ShortcutModifierMask @Published private(set) var fullAcceptanceKeyLabel: String + /// When on, Cotabby checks the user's last word with `NSSpellChecker` and skips the normal + /// continuation request when the word looks misspelled. Avoids the bad-feel of extending a + /// typo'd word with more text on top. + @Published private(set) var suppressCompletionsOnTypo: Bool + /// When on (and `suppressCompletionsOnTypo` is also on), the typo trigger switches into a + /// correction request instead of dropping the suggestion entirely: the model is asked for a + /// context-aware fix, which the user can accept to replace the typo. + @Published private(set) var offerTypoCorrections: Bool private let userDefaults: UserDefaults private static let isGloballyEnabledDefaultsKey = "cotabbyGloballyEnabled" @@ -67,6 +75,8 @@ final class SuggestionSettingsModel: ObservableObject { private static let fullAcceptanceKeyCodeDefaultsKey = "cotabbyFullAcceptanceKeyCode" private static let fullAcceptanceKeyModifiersDefaultsKey = "cotabbyFullAcceptanceKeyModifiers" private static let fullAcceptanceKeyLabelDefaultsKey = "cotabbyFullAcceptanceKeyLabel" + private static let suppressCompletionsOnTypoDefaultsKey = "cotabbySuppressCompletionsOnTypo" + private static let offerTypoCorrectionsDefaultsKey = "cotabbyOfferTypoCorrections" static let defaultAcceptanceKeyCode: CGKeyCode = 48 static let defaultAcceptanceKeyLabel = "Tab" @@ -201,6 +211,13 @@ final class SuggestionSettingsModel: ObservableObject { let resolvedFullAcceptanceKeyLabel = userDefaults.string(forKey: Self.fullAcceptanceKeyLabelDefaultsKey) ?? Self.defaultFullAcceptanceKeyLabel + // Default both to true: typo suppression and correction-on-typo are polish features and on + // balance the right out-of-box behavior. Existing users without a stored value get them on. + let resolvedSuppressCompletionsOnTypo = + userDefaults.object(forKey: Self.suppressCompletionsOnTypoDefaultsKey) as? Bool ?? true + let resolvedOfferTypoCorrections = + userDefaults.object(forKey: Self.offerTypoCorrectionsDefaultsKey) as? Bool ?? true + isGloballyEnabled = resolvedGloballyEnabled disabledAppRules = resolvedDisabledAppRules showIndicator = resolvedShowIndicator @@ -225,6 +242,8 @@ final class SuggestionSettingsModel: ObservableObject { fullAcceptanceKeyCode = resolvedFullAcceptanceKeyCode fullAcceptanceKeyModifiers = resolvedFullAcceptanceKeyModifiers fullAcceptanceKeyLabel = resolvedFullAcceptanceKeyLabel + suppressCompletionsOnTypo = resolvedSuppressCompletionsOnTypo + offerTypoCorrections = resolvedOfferTypoCorrections userDefaults.set(resolvedGloballyEnabled, forKey: Self.isGloballyEnabledDefaultsKey) persistDisabledAppRules(resolvedDisabledAppRules) @@ -253,6 +272,8 @@ final class SuggestionSettingsModel: ObservableObject { forKey: Self.fullAcceptanceKeyModifiersDefaultsKey ) userDefaults.set(resolvedFullAcceptanceKeyLabel, forKey: Self.fullAcceptanceKeyLabelDefaultsKey) + userDefaults.set(resolvedSuppressCompletionsOnTypo, forKey: Self.suppressCompletionsOnTypoDefaultsKey) + userDefaults.set(resolvedOfferTypoCorrections, forKey: Self.offerTypoCorrectionsDefaultsKey) // The custom indicator icon feature was removed; scrub any previously-persisted PNG so // users who picked one in an older build get the default cat icon back automatically. @@ -279,7 +300,9 @@ final class SuggestionSettingsModel: ObservableObject { isMultiLineEnabled: isMultiLineEnabled, autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, isFastModeEnabled: isFastModeEnabled, - mirrorPreference: mirrorPreference + mirrorPreference: mirrorPreference, + suppressCompletionsOnTypo: suppressCompletionsOnTypo, + offerTypoCorrections: offerTypoCorrections ) } @@ -344,6 +367,22 @@ final class SuggestionSettingsModel: ObservableObject { userDefaults.set(enabled, forKey: Self.autoAcceptTrailingPunctuationDefaultsKey) } + func setSuppressCompletionsOnTypo(_ enabled: Bool) { + guard suppressCompletionsOnTypo != enabled else { + return + } + suppressCompletionsOnTypo = enabled + userDefaults.set(enabled, forKey: Self.suppressCompletionsOnTypoDefaultsKey) + } + + func setOfferTypoCorrections(_ enabled: Bool) { + guard offerTypoCorrections != enabled else { + return + } + offerTypoCorrections = enabled + userDefaults.set(enabled, forKey: Self.offerTypoCorrectionsDefaultsKey) + } + func setDebounceMilliseconds(_ value: Int) { let clamped = max(10, min(500, value)) guard debounceMilliseconds != clamped else { @@ -785,7 +824,14 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { $selectedEngine, $selectedWordCountPreset ), - Publishers.CombineLatest3($isClipboardContextEnabled, $isFastModeEnabled, $mirrorPreference), + // Five fields with no native CombineLatest5 — pair the two typo toggles into one + // inner publisher so the outer slot count stays at 4. + Publishers.CombineLatest4( + $isClipboardContextEnabled, + $isFastModeEnabled, + $mirrorPreference, + Publishers.CombineLatest($suppressCompletionsOnTypo, $offerTypoCorrections) + ), Publishers.CombineLatest3($userName, $customRules, $responseLanguages), Publishers.CombineLatest4( $debounceMilliseconds, @@ -796,7 +842,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { ) .map { combinedSettings, presentationToggles, profile, timing in let (globallyEnabled, disabledAppRules, engine, wordCountPreset) = combinedSettings - let (clipboardContextEnabled, fastModeEnabled, mirrorPreference) = presentationToggles + let (clipboardContextEnabled, fastModeEnabled, mirrorPreference, typoToggles) = presentationToggles + let (suppressOnTypo, offerCorrections) = typoToggles let (userName, customRules, responseLanguages) = profile let (debounce, focusPoll, multiLine, autoAcceptPunctuation) = timing return SuggestionSettingsSnapshot( @@ -813,7 +860,9 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { isMultiLineEnabled: multiLine, autoAcceptTrailingPunctuation: autoAcceptPunctuation, isFastModeEnabled: fastModeEnabled, - mirrorPreference: mirrorPreference + mirrorPreference: mirrorPreference, + suppressCompletionsOnTypo: suppressOnTypo, + offerTypoCorrections: offerCorrections ) } .removeDuplicates() diff --git a/Cotabby/Models/SuggestionSubsystemContracts.swift b/Cotabby/Models/SuggestionSubsystemContracts.swift index 6b2c74ed..b2969d24 100644 --- a/Cotabby/Models/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/SuggestionSubsystemContracts.swift @@ -79,6 +79,13 @@ protocol SuggestionInserting: AnyObject { var lastErrorMessage: String? { get } func insert(_ suggestion: String) -> Bool + + /// Replaces the trailing `deleteCount` characters of the host field with `suggestion` by first + /// synthesizing `deleteCount` backspace events and then inserting the replacement. Used by the + /// correction-acceptance path so a typo'd word can be swapped for the corrected word in one + /// gesture. Implementations must arm `InputSuppressionController` for every synthetic event + /// they emit so the global tap doesn't observe its own writes. + func insert(_ suggestion: String, replacingLastCharacters deleteCount: Int) -> Bool } @MainActor diff --git a/Cotabby/Services/Spelling/CurrentWordSpellChecker.swift b/Cotabby/Services/Spelling/CurrentWordSpellChecker.swift new file mode 100644 index 00000000..946b6afd --- /dev/null +++ b/Cotabby/Services/Spelling/CurrentWordSpellChecker.swift @@ -0,0 +1,60 @@ +import AppKit +import Foundation + +/// File overview: +/// Thin wrapper around `NSSpellChecker` for the typo gate. We isolate the AppKit dependency here +/// so the prediction pipeline depends on a focused, testable surface rather than `NSSpellChecker` +/// directly. +/// +/// Why a wrapper at all: +/// - We need a stable per-app spell document tag so our checks don't share state with other apps. +/// - The "is this word a typo?" question has a specific NSRange-equality interpretation that we +/// want to spell out once and never get wrong. +/// - Mockability for tests later. +@MainActor +final class CurrentWordSpellChecker { + /// Document tag identifies our "spell session" inside `NSSpellChecker.shared`. Using a unique + /// tag avoids cross-contamination with whatever spellcheck state other apps have armed. + private let documentTag: Int + + init() { + documentTag = NSSpellChecker.uniqueSpellDocumentTag() + // We don't pin a language. The shared checker picks up the system language and, with this + // flag on, will swap as the user's text suggests a different one — useful for users who + // code-switch between languages mid-paragraph. + NSSpellChecker.shared.automaticallyIdentifiesLanguages = true + } + + /// Returns true when NSSpellChecker considers the entire word misspelled. We require the + /// returned range to cover the whole word starting at offset 0 — otherwise we'd misfire on + /// words like "I'm" where only part of the token is flagged. + func isTypo(_ word: String, language: String? = nil) -> Bool { + guard !word.isEmpty else { return false } + let misspelledRange = NSSpellChecker.shared.checkSpelling( + of: word, + startingAt: 0, + language: language, + wrap: false, + inSpellDocumentWithTag: documentTag, + wordCount: nil + ) + guard misspelledRange.location == 0 else { + return false + } + return misspelledRange.length == (word as NSString).length + } + + /// Returns NSSpellChecker's own ranked corrections for the word (best first). Used as a hint + /// to the LLM in correction mode — the model can override it when surrounding context picks + /// a better fix. Empty array means the checker had no suggestions to offer. + func nativeCorrections(for word: String, language: String? = nil) -> [String] { + let fullRange = NSRange(location: 0, length: (word as NSString).length) + let guesses = NSSpellChecker.shared.guesses( + forWordRange: fullRange, + in: word, + language: language, + inSpellDocumentWithTag: documentTag + ) + return guesses ?? [] + } +} diff --git a/Cotabby/Services/Suggestion/SuggestionInserter.swift b/Cotabby/Services/Suggestion/SuggestionInserter.swift index 619145a7..5acd4a64 100644 --- a/Cotabby/Services/Suggestion/SuggestionInserter.swift +++ b/Cotabby/Services/Suggestion/SuggestionInserter.swift @@ -20,6 +20,21 @@ final class SuggestionInserter { /// Posts a Unicode keydown/keyup pair for the accepted suggestion and reports any insertion failure. func insert(_ suggestion: String) -> Bool { + insert(suggestion, replacingLastCharacters: 0) + } + + /// Replaces the trailing `deleteCount` characters of the host field with `suggestion`. The + /// implementation: + /// 1. Arms `InputSuppressionController` for `deleteCount + 1` synthetic key-down events so + /// the global event tap ignores its own writes during the replacement. + /// 2. Posts `deleteCount` backspace events using `kVK_Delete` (virtual key 51). + /// 3. Posts one Unicode keydown/keyup pair carrying the replacement string. + /// + /// We synthesize backspaces rather than mutating the AX value directly so the field's own + /// undo stack, IME state, and rich-text formatting machinery all see the change as if the + /// user typed it themselves — that matches how `insert` already behaves for forward inserts + /// and avoids opening a second, app-specific failure mode. + func insert(_ suggestion: String, replacingLastCharacters deleteCount: Int) -> Bool { let normalized = suggestion.replacingOccurrences(of: "\r", with: "") guard !normalized.isEmpty else { lastErrorMessage = "Suggestion was empty." @@ -27,6 +42,23 @@ final class SuggestionInserter { return false } + let backspaceCount = max(deleteCount, 0) + // Arm suppression to cover every synthetic keydown we're about to post: one per backspace + // plus one for the Unicode insert. The expiry window (1s, set inside the controller) is + // wide enough that the whole sequence completes before suppression decays. + suppressionController.registerSyntheticInsertion(expectedKeyDownCount: backspaceCount + 1) + + for _ in 0.. 0 { + CotabbyLogger.suggestion.debug( + "Replaced last \(backspaceCount) characters with \(normalized.count) characters via synthetic keystrokes" + ) + } else { + CotabbyLogger.suggestion.debug("Inserted \(normalized.count) characters via synthetic keystroke") + } return true } } diff --git a/Cotabby/Services/Suggestion/SuggestionInteractionState.swift b/Cotabby/Services/Suggestion/SuggestionInteractionState.swift index 1eb6e291..1f94e1c3 100644 --- a/Cotabby/Services/Suggestion/SuggestionInteractionState.swift +++ b/Cotabby/Services/Suggestion/SuggestionInteractionState.swift @@ -50,11 +50,17 @@ final class SuggestionInteractionState { clearSuggestion() } - func startSession(fullText: String, liveContext: FocusedInputContext, latency: TimeInterval) -> ActiveSuggestionSession { + func startSession( + fullText: String, + liveContext: FocusedInputContext, + latency: TimeInterval, + kind: SuggestionKind = .continuation + ) -> ActiveSuggestionSession { let session = ActiveSuggestionSession( baseContext: liveContext, fullText: fullText, - latency: latency + latency: latency, + kind: kind ) activeSession = session pendingInsertionConsumedCount = nil diff --git a/Cotabby/Services/UI/OverlayController.swift b/Cotabby/Services/UI/OverlayController.swift index 75e74c63..151aa35f 100644 --- a/Cotabby/Services/UI/OverlayController.swift +++ b/Cotabby/Services/UI/OverlayController.swift @@ -163,7 +163,8 @@ final class OverlayController: SuggestionOverlayControlling { fontSize: fontSize, customColor: customGhostColor, keycapLabel: acceptanceHintLabel, - opacity: ghostOpacity + opacity: ghostOpacity, + isCorrection: geometry.isCorrection ) let contentView: NSHostingView @@ -294,8 +295,20 @@ private struct GhostSuggestionView: View { /// User-controlled fade for the suggestion text, in [0.3, 1.0]. Applied only to the ghost text, /// not the keycap, so the acceptance hint stays legible at low opacities. let opacity: Double + /// When true, the suggestion is replacing a typo'd word. We render in green to signal that + /// accepting will swap the user's last word, not extend the text. The custom color override + /// is intentionally bypassed in this mode — semantic communication beats personalization. + let isCorrection: Bool var ghostColor: Color { + if isCorrection { + // Tuned per color scheme so the green stays legible in both modes without dropping + // below the WCAG contrast floor against typical text-field backgrounds. + let correctionColor = colorScheme == .dark + ? Color(red: 0.45, green: 0.85, blue: 0.45) + : Color(red: 0.15, green: 0.60, blue: 0.20) + return correctionColor.opacity(opacity) + } let baseColor = customColor ?? ( colorScheme == .dark diff --git a/Cotabby/Support/CorrectionPromptRenderer.swift b/Cotabby/Support/CorrectionPromptRenderer.swift new file mode 100644 index 00000000..77400eee --- /dev/null +++ b/Cotabby/Support/CorrectionPromptRenderer.swift @@ -0,0 +1,72 @@ +import Foundation + +/// File overview: +/// Renders the prompt sent to the local llama runtime when Cotabby is in correction mode — +/// the user's last word looks misspelled and we're asking the model for a context-aware fix. +/// +/// Why a separate file from `LlamaPromptRenderer`: +/// the correction prompt has a fundamentally different output contract (one corrected word, no +/// continuation) and mixing it into the general autocomplete renderer would force a mode-flag +/// branch through every section. Keeping them apart preserves the simple "one prompt = one +/// shape" rule for both. +enum CorrectionPromptRenderer { + struct Metadata { + let applicationName: String + let userName: String? + let languageInstruction: String? + } + + static func prompt( + precedingTextBeforeTypo: String, + typoWord: String, + nativeCorrectionsHint: [String], + metadata: Metadata + ) -> String { + let applicationName = metadata.applicationName + let userName = metadata.userName + let languageInstruction = metadata.languageInstruction + var sections = [ + "Task:", + "- The user's most recent word looks misspelled. Output the corrected word only.", + "- Reply with one word. No explanation, no quotes, no punctuation surrounding it, no markdown.", + "- Match the user's intended capitalization when it's obvious from the typo.", + "- Use the surrounding context to pick the correction that fits.", + "- If you can't confidently improve the word, repeat it verbatim and we'll drop the suggestion." + ] + + if let name = userName, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + sections.append("") + sections.append("The user's name is \(name).") + } + + if let languageInstruction, !languageInstruction.isEmpty { + sections.append("") + sections.append(languageInstruction) + } + + sections.append("") + sections.append("Screen context:") + sections.append("User is on \(applicationName).") + + sections.append("") + sections.append("Text written before the typo (may be empty):") + sections.append(precedingTextBeforeTypo) + + // Top three NSSpellChecker guesses go in as a hint, not a constraint. The model is free to + // ignore them when surrounding context points elsewhere — e.g. spellchecker says "myth" + // for "myy" but the sentence makes "my" the obvious fix. + let trimmedHints = nativeCorrectionsHint + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(3) + if !trimmedHints.isEmpty { + sections.append("") + sections.append("Spellchecker hints (use only when they fit the context): \(trimmedHints.joined(separator: ", "))") + } + + sections.append("") + sections.append("Typo: \(typoWord)") + sections.append("Corrected word:") + return sections.joined(separator: "\n") + } +} diff --git a/Cotabby/Support/CurrentWordExtractor.swift b/Cotabby/Support/CurrentWordExtractor.swift new file mode 100644 index 00000000..da5e3fe4 --- /dev/null +++ b/Cotabby/Support/CurrentWordExtractor.swift @@ -0,0 +1,71 @@ +import Foundation + +/// File overview: +/// Pure helper that pulls the trailing word out of the text before the caret. Used by the typo +/// gate before we decide whether to suppress a completion or flip into correction mode. +/// +/// We intentionally do not lean on `NSLinguisticTagger` or `NLTokenizer` here — both pull in +/// language detection that's overkill for the "is the cursor inside or just after a word" +/// question. A whitespace walk is faster, deterministic, and easy to reason about. +enum CurrentWordExtractor { + 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 + } + + /// Returns the trailing word at the cursor, or `nil` when: + /// - the cursor is on (or just after) whitespace, + /// - the trailing token is implausible as natural language (URL, code, all-caps acronym, + /// digits), so `NSSpellChecker` would over-flag it, + /// - the trailing token is too short (single-letter words are too noisy to act on). + static func extract(from precedingText: String) -> Result? { + guard let lastCharacter = precedingText.last, !lastCharacter.isWhitespace else { + return nil + } + + // Walk back to the previous whitespace boundary; that's the start of the trailing word. + var startIndex = precedingText.endIndex + while startIndex > precedingText.startIndex { + let prior = precedingText.index(before: startIndex) + if precedingText[prior].isWhitespace { + break + } + startIndex = prior + } + + let word = String(precedingText[startIndex.. Bool { + guard word.count >= 2 else { return false } + + let codeLikeCharacters: Set = [ + "@", "/", "\\", "_", ":", ".", "#", "<", ">", + "(", ")", "[", "]", "{", "}", + "$", "%", "^", "*", "=", "+", "|", "~", "`" + ] + for character in word { + if character.isNumber { return false } + if codeLikeCharacters.contains(character) { return false } + } + + // All-uppercase tokens are almost always acronyms (USA, HTTP, JSON). NSSpellChecker + // flags many of them as typos but that's not useful here. + let letters = word.filter { $0.isLetter } + if !letters.isEmpty, letters.allSatisfy({ $0.isUppercase }) { + return false + } + + return true + } +} diff --git a/Cotabby/Support/SuggestionRequestFactory.swift b/Cotabby/Support/SuggestionRequestFactory.swift index 7114f0f7..726670df 100644 --- a/Cotabby/Support/SuggestionRequestFactory.swift +++ b/Cotabby/Support/SuggestionRequestFactory.swift @@ -29,12 +29,17 @@ enum SuggestionRequestFactory { } /// Builds the generation request plus the exact prompt preview used by Cotabby's diagnostics UI. + /// + /// When `typoContext` is non-nil, the factory swaps to a correction-flavored prompt template + /// and stamps `request.kind = .correction(...)` so downstream acceptance knows to replace the + /// typo'd word instead of appending forward text. static func buildRequest( context: FocusedInputContext, settings: SuggestionSettingsSnapshot, configuration: SuggestionConfiguration, clipboardContext: String? = nil, - visualContextSummary: String? = nil + visualContextSummary: String? = nil, + typoContext: TypoContext? = nil ) -> SuggestionRequestBuildResult { let prefixText = truncatedPromptPrefix( from: context.precedingText, @@ -54,27 +59,58 @@ enum SuggestionRequestFactory { let boundedVisualContextSummary = activeVisualContextSummary( rawSummary: visualContextSummary ) - let prompt = LlamaPromptRenderer.prompt( - prefixText: prefixText, - applicationName: context.applicationName, - completionLengthInstruction: completionLengthInstruction, - userName: userName, - customRules: customRules, - languageInstruction: languageInstruction, - clipboardContext: boundedClipboardContext, - visualContextSummary: boundedVisualContextSummary - ) + + let prompt: String + let kind: SuggestionKind + let maxPredictionTokens: Int + + if let typoContext { + // Correction mode: ask the model for a single-word fix. The prefix passed to the + // renderer is the text *before* the typo so the model sees surrounding context but + // never sees the typo word twice (the prompt provides it explicitly). + let prefixWithoutTypo = Self.precedingTextWithoutTrailingWord( + prefixText: prefixText, + trailingWord: typoContext.typoWord + ) + prompt = CorrectionPromptRenderer.prompt( + precedingTextBeforeTypo: prefixWithoutTypo, + typoWord: typoContext.typoWord, + nativeCorrectionsHint: typoContext.nativeCorrections, + metadata: CorrectionPromptRenderer.Metadata( + applicationName: context.applicationName, + userName: userName, + languageInstruction: languageInstruction + ) + ) + kind = .correction(replacingLastWordOfLength: typoContext.typoWord.count) + // One word fits comfortably in a small token budget; oversized budgets risk the model + // adding a continuation we'd then have to strip. + maxPredictionTokens = 6 + } else { + prompt = LlamaPromptRenderer.prompt( + prefixText: prefixText, + applicationName: context.applicationName, + completionLengthInstruction: completionLengthInstruction, + userName: userName, + customRules: customRules, + languageInstruction: languageInstruction, + clipboardContext: boundedClipboardContext, + visualContextSummary: boundedVisualContextSummary + ) + kind = .continuation + maxPredictionTokens = activeMaxPredictionTokens( + configuration: configuration, + wordCountPreset: settings.selectedWordCountPreset, + isMultiLineEnabled: settings.isMultiLineEnabled + ) + } let request = SuggestionRequest( context: context, prefixText: prefixText, prompt: prompt, generation: context.generation, - maxPredictionTokens: activeMaxPredictionTokens( - configuration: configuration, - wordCountPreset: settings.selectedWordCountPreset, - isMultiLineEnabled: settings.isMultiLineEnabled - ), + maxPredictionTokens: maxPredictionTokens, temperature: configuration.temperature, topK: configuration.topK, topP: configuration.topP, @@ -88,7 +124,8 @@ enum SuggestionRequestFactory { languageInstruction: languageInstruction, clipboardContext: boundedClipboardContext, visualContextSummary: boundedVisualContextSummary, - isMultiLineEnabled: settings.isMultiLineEnabled + isMultiLineEnabled: settings.isMultiLineEnabled, + kind: kind ) return SuggestionRequestBuildResult( @@ -97,6 +134,20 @@ enum SuggestionRequestFactory { ) } + /// Drops the trailing word from `prefixText` when it matches `trailingWord`. Used by the + /// correction prompt so the context shown to the model stops just before the typo, avoiding + /// the awkward duplication of having the typo appear inline *and* under "Typo:". + private static func precedingTextWithoutTrailingWord( + prefixText: String, + trailingWord: String + ) -> String { + guard prefixText.hasSuffix(trailingWord) else { + return prefixText + } + return String(prefixText.dropLast(trailingWord.count)) + .trimmingCharacters(in: CharacterSet.whitespaces) + } + /// Keep only the latest short word tail to prevent long stale context from steering output. /// /// Exposed (non-private) so the coordinator can compute the same bounded window before diff --git a/Cotabby/UI/SettingsView.swift b/Cotabby/UI/SettingsView.swift index d758582b..98de8bbd 100644 --- a/Cotabby/UI/SettingsView.swift +++ b/Cotabby/UI/SettingsView.swift @@ -182,6 +182,19 @@ struct SettingsView: View { Toggle("Accept Punctuation With Word", isOn: autoAcceptTrailingPunctuationBinding) .cotabbyHelp("With this on, accepting a word also takes punctuation attached to it, like the \"?\" in \"you?\".") + Toggle("Hide Suggestions on Typo", isOn: suppressCompletionsOnTypoBinding) + .help( + "Hide the continuation when the word you're currently typing looks misspelled, " + + "so completions don't pile on top of a broken word." + ) + + Toggle("Offer Corrections on Typo", isOn: offerTypoCorrectionsBinding) + .help( + "When the current word looks misspelled, suggest a fix in green. Pressing the " + + "accept key replaces the typo with the corrected word." + ) + .disabled(!suggestionSettings.suppressCompletionsOnTypo) + Toggle("Include Clipboard Context", isOn: clipboardContextEnabledBinding) .cotabbyHelp("Include your latest clipboard contents in the prompt so completions can reference what you copied.") @@ -764,6 +777,20 @@ struct SettingsView: View { ) } + private var suppressCompletionsOnTypoBinding: Binding { + Binding( + get: { suggestionSettings.suppressCompletionsOnTypo }, + set: { suggestionSettings.setSuppressCompletionsOnTypo($0) } + ) + } + + private var offerTypoCorrectionsBinding: Binding { + Binding( + get: { suggestionSettings.offerTypoCorrections }, + set: { suggestionSettings.setOfferTypoCorrections($0) } + ) + } + private var debounceMillisecondsBinding: Binding { Binding( get: { suggestionSettings.debounceMilliseconds }, diff --git a/CotabbyTests/CotabbyTestFixtures.swift b/CotabbyTests/CotabbyTestFixtures.swift index 2d2d1f2f..89893661 100644 --- a/CotabbyTests/CotabbyTestFixtures.swift +++ b/CotabbyTests/CotabbyTestFixtures.swift @@ -127,7 +127,8 @@ enum CotabbyTestFixtures { languageInstruction: languageInstruction, clipboardContext: clipboardContext, visualContextSummary: visualContextSummary, - isMultiLineEnabled: isMultiLineEnabled + isMultiLineEnabled: isMultiLineEnabled, + kind: .continuation ) } @@ -221,7 +222,9 @@ enum CotabbyTestFixtures { isMultiLineEnabled: Bool = false, autoAcceptTrailingPunctuation: Bool = true, isFastModeEnabled: Bool = false, - mirrorPreference: MirrorPreference = .auto + mirrorPreference: MirrorPreference = .auto, + suppressCompletionsOnTypo: Bool = false, + offerTypoCorrections: Bool = false ) -> SuggestionSettingsSnapshot { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, @@ -237,7 +240,9 @@ enum CotabbyTestFixtures { isMultiLineEnabled: isMultiLineEnabled, autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, isFastModeEnabled: isFastModeEnabled, - mirrorPreference: mirrorPreference + mirrorPreference: mirrorPreference, + suppressCompletionsOnTypo: suppressCompletionsOnTypo, + offerTypoCorrections: offerTypoCorrections ) } } diff --git a/CotabbyTests/LlamaPromptRendererTests.swift b/CotabbyTests/LlamaPromptRendererTests.swift index 1ac9b039..61548050 100644 --- a/CotabbyTests/LlamaPromptRendererTests.swift +++ b/CotabbyTests/LlamaPromptRendererTests.swift @@ -234,7 +234,8 @@ final class LlamaPromptRendererTests: XCTestCase { languageInstruction: nil, clipboardContext: nil, visualContextSummary: nil, - isMultiLineEnabled: false + isMultiLineEnabled: false, + kind: .continuation ) } }