diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index 9fc841ba..a68b3610 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -242,29 +242,25 @@ extension SuggestionCoordinator { keyName: String, rawContext: FocusedInputSnapshot ) -> Bool { - let correctedText = session.fullText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !correctedText.isEmpty else { - return passTabThrough(reason: "Key passed through because the correction text was empty.") - } - // Confirm the live field still ends with the exact word we offered to correct (tolerating // one trailing space the user pressed after it). Comparing the word itself, not just its // length, closes the window where a keystroke between the last AX poll and this Tab swapped // in a different same-length word; if it diverged, pass the key through rather than delete // the wrong text. guard case let .correction(typoWord) = session.kind, - let live = CurrentWordExtractor.extractTrailingWord(from: rawContext.precedingText), - live.result.word == typoWord else { + let replacement = TypoCorrectionReplacementPlanner.plan( + precedingText: rawContext.precedingText, + expectedTypo: typoWord, + correctedWord: session.fullText, + requiresTrailingSpace: false + ) else { return passTabThrough(reason: "Key passed through because the word to correct changed.") } - // Delete the typo plus any single trailing space the user added after it, then re-insert the - // correction followed by that same space, so `nmae |` becomes `name |` with the spacing and - // caret intact. `replace` deletes by UTF-16 unit (its parameter name and the emoji path's - // contract), which equals the on-screen character count for the NFC text macOS AX delivers. - let trailingSpaces = String(repeating: " ", count: live.trailingSpaceCount) - let deletingUTF16Count = (typoWord as NSString).length + live.trailingSpaceCount - guard suggestionInserter.replace(deletingUTF16Count: deletingUTF16Count, with: correctedText + trailingSpaces) else { + guard suggestionInserter.replace( + deletingUTF16Count: replacement.deletingUTF16Count, + with: replacement.replacementText + ) else { let message = suggestionInserter.lastErrorMessage ?? "Correction insertion failed." cancelPredictionWork() clearSuggestion(clearDiagnostics: true) @@ -275,12 +271,12 @@ extension SuggestionCoordinator { workID: currentWorkID, generation: session.baseContext.generation, message: message, - normalizedOutput: correctedText + normalizedOutput: replacement.replacementText ) return false } - recordAcceptedWords(from: correctedText) + recordAcceptedWords(from: replacement.replacementText) cancelPredictionWork() latestGenerationNumber = session.baseContext.generation clearSuggestion(clearDiagnostics: false) @@ -292,7 +288,7 @@ extension SuggestionCoordinator { workID: currentWorkID, generation: session.baseContext.generation, message: "Replaced the user's last word with the corrected version.", - normalizedOutput: correctedText + normalizedOutput: replacement.replacementText ) // Re-arm prediction so the next keystroke can produce a fresh continuation now that the typo // is gone — the user usually keeps typing right after accepting. diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index e3773fa2..b6a50101 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -60,9 +60,9 @@ extension SuggestionCoordinator { // Typo gate: before building a normal continuation, check the current word with // NSSpellChecker. A misspelled word either suppresses the continuation (so completions never - // pile onto a broken word) or, when corrections are enabled, presents a native spell-checker - // fix the user can accept to replace the typo. Native correction is instant and needs no - // model generation, so it is handled synchronously and returns before any request runs. + // pile onto a broken word), presents a green correction, or automatically fixes a completed + // word after Space. Native correction is instant and needs no model generation, so it is + // handled synchronously and returns before any request runs. if handleTypoGate(rawContext: rawContext, workID: workID) { return } @@ -141,15 +141,15 @@ extension SuggestionCoordinator { } } - /// Runs the typo gate for the current word. Returns `true` when it handled the cycle (suppressed - /// the continuation or presented a correction) and the caller should stop; `false` to proceed - /// with a normal continuation. Kept separate so `generateFromCurrentFocus` stays within the - /// project's cyclomatic-complexity budget. + /// Runs the typo gate for the current word. Returns `true` when it handled the cycle by suppressing, + /// offering, or applying a correction; `false` proceeds with a normal continuation. Kept separate + /// so `generateFromCurrentFocus` stays within the project's cyclomatic-complexity budget. private func handleTypoGate(rawContext: FocusedInputSnapshot, workID: UInt64) -> Bool { switch TypoGate.resolve( precedingText: rawContext.precedingText, suppressCompletionsOnTypo: settingsSnapshot.suppressCompletionsOnTypo, offerTypoCorrections: settingsSnapshot.offerTypoCorrections, + automaticallyFixTypos: settingsSnapshot.automaticallyFixTypos, isTypo: { spellChecker.isTypo($0) }, // Correction word: SymSpell (frequency-ranked, edit distance ≤ 2) first; fall back to the // NSSpellChecker guess while SymSpell's index is still loading or when it has no match. @@ -167,7 +167,7 @@ extension SuggestionCoordinator { message: "Skipped generation because the current word looks misspelled." ) return true - case let .correct(word, correctedWord): + case let .offerCorrection(word, correctedWord): presentCorrection( typoWord: word, correctedWord: correctedWord, @@ -175,9 +175,84 @@ extension SuggestionCoordinator { workID: workID ) return true + case let .applyCorrection(word, correctedWord): + applyAutomaticCorrection( + typoWord: word, + correctedWord: correctedWord, + rawContext: rawContext, + workID: workID + ) + return true } } + /// Replaces a completed typo after Space without creating a visible correction session. + /// + /// Automatic mutation is intentionally limited to a committed word boundary. The shared planner + /// revalidates the exact trailing word and requires that Space to still be present, so a stale AX + /// snapshot or a user who resumed typing cannot make Cotabby delete an unrelated suffix. + private func applyAutomaticCorrection( + typoWord: String, + correctedWord: String, + rawContext: FocusedInputSnapshot, + workID: UInt64 + ) { + let liveContext = interactionState.materializeContext(from: rawContext) + latestGenerationNumber = liveContext.generation + guard let replacement = TypoCorrectionReplacementPlanner.plan( + precedingText: rawContext.precedingText, + expectedTypo: typoWord, + correctedWord: correctedWord, + requiresTrailingSpace: true + ) else { + clearSuggestion() + hideOverlay(reason: "Overlay hidden because the automatic correction target changed.") + state = .idle + logStage( + "typo-auto-correction-stale", + workID: workID, + generation: liveContext.generation, + message: "Skipped automatic correction because the completed word no longer matched." + ) + return + } + + guard suggestionInserter.replace( + deletingUTF16Count: replacement.deletingUTF16Count, + with: replacement.replacementText + ) else { + let message = suggestionInserter.lastErrorMessage ?? "Automatic correction insertion failed." + cancelPredictionWork() + clearSuggestion(clearDiagnostics: true) + hideOverlay(reason: "Overlay hidden because automatic correction insertion failed.") + state = .idle + logStage( + "typo-auto-correction-failed", + workID: workID, + generation: liveContext.generation, + message: message, + normalizedOutput: correctedWord + ) + return + } + + cancelPredictionWork() + clearSuggestion(clearDiagnostics: false) + hideOverlay(reason: "Overlay hidden because Cotabby automatically fixed a typo.") + latestAcceptanceAction = "Automatically corrected \"\(typoWord)\" to \"\(correctedWord)\"." + state = .idle + logStage( + "typo-auto-corrected", + workID: workID, + generation: liveContext.generation, + message: "Automatically replaced the completed misspelled word after Space.", + normalizedOutput: correctedWord + ) + // Synthetic replacement is asynchronous from the host editor's perspective. Poll until AX + // publishes the corrected text before asking for the next continuation. + schedulePredictionAfterHostPublishDelay() + } + /// Presents a native spell-checker correction as a replace-the-word suggestion, with no model /// generation. The session carries `.correction(typoWord:)` so the acceptance /// path swaps the typo for the fix, and the overlay renders green so the user can tell at a diff --git a/Cotabby/Models/SuggestionEngineModels.swift b/Cotabby/Models/SuggestionEngineModels.swift index d93af8e8..46e5ce8c 100644 --- a/Cotabby/Models/SuggestionEngineModels.swift +++ b/Cotabby/Models/SuggestionEngineModels.swift @@ -124,6 +124,10 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable { /// When true (and `suppressCompletionsOnTypo` is also true), a detected typo is offered a native /// spell-checker correction instead of being silently suppressed. No effect when suppression is off. let offerTypoCorrections: Bool + /// When true (and typo suppression is on), a correction is applied automatically after the user + /// commits the misspelled word with Space. The word boundary prevents pauses in unfinished words + /// from triggering destructive edits. + let automaticallyFixTypos: Bool /// Single chokepoint that picks between the preset's range and the user's custom range. /// Every downstream consumer (token-budget math, prompt-instruction text, UI labels in the diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index 021bf2ea..0894d1c9 100644 --- a/Cotabby/Models/SuggestionSettingsData.swift +++ b/Cotabby/Models/SuggestionSettingsData.swift @@ -33,6 +33,10 @@ struct SuggestionSettingsData: Equatable { /// When on (and `suppressCompletionsOnTypo` is also on), a detected typo switches into correction /// mode: Cotabby offers the spell-checker's fix as a green replace-the-word suggestion. var offerTypoCorrections: Bool + /// When on (and typo suppression is also on), a completed misspelled word is replaced as soon as + /// the user presses Space. This remains separate from `offerTypoCorrections`: users may keep the + /// green preview while typing, disable it, or use both behaviors together. + var automaticallyFixTypos: Bool var isPerformanceTrackingEnabled: Bool var isMenuBarWordCountVisible: Bool var mirrorPreference: MirrorPreference diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index ab136cea..734d209a 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -55,6 +55,9 @@ final class SuggestionSettingsModel: ObservableObject { /// When on (and `suppressCompletionsOnTypo` is also on), a misspelled current word is offered a /// green spell-checker correction the user can accept to replace the typo. @Published private(set) var offerTypoCorrections: Bool + /// When on (and typo suppression is on), pressing Space after a misspelled word applies the best + /// local correction immediately. Kept opt-in because this changes text without confirmation. + @Published private(set) var automaticallyFixTypos: Bool /// Whether the Performance pane is recording per-request latency. Defaults to false so the /// default user never pays any extra storage or write cost — recording only kicks in once the /// user opts in from Settings. @@ -143,6 +146,7 @@ final class SuggestionSettingsModel: ObservableObject { isFastModeEnabled = data.isFastModeEnabled suppressCompletionsOnTypo = data.suppressCompletionsOnTypo offerTypoCorrections = data.offerTypoCorrections + automaticallyFixTypos = data.automaticallyFixTypos isPerformanceTrackingEnabled = data.isPerformanceTrackingEnabled isMenuBarWordCountVisible = data.isMenuBarWordCountVisible mirrorPreference = data.mirrorPreference @@ -204,7 +208,8 @@ final class SuggestionSettingsModel: ObservableObject { mirrorPreference: mirrorPreference, acceptanceGranularity: acceptanceGranularity, suppressCompletionsOnTypo: suppressCompletionsOnTypo, - offerTypoCorrections: offerTypoCorrections + offerTypoCorrections: offerTypoCorrections, + automaticallyFixTypos: automaticallyFixTypos ) } @@ -375,6 +380,15 @@ final class SuggestionSettingsModel: ObservableObject { store.saveOfferTypoCorrections(enabled) } + func setAutomaticallyFixTypos(_ enabled: Bool) { + guard automaticallyFixTypos != enabled else { + return + } + + automaticallyFixTypos = enabled + store.saveAutomaticallyFixTypos(enabled) + } + func setPerformanceTrackingEnabled(_ enabled: Bool) { guard isPerformanceTrackingEnabled != enabled else { return @@ -825,13 +839,17 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { $selectedEngine, $selectedWordCountPreset ), - // Pair the two typo toggles into one inner publisher so the presentation slot stays at - // Combine's four-upstream cap while still carrying both new fields. + // Group the typo settings into one inner publisher so the presentation slot stays at + // Combine's four-upstream cap while carrying the full correction policy. Publishers.CombineLatest4( $isClipboardContextEnabled, $isFastModeEnabled, $mirrorPreference, - Publishers.CombineLatest($suppressCompletionsOnTypo, $offerTypoCorrections) + Publishers.CombineLatest3( + $suppressCompletionsOnTypo, + $offerTypoCorrections, + $automaticallyFixTypos + ) ), Publishers.CombineLatest3($userName, $customRules, $responseLanguages), Publishers.CombineLatest4( @@ -855,7 +873,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { let (combinedSettings, presentationToggles, profile, timing) = primaryTuple let (globallyEnabled, disabledAppRules, engine, wordCountPreset) = combinedSettings let (clipboardContextEnabled, fastModeEnabled, mirrorPreference, typoToggles) = presentationToggles - let (suppressOnTypo, offerCorrections) = typoToggles + let (suppressOnTypo, offerCorrections, automaticallyFixTypos) = typoToggles let (userName, customRules, responseLanguages) = profile let (debounce, focusPoll, multiLine, autoAcceptPunctuation) = timing let (isCustomActive, customLow, customHigh) = customRangeTuple @@ -879,7 +897,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { mirrorPreference: mirrorPreference, acceptanceGranularity: granularity, suppressCompletionsOnTypo: suppressOnTypo, - offerTypoCorrections: offerCorrections + offerTypoCorrections: offerCorrections, + automaticallyFixTypos: automaticallyFixTypos ) } .removeDuplicates() diff --git a/Cotabby/Support/CurrentWordExtractor.swift b/Cotabby/Support/CurrentWordExtractor.swift index 80bc5fd2..d07f7af4 100644 --- a/Cotabby/Support/CurrentWordExtractor.swift +++ b/Cotabby/Support/CurrentWordExtractor.swift @@ -100,3 +100,42 @@ enum CurrentWordExtractor { return true } } + +/// The exact synthetic edit needed to replace one verified trailing typo. +/// +/// Keeping this as a value type lets the coordinator validate first and perform side effects second. +/// That separation matters for Accessibility-backed editors, where the field may change between the +/// original correction offer and the eventual edit. +struct TypoCorrectionReplacement: Equatable, Sendable { + let deletingUTF16Count: Int + let replacementText: String +} + +/// Builds a fail-closed replacement from the latest text before the caret. +/// +/// Both accepted green corrections and automatic post-Space fixes use this planner. Centralizing the +/// word-match and whitespace-preservation rules prevents the two paths from drifting and accidentally +/// deleting a different word after the user continues typing. +enum TypoCorrectionReplacementPlanner { + static func plan( + precedingText: String, + expectedTypo: String, + correctedWord: String, + requiresTrailingSpace: Bool + ) -> TypoCorrectionReplacement? { + let normalizedCorrection = correctedWord.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedCorrection.isEmpty, + normalizedCorrection != expectedTypo, + let live = CurrentWordExtractor.extractTrailingWord(from: precedingText), + live.result.word == expectedTypo, + !requiresTrailingSpace || live.trailingSpaceCount == 1 else { + return nil + } + + let preservedSpaces = String(repeating: " ", count: live.trailingSpaceCount) + return TypoCorrectionReplacement( + deletingUTF16Count: (expectedTypo as NSString).length + live.trailingSpaceCount, + replacementText: normalizedCorrection + preservedSpaces + ) + } +} diff --git a/Cotabby/Support/SuggestionSettingsStore.swift b/Cotabby/Support/SuggestionSettingsStore.swift index 7cd50423..c42d697b 100644 --- a/Cotabby/Support/SuggestionSettingsStore.swift +++ b/Cotabby/Support/SuggestionSettingsStore.swift @@ -74,6 +74,7 @@ struct SuggestionSettingsStore { private static let fastModeEnabledDefaultsKey = "cotabbyFastModeEnabled" private static let suppressCompletionsOnTypoDefaultsKey = "cotabbySuppressCompletionsOnTypo" private static let offerTypoCorrectionsDefaultsKey = "cotabbyOfferTypoCorrections" + private static let automaticallyFixTyposDefaultsKey = "cotabbyAutomaticallyFixTypos" private static let performanceTrackingEnabledDefaultsKey = "cotabbyPerformanceTrackingEnabled" private static let menuBarWordCountVisibleDefaultsKey = "cotabbyMenuBarWordCountVisible" private static let mirrorPreferenceDefaultsKey = "cotabbyMirrorPreference" @@ -163,13 +164,15 @@ struct SuggestionSettingsStore { // into fast mode turns it off. let resolvedFastModeEnabled = userDefaults.object(forKey: Self.fastModeEnabledDefaultsKey) as? Bool ?? false - // Default both typo toggles to true: hiding a completion on a misspelled current word and - // offering a fix are the right out-of-box behavior. Existing users without a stored value - // get them on; the second is only effective when the first is on. + // Hiding a completion on a misspelled current word and offering a fix remain the default + // behavior. Automatic replacement is deliberately opt-in because it mutates host-app text + // without a confirmation key. let resolvedSuppressCompletionsOnTypo = userDefaults.object(forKey: Self.suppressCompletionsOnTypoDefaultsKey) as? Bool ?? true let resolvedOfferTypoCorrections = userDefaults.object(forKey: Self.offerTypoCorrectionsDefaultsKey) as? Bool ?? true + let resolvedAutomaticallyFixTypos = + userDefaults.object(forKey: Self.automaticallyFixTyposDefaultsKey) as? Bool ?? false // Defaults to false so the metrics ring buffer stays empty until the user explicitly opts // in from the Performance pane. let resolvedPerformanceTrackingEnabled = @@ -314,6 +317,7 @@ struct SuggestionSettingsStore { isFastModeEnabled: resolvedFastModeEnabled, suppressCompletionsOnTypo: resolvedSuppressCompletionsOnTypo, offerTypoCorrections: resolvedOfferTypoCorrections, + automaticallyFixTypos: resolvedAutomaticallyFixTypos, isPerformanceTrackingEnabled: resolvedPerformanceTrackingEnabled, isMenuBarWordCountVisible: resolvedMenuBarWordCountVisible, mirrorPreference: resolvedMirrorPreference, @@ -362,6 +366,7 @@ struct SuggestionSettingsStore { saveFastModeEnabled(data.isFastModeEnabled) saveSuppressCompletionsOnTypo(data.suppressCompletionsOnTypo) saveOfferTypoCorrections(data.offerTypoCorrections) + saveAutomaticallyFixTypos(data.automaticallyFixTypos) savePerformanceTrackingEnabled(data.isPerformanceTrackingEnabled) saveMenuBarWordCountVisible(data.isMenuBarWordCountVisible) saveMirrorPreference(data.mirrorPreference) @@ -499,6 +504,10 @@ struct SuggestionSettingsStore { userDefaults.set(enabled, forKey: Self.offerTypoCorrectionsDefaultsKey) } + func saveAutomaticallyFixTypos(_ enabled: Bool) { + userDefaults.set(enabled, forKey: Self.automaticallyFixTyposDefaultsKey) + } + func savePerformanceTrackingEnabled(_ enabled: Bool) { userDefaults.set(enabled, forKey: Self.performanceTrackingEnabledDefaultsKey) } diff --git a/Cotabby/Support/TypoGate.swift b/Cotabby/Support/TypoGate.swift index 38f28620..16684563 100644 --- a/Cotabby/Support/TypoGate.swift +++ b/Cotabby/Support/TypoGate.swift @@ -12,37 +12,47 @@ enum TypoGateDecision: Equatable { /// continuation so completions never pile on top of a broken word, but show nothing. case suppress /// The current word looks misspelled and a correction is available. Offer it as a replace-the-word - /// suggestion. `word` is the typo to replace; the accept path recomputes the delete length from - /// the live field rather than trusting a value captured here. - case correct(word: String, correctedWord: String) + /// suggestion. `word` is the typo to replace; the accept path recomputes the edit from live text. + case offerCorrection(word: String, correctedWord: String) + /// The user finished a misspelled word with Space and enabled automatic fixing. Apply the edit + /// immediately instead of creating an accept-key session. + case applyCorrection(word: String, correctedWord: String) } enum TypoGate { /// Resolves the gate decision for the trailing word of `precedingText`. /// /// `isTypo` and `bestCorrection` are injected so this stays pure: in production they wrap - /// `CurrentWordSpellChecker`; in tests they are stubs. Correction requires both toggles on AND a - /// non-nil correction; otherwise a detected typo falls back to suppression. + /// `CurrentWordSpellChecker`; in tests they are stubs. Automatic fixing takes precedence only + /// after a literal trailing Space. Before that boundary the gate may offer a correction, but never + /// mutates an unfinished word merely because the user paused. static func resolve( precedingText: String, suppressCompletionsOnTypo: Bool, offerTypoCorrections: Bool, + automaticallyFixTypos: Bool, isTypo: (String) -> Bool, bestCorrection: (String) -> String? ) -> TypoGateDecision { guard suppressCompletionsOnTypo else { return .proceed } - // Tolerate one trailing space so a just-finished word (the user typed it and pressed space) - // is still offered a correction instead of the offer vanishing the moment space is pressed. - guard let current = CurrentWordExtractor.extractTrailingWord(from: precedingText)?.result else { + // Tolerate one trailing space so a just-finished word remains actionable: automatic mode can + // apply it, while offer mode can keep the green correction alive after Space. + guard let current = CurrentWordExtractor.extractTrailingWord(from: precedingText) else { return .proceed } - guard isTypo(current.word) else { + guard isTypo(current.result.word) else { return .proceed } - if offerTypoCorrections, let corrected = bestCorrection(current.word) { - return .correct(word: current.word, correctedWord: corrected) + guard let corrected = bestCorrection(current.result.word) else { + return .suppress + } + if automaticallyFixTypos, current.trailingSpaceCount == 1 { + return .applyCorrection(word: current.result.word, correctedWord: corrected) + } + if offerTypoCorrections { + return .offerCorrection(word: current.result.word, correctedWord: corrected) } return .suppress } diff --git a/Cotabby/UI/Settings/Panes/WritingPaneView.swift b/Cotabby/UI/Settings/Panes/WritingPaneView.swift index 56209177..9ff651ef 100644 --- a/Cotabby/UI/Settings/Panes/WritingPaneView.swift +++ b/Cotabby/UI/Settings/Panes/WritingPaneView.swift @@ -51,18 +51,28 @@ struct WritingPaneView: View { } Section("Corrections") { - Toggle("Hide Suggestions on Typo", isOn: suppressCompletionsOnTypoBinding) - .help( - "When the word you're currently typing looks misspelled, hide the normal " + - "completion so suggestions don't pile on top of a broken word." + Toggle(isOn: suppressCompletionsOnTypoBinding) { + SettingsRowLabel( + title: "Hide Suggestions on Typo", + description: "Stops normal completions while the current word appears misspelled." + ) + } + + Toggle(isOn: offerTypoCorrectionsBinding) { + SettingsRowLabel( + title: "Offer Corrections on Typo", + description: "Shows a green replacement you can apply with your accept key." ) + } + .disabled(!suggestionSettings.suppressCompletionsOnTypo) - 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." + Toggle(isOn: automaticallyFixTyposBinding) { + SettingsRowLabel( + title: "Automatically Fix Typos", + description: "After you press Space, replaces a misspelled word without requiring your accept key." ) - .disabled(!suggestionSettings.suppressCompletionsOnTypo) + } + .disabled(!suggestionSettings.suppressCompletionsOnTypo) } Section("Profile") { @@ -130,6 +140,13 @@ struct WritingPaneView: View { ) } + private var automaticallyFixTyposBinding: Binding { + Binding( + get: { suggestionSettings.automaticallyFixTypos }, + set: { suggestionSettings.setAutomaticallyFixTypos($0) } + ) + } + private enum LengthChoice: Hashable { case preset(SuggestionWordCountPreset) case custom diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 61377def..7a9a0981 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -37,6 +37,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case customRules case hideSuggestionsOnTypo case offerTypoCorrections + case automaticallyFixTypos // Context case extendedContext case contextLivePreview @@ -102,6 +103,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .customRules: return "Custom Rules" case .hideSuggestionsOnTypo: return "Hide Suggestions on Typo" case .offerTypoCorrections: return "Offer Corrections on Typo" + case .automaticallyFixTypos: return "Automatically Fix Typos" case .extendedContext: return "Extended Context" case .contextLivePreview: return "Live Preview" case .engine: return "Engine" @@ -160,6 +162,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .customRules: return "list.bullet.rectangle" case .hideSuggestionsOnTypo: return "eye.slash" case .offerTypoCorrections: return "checkmark.bubble" + case .automaticallyFixTypos: return "checkmark.circle" case .extendedContext: return "doc.text" case .contextLivePreview: return "text.cursor" case .engine: return "cpu" @@ -203,7 +206,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .emojiPicker, .emojiSkinTone, .emojiPeopleStyle, .emojiHistory: return .emoji case .length, .name, .languages, .customRules, - .hideSuggestionsOnTypo, .offerTypoCorrections: + .hideSuggestionsOnTypo, .offerTypoCorrections, .automaticallyFixTypos: return .writing case .extendedContext, .contextLivePreview: return .context @@ -300,6 +303,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .offerTypoCorrections: return ["typo", "correct", "correction", "fix", "spelling", "autocorrect", "spell check", "mistake", "rewrite"] + case .automaticallyFixTypos: + return ["typo", "automatic", "automatically", "autocorrect", "fix", "spelling", + "replace", "space", "instant", "without accepting"] case .extendedContext: return ["context", "glossary", "reference", "notes", "jargon", "instructions", "memory", "background", "system prompt", "vocabulary"] diff --git a/CotabbyTests/CotabbyTestFixtures.swift b/CotabbyTests/CotabbyTestFixtures.swift index 962089b5..417c08f3 100644 --- a/CotabbyTests/CotabbyTestFixtures.swift +++ b/CotabbyTests/CotabbyTestFixtures.swift @@ -231,7 +231,8 @@ enum CotabbyTestFixtures { mirrorPreference: MirrorPreference = .auto, acceptanceGranularity: AcceptanceGranularity = .word, suppressCompletionsOnTypo: Bool = false, - offerTypoCorrections: Bool = false + offerTypoCorrections: Bool = false, + automaticallyFixTypos: Bool = false ) -> SuggestionSettingsSnapshot { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, @@ -253,7 +254,8 @@ enum CotabbyTestFixtures { mirrorPreference: mirrorPreference, acceptanceGranularity: acceptanceGranularity, suppressCompletionsOnTypo: suppressCompletionsOnTypo, - offerTypoCorrections: offerTypoCorrections + offerTypoCorrections: offerTypoCorrections, + automaticallyFixTypos: automaticallyFixTypos ) } } diff --git a/CotabbyTests/CurrentWordExtractorTests.swift b/CotabbyTests/CurrentWordExtractorTests.swift index 420432ba..6d2564cb 100644 --- a/CotabbyTests/CurrentWordExtractorTests.swift +++ b/CotabbyTests/CurrentWordExtractorTests.swift @@ -79,4 +79,42 @@ final class CurrentWordExtractorTests: XCTestCase { func test_trailingWord_rejectsImplausibleWordEvenWithSpace() { XCTAssertNil(CurrentWordExtractor.extractTrailingWord(from: "open https://example.com ")) } + + // MARK: - Typo replacement planning + + func test_typoReplacement_preservesCommittedSpaceAndUsesUTF16Count() { + let replacement = TypoCorrectionReplacementPlanner.plan( + precedingText: "hi my nmae ", + expectedTypo: "nmae", + correctedWord: "name", + requiresTrailingSpace: true + ) + + XCTAssertEqual( + replacement, + TypoCorrectionReplacement(deletingUTF16Count: 5, replacementText: "name ") + ) + } + + func test_typoReplacement_rejectsAutomaticFixBeforeSpace() { + XCTAssertNil( + TypoCorrectionReplacementPlanner.plan( + precedingText: "hi my nmae", + expectedTypo: "nmae", + correctedWord: "name", + requiresTrailingSpace: true + ) + ) + } + + func test_typoReplacement_rejectsChangedTrailingWord() { + XCTAssertNil( + TypoCorrectionReplacementPlanner.plan( + precedingText: "hi my names ", + expectedTypo: "nmae", + correctedWord: "name", + requiresTrailingSpace: false + ) + ) + } } diff --git a/CotabbyTests/SuggestionSettingsStoreTests.swift b/CotabbyTests/SuggestionSettingsStoreTests.swift index 8932ef9a..f4d910cb 100644 --- a/CotabbyTests/SuggestionSettingsStoreTests.swift +++ b/CotabbyTests/SuggestionSettingsStoreTests.swift @@ -140,6 +140,14 @@ final class SuggestionSettingsStoreTests: XCTestCase { // MARK: - Save / load round-trips + func test_load_automaticTypoFixingDefaultsOff() async { + let defaults = makeIsolatedDefaults() + + let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) + + XCTAssertFalse(data.automaticallyFixTypos) + } + func test_saveThenLoad_roundTripsScalarFields() async { let defaults = makeIsolatedDefaults() let store = SuggestionSettingsStore(userDefaults: defaults) @@ -148,6 +156,7 @@ final class SuggestionSettingsStoreTests: XCTestCase { store.saveUserName("Ada Lovelace") store.saveGhostTextOpacity(0.5) store.saveFastModeEnabled(true) + store.saveAutomaticallyFixTypos(true) store.saveMenuBarWordCountVisible(false) let data = store.load(configuration: .standard) @@ -156,6 +165,7 @@ final class SuggestionSettingsStoreTests: XCTestCase { XCTAssertEqual(data.userName, "Ada Lovelace") XCTAssertEqual(data.ghostTextOpacity, 0.5, accuracy: 0.0001) XCTAssertTrue(data.isFastModeEnabled) + XCTAssertTrue(data.automaticallyFixTypos) XCTAssertFalse(data.isMenuBarWordCountVisible) } diff --git a/CotabbyTests/TypoGateTests.swift b/CotabbyTests/TypoGateTests.swift index 6449c26e..77a8dcf0 100644 --- a/CotabbyTests/TypoGateTests.swift +++ b/CotabbyTests/TypoGateTests.swift @@ -6,6 +6,7 @@ final class TypoGateTests: XCTestCase { precedingText: String, suppress: Bool, offer: Bool, + automatic: Bool = false, typos: Set = [], corrections: [String: String] = [:] ) -> TypoGateDecision { @@ -13,6 +14,7 @@ final class TypoGateTests: XCTestCase { precedingText: precedingText, suppressCompletionsOnTypo: suppress, offerTypoCorrections: offer, + automaticallyFixTypos: automatic, isTypo: { typos.contains($0) }, bestCorrection: { corrections[$0] } ) @@ -55,7 +57,7 @@ final class TypoGateTests: XCTestCase { typos: ["nmae"], corrections: ["nmae": "name"] ) - XCTAssertEqual(decision, .correct(word: "nmae", correctedWord: "name")) + XCTAssertEqual(decision, .offerCorrection(word: "nmae", correctedWord: "name")) } func test_correctsWhenTypoFollowedByOneSpace() { @@ -67,7 +69,7 @@ final class TypoGateTests: XCTestCase { typos: ["nmae"], corrections: ["nmae": "name"] ) - XCTAssertEqual(decision, .correct(word: "nmae", correctedWord: "name")) + XCTAssertEqual(decision, .offerCorrection(word: "nmae", correctedWord: "name")) } func test_proceedsWhenTypoFollowedByTwoSpaces() { @@ -81,4 +83,28 @@ final class TypoGateTests: XCTestCase { ) XCTAssertEqual(decision, .proceed) } + + func test_automaticFixAppliesOnlyAfterSpace() { + let decision = resolve( + precedingText: "hi my nmae ", + suppress: true, + offer: false, + automatic: true, + typos: ["nmae"], + corrections: ["nmae": "name"] + ) + XCTAssertEqual(decision, .applyCorrection(word: "nmae", correctedWord: "name")) + } + + func test_automaticFixDoesNotMutateUnfinishedWord() { + let decision = resolve( + precedingText: "hi my nmae", + suppress: true, + offer: true, + automatic: true, + typos: ["nmae"], + corrections: ["nmae": "name"] + ) + XCTAssertEqual(decision, .offerCorrection(word: "nmae", correctedWord: "name")) + } }