Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 13 additions & 17 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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.
Expand Down
91 changes: 83 additions & 8 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand All @@ -167,17 +167,92 @@ 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,
rawContext: rawContext,
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
)
Comment on lines +229 to +250

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 normalizedOutput diverges from the acceptance path on both branches

Both the failure log (line 234) and success log (line 249) pass correctedWord — the raw string from bestCorrection, before normalization and before trailing-space preservation. The manual-acceptance path in acceptCorrection consistently passes replacement.replacementText for normalizedOutput on its failure and success branches. Using replacement.replacementText here as well would make both paths comparable when correlating auto-corrected and manually-accepted events in diagnostics.

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

Fix in Codex Fix in Claude Code

// 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
Expand Down
4 changes: 4 additions & 0 deletions Cotabby/Models/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Cotabby/Models/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 25 additions & 6 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -204,7 +208,8 @@ final class SuggestionSettingsModel: ObservableObject {
mirrorPreference: mirrorPreference,
acceptanceGranularity: acceptanceGranularity,
suppressCompletionsOnTypo: suppressCompletionsOnTypo,
offerTypoCorrections: offerTypoCorrections
offerTypoCorrections: offerTypoCorrections,
automaticallyFixTypos: automaticallyFixTypos
)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -879,7 +897,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
mirrorPreference: mirrorPreference,
acceptanceGranularity: granularity,
suppressCompletionsOnTypo: suppressOnTypo,
offerTypoCorrections: offerCorrections
offerTypoCorrections: offerCorrections,
automaticallyFixTypos: automaticallyFixTypos
)
}
.removeDuplicates()
Expand Down
39 changes: 39 additions & 0 deletions Cotabby/Support/CurrentWordExtractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Loading
Loading