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
104 changes: 104 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

29 changes: 26 additions & 3 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,12 @@ extension SuggestionCoordinator {
suppressCompletionsOnTypo: settingsSnapshot.suppressCompletionsOnTypo,
offerTypoCorrections: settingsSnapshot.offerTypoCorrections,
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.
bestCorrection: { symSpellCorrector.bestCorrection(for: $0) ?? spellChecker.bestCorrection(for: $0) }
bestCorrection: {
bestCorrection(
for: $0,
precedingText: rawContext.precedingText
)
}
) {
case .proceed:
return false
Expand All @@ -178,6 +181,26 @@ extension SuggestionCoordinator {
}
}

/// Routes the typo to one enabled language-specific SymSpell index. The dictionaries remain
/// separate because frequency counts from different corpora are not comparable. Ambiguous
/// multilingual context, a cold index, or a missing SymSpell candidate all fall back to the
/// user's automatic-language macOS spell checker.
private func bestCorrection(for word: String, precedingText: String) -> String? {
let enabledLanguages = SpellingDictionaryCatalog.languages(
for: settingsSnapshot.enabledSpellingDictionaryCodes
)
guard let language = spellingLanguageResolver.resolve(
precedingText: precedingText,
currentWord: word,
enabledLanguages: enabledLanguages
) else {
return spellChecker.bestCorrection(for: word)
}

return symSpellCorrector.bestCorrection(for: word, language: language)
?? spellChecker.bestCorrection(for: word)
}

/// 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
5 changes: 5 additions & 0 deletions Cotabby/App/Coordinators/SuggestionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ final class SuggestionCoordinator: ObservableObject {
/// Frequency-ranked correction source (SymSpell). Used first for the correction word, with
/// `spellChecker` as the fallback while its index is still loading or when it has no suggestion.
let symSpellCorrector: SymSpellCorrector
/// Chooses at most one enabled SymSpell language from the text surrounding the typo. Ambiguous
/// contexts return nil so correction ranking falls back to the system spell checker.
let spellingLanguageResolver: SpellingLanguageResolver

/// Optional first-look hook the emoji picker installs to observe the keystroke stream. Called at
/// the very top of `handleInputEvent`, before any suggestion logic. Returns `true` when an emoji
Expand Down Expand Up @@ -130,6 +133,7 @@ final class SuggestionCoordinator: ObservableObject {
configuration: SuggestionConfiguration,
spellChecker: CurrentWordSpellChecker,
symSpellCorrector: SymSpellCorrector,
spellingLanguageResolver: SpellingLanguageResolver = SpellingLanguageResolver(),
userDefaults: UserDefaults = .standard
) {
let storedTotalTabAcceptedWordCount = userDefaults.integer(
Expand All @@ -150,6 +154,7 @@ final class SuggestionCoordinator: ObservableObject {
self.configuration = configuration
self.spellChecker = spellChecker
self.symSpellCorrector = symSpellCorrector
self.spellingLanguageResolver = spellingLanguageResolver
self.userDefaults = userDefaults
settingsSnapshot = suggestionSettings.snapshot
// These collaborators isolate "how overlay/logging works" from "when the coordinator
Expand Down
16 changes: 13 additions & 3 deletions Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,17 @@ final class CotabbyAppEnvironment {
// Constructed once at app scope so the underlying `NSSpellChecker` document tag survives
// across coordinator state transitions instead of churning per keystroke.
let spellChecker = CurrentWordSpellChecker()
// Builds its SymSpell index off the main thread on init; ready within ~a second of launch.
let symSpellCorrector = SymSpellCorrector()
let enabledSpellingLanguages = SpellingDictionaryCatalog.languages(
for: suggestionSettings.enabledSpellingDictionaryCodes
)
// Preserve the existing warm English path when it is enabled. A sole non-English choice is
// also preloaded; broader multilingual sets stay lazy so app launch never builds every index.
let preloadSpellingLanguage = enabledSpellingLanguages.count == 1
? enabledSpellingLanguages.first
: enabledSpellingLanguages.first(where: { $0 == .english })
let symSpellCorrector = SymSpellCorrector(
preloadLanguage: preloadSpellingLanguage
)
let suggestionCoordinator = SuggestionCoordinator(
permissionManager: permissionManager,
focusModel: focusModel,
Expand All @@ -194,7 +203,8 @@ final class CotabbyAppEnvironment {
workController: workController,
configuration: configuration,
spellChecker: spellChecker,
symSpellCorrector: symSpellCorrector
symSpellCorrector: symSpellCorrector,
spellingLanguageResolver: SpellingLanguageResolver()
)

// The emoji picker is a sibling to the suggestion coordinator. It reuses the input monitor,
Expand Down
84 changes: 84 additions & 0 deletions Cotabby/Models/SpellingDictionaryCatalog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Foundation

/// A bundled SymSpell frequency dictionary that Cotabby can use for ranked typo correction.
///
/// The raw value is the ISO 639-1 language code persisted in `UserDefaults`. Keeping the durable
/// representation as a standard language code makes future migrations straightforward and avoids
/// coupling stored preferences to display labels or resource filenames.
nonisolated enum SpellingDictionaryLanguage: String, CaseIterable, Codable, Hashable, Identifiable, Sendable {
case english = "en"
case german = "de"
case spanish = "es"
case french = "fr"
case hebrew = "he"
case italian = "it"
case russian = "ru"

var id: String { rawValue }

/// English name used in logs, documentation, and accessibility descriptions.
var displayName: String {
switch self {
case .english: return "English"
case .german: return "German"
case .spanish: return "Spanish"
case .french: return "French"
case .hebrew: return "Hebrew"
case .italian: return "Italian"
case .russian: return "Russian"
}
}

/// Native-script label shown in Settings so speakers can identify their language quickly.
var settingsLabel: String {
switch self {
case .english: return "English"
case .german: return "Deutsch (German)"
case .spanish: return "Español (Spanish)"
case .french: return "Français (French)"
case .hebrew: return "עברית (Hebrew)"
case .italian: return "Italiano (Italian)"
case .russian: return "Русский (Russian)"
}
}

/// Resource basename from the upstream SymSpell frequency-dictionary folder.
var resourceName: String {
switch self {
case .english: return "frequency_dictionary_en_82_765"
case .german: return "de-100k"
case .spanish: return "es-100l"

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 The Spanish resource name es-100l (letter l) diverges from the *-100k pattern every other language follows (de-100k, fr-100k, he-100k, it-100k, ru-100k). The actual file on disk is also es-100l.txt, so this works correctly at runtime, but anyone expecting es-100k when browsing the bundle or the resource directory will look for the wrong file. A comment on the case would prevent future confusion.

Suggested change
case .spanish: return "es-100l"
// Upstream SymSpell file uses "l" (not "k") — matches the filename es-100l.txt in the bundle.
case .spanish: return "es-100l"

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

case .french: return "fr-100k"
case .hebrew: return "he-100k"
case .italian: return "it-100k"
case .russian: return "ru-100k"
}
}
}

/// Pure catalog rules for the spelling-dictionary setting.
///
/// This is intentionally separate from `LanguageCatalog`: response languages steer model output,
/// while spelling dictionaries decide which deterministic correction indexes may be queried. A
/// multilingual writer may reasonably enable several response languages but keep autocorrection
/// limited to one conservative dictionary.
nonisolated enum SpellingDictionaryCatalog {
static let defaultEnabledCodes = [SpellingDictionaryLanguage.english.rawValue]

/// Drops unknown and duplicate codes, then returns the result in stable catalog order.
///
/// Stable ordering keeps persisted values, snapshots, tests, and Settings rendering
/// deterministic even when callers build the input from a `Set`.
static func normalize(_ codes: [String]) -> [String] {
let requested = Set(codes.map {
$0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
})
return SpellingDictionaryLanguage.allCases
.filter { requested.contains($0.rawValue) }
.map(\.rawValue)
}

static func languages(for codes: [String]) -> [SpellingDictionaryLanguage] {
normalize(codes).compactMap(SpellingDictionaryLanguage.init(rawValue:))
}
}
3 changes: 3 additions & 0 deletions Cotabby/Models/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ 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
/// Normalized ISO codes for the bundled SymSpell dictionaries eligible for correction ranking.
/// Language routing chooses at most one per typo; an empty array uses only `NSSpellChecker`.
let enabledSpellingDictionaryCodes: [String]

/// 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
3 changes: 3 additions & 0 deletions Cotabby/Models/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ 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
/// ISO language codes for the bundled SymSpell dictionaries the user permits Cotabby to query.
/// Empty means correction ranking relies exclusively on the system `NSSpellChecker`.
var enabledSpellingDictionaryCodes: [String]
var isPerformanceTrackingEnabled: Bool
var isMenuBarWordCountVisible: Bool
var mirrorPreference: MirrorPreference
Expand Down
43 changes: 39 additions & 4 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ 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
/// Bundled SymSpell languages eligible for frequency-ranked corrections. This remains separate
/// from response languages because model prompting and deterministic autocorrection are distinct
/// user policies.
@Published private(set) var enabledSpellingDictionaryCodes: [String]
/// 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 @@ -152,6 +156,7 @@ final class SuggestionSettingsModel: ObservableObject {
isFastModeEnabled = data.isFastModeEnabled
suppressCompletionsOnTypo = data.suppressCompletionsOnTypo
offerTypoCorrections = data.offerTypoCorrections
enabledSpellingDictionaryCodes = data.enabledSpellingDictionaryCodes
isPerformanceTrackingEnabled = data.isPerformanceTrackingEnabled
isMenuBarWordCountVisible = data.isMenuBarWordCountVisible
mirrorPreference = data.mirrorPreference
Expand Down Expand Up @@ -213,7 +218,8 @@ final class SuggestionSettingsModel: ObservableObject {
mirrorPreference: mirrorPreference,
acceptanceGranularity: acceptanceGranularity,
suppressCompletionsOnTypo: suppressCompletionsOnTypo,
offerTypoCorrections: offerTypoCorrections
offerTypoCorrections: offerTypoCorrections,
enabledSpellingDictionaryCodes: enabledSpellingDictionaryCodes
)
}

Expand Down Expand Up @@ -384,6 +390,27 @@ final class SuggestionSettingsModel: ObservableObject {
store.saveOfferTypoCorrections(enabled)
}

func setSpellingDictionary(_ language: SpellingDictionaryLanguage, enabled: Bool) {
var selected = Set(enabledSpellingDictionaryCodes)
if enabled {
selected.insert(language.rawValue)
} else {
selected.remove(language.rawValue)
}

let normalized = SpellingDictionaryCatalog.normalize(Array(selected))
guard enabledSpellingDictionaryCodes != normalized else {
return
}

enabledSpellingDictionaryCodes = normalized
store.saveEnabledSpellingDictionaryCodes(normalized)
}

func isSpellingDictionaryEnabled(_ language: SpellingDictionaryLanguage) -> Bool {
enabledSpellingDictionaryCodes.contains(language.rawValue)
}

func setPerformanceTrackingEnabled(_ enabled: Bool) {
guard isPerformanceTrackingEnabled != enabled else {
return
Expand Down Expand Up @@ -852,7 +879,14 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
$mirrorPreference,
Publishers.CombineLatest($suppressCompletionsOnTypo, $offerTypoCorrections)
),
Publishers.CombineLatest3($userName, $customRules, $responseLanguages),
// Profile and language policy travel together because both affect a request/correction
// without changing presentation or timing.
Publishers.CombineLatest4(
$userName,
$customRules,
$responseLanguages,
$enabledSpellingDictionaryCodes
),
Publishers.CombineLatest4(
$debounceMilliseconds,
$focusPollIntervalMilliseconds,
Expand All @@ -875,7 +909,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
let (globallyEnabled, disabledAppRules, engine, wordCountPreset) = combinedSettings
let (clipboardContextEnabled, fastModeEnabled, mirrorPreference, typoToggles) = presentationToggles
let (suppressOnTypo, offerCorrections) = typoToggles
let (userName, customRules, responseLanguages) = profile
let (userName, customRules, responseLanguages, enabledSpellingDictionaryCodes) = profile
let (debounce, focusPoll, multiLine, autoAcceptPunctuation) = timing
let (isCustomActive, customLow, customHigh) = customRangeTuple
return SuggestionSettingsSnapshot(
Expand All @@ -898,7 +932,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
mirrorPreference: mirrorPreference,
acceptanceGranularity: granularity,
suppressCompletionsOnTypo: suppressOnTypo,
offerTypoCorrections: offerCorrections
offerTypoCorrections: offerCorrections,
enabledSpellingDictionaryCodes: enabledSpellingDictionaryCodes
)
}
.removeDuplicates()
Expand Down
Loading