Skip to content
Merged
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
31 changes: 29 additions & 2 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,15 @@ extension SuggestionCoordinator {
precedingText: liveContext.precedingText
)

let insertionText = insertionTextApplyingAutoSpace(
insertionChunk: insertionChunk,
acceptedChunk: acceptedChunk,
session: sessionForAcceptance
)

// An empty chunk means the accepted span was entirely a boundary space the field already
// supplies: advance the session without synthesizing a keystroke.
if !insertionChunk.isEmpty, !suggestionInserter.insert(insertionChunk) {
if !insertionText.isEmpty, !suggestionInserter.insert(insertionText) {
let message = suggestionInserter.lastErrorMessage ?? "Suggestion insertion failed."
cancelPredictionWork()
clearSuggestion(clearDiagnostics: true)
Expand All @@ -109,7 +115,7 @@ extension SuggestionCoordinator {
workID: currentWorkID,
generation: liveContext.generation,
message: message,
normalizedOutput: insertionChunk
normalizedOutput: insertionText
)
return false
}
Expand Down Expand Up @@ -190,6 +196,27 @@ extension SuggestionCoordinator {
}
}

/// Applies the opt-in "add a space after accepting" setting to the text about to be inserted.
///
/// The trailing space is only appended when this accept *exhausts* the suggestion — predicted the
/// same way `commitAcceptedChunk` decides it — because a mid-suggestion word accept is already
/// followed by the next chunk's own leading space, so a space here would double up. Only the
/// inserted text grows: session accounting still advances by the unchanged `acceptedChunk`, and
/// the session tears down on exhaustion, so the extra space never disturbs the consumed-suffix
/// reconciliation a still-live session relies on. Whether the space actually lands (vs. being
/// suppressed after punctuation, whitespace, or a space-less script) is the reconciler's rule.
private func insertionTextApplyingAutoSpace(
insertionChunk: String,
acceptedChunk: String,
session: ActiveSuggestionSession
) -> String {
guard settingsSnapshot.addSpaceAfterAccept,
session.advancing(by: acceptedChunk.count).isExhausted else {
return insertionChunk
}
return SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace(insertionChunk)
}

/// Runs acceptance bookkeeping one runloop hop after the consuming tap callback returns.
///
/// While ghost text is visible the accept tap gates every keyDown system-wide, and the whole
Expand Down
5 changes: 5 additions & 0 deletions Cotabby/Models/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable {
/// When true (the default), accepting a word also takes punctuation attached to it. When false,
/// trailing punctuation is left as its own acceptance part so a single Tab takes the word alone.
let autoAcceptTrailingPunctuation: Bool
/// When true, an accept that finishes a word and exhausts the suggestion also types a trailing
/// space so the user can keep typing without pressing Space. Suppressed for text ending in
/// punctuation, whitespace, or a space-less script. Defaults to false; travels in the snapshot so
/// the acceptance path reads the live value without subscribing to the settings model.
let addSpaceAfterAccept: Bool
/// When true, the screenshot/OCR visual-context pipeline is skipped entirely for lower-latency
/// suggestions. Defaults to false. Only affects visual context — predictions still run.
let isFastModeEnabled: Bool
Expand Down
5 changes: 5 additions & 0 deletions Cotabby/Models/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ struct SuggestionSettingsData: Equatable {
var preferredEmojiSkinTone: EmojiSkinTone
var preferredEmojiGender: EmojiGender
var autoAcceptTrailingPunctuation: Bool
/// When on, accepting a suggestion that finishes a word also types a trailing space, so the user
/// can keep typing the next word without pressing Space. Suppressed when the accepted text already
/// ends in punctuation or whitespace, or in a space-less script. Defaults to off so the WYSIWYG
/// accept behavior is unchanged unless the user opts in.
var addSpaceAfterAccept: Bool
var acceptanceKeyCode: CGKeyCode
var acceptanceKeyModifiers: ShortcutModifierMask
var acceptanceKeyLabel: String
Expand Down
19 changes: 17 additions & 2 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ final class SuggestionSettingsModel: ObservableObject {
@Published private(set) var preferredEmojiSkinTone: EmojiSkinTone
@Published private(set) var preferredEmojiGender: EmojiGender
@Published private(set) var autoAcceptTrailingPunctuation: Bool
@Published private(set) var addSpaceAfterAccept: Bool
@Published private(set) var acceptanceKeyCode: CGKeyCode
@Published private(set) var acceptanceKeyModifiers: ShortcutModifierMask
@Published private(set) var acceptanceKeyLabel: String
Expand Down Expand Up @@ -181,6 +182,7 @@ final class SuggestionSettingsModel: ObservableObject {
preferredEmojiSkinTone = data.preferredEmojiSkinTone
preferredEmojiGender = data.preferredEmojiGender
autoAcceptTrailingPunctuation = data.autoAcceptTrailingPunctuation
addSpaceAfterAccept = data.addSpaceAfterAccept
acceptanceKeyCode = data.acceptanceKeyCode
acceptanceKeyModifiers = data.acceptanceKeyModifiers
acceptanceKeyLabel = data.acceptanceKeyLabel
Expand Down Expand Up @@ -224,6 +226,7 @@ final class SuggestionSettingsModel: ObservableObject {
focusPollIntervalMilliseconds: focusPollIntervalMilliseconds,
isMultiLineEnabled: isMultiLineEnabled,
autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation,
addSpaceAfterAccept: addSpaceAfterAccept,
isFastModeEnabled: isFastModeEnabled,
mirrorPreference: mirrorPreference,
acceptanceGranularity: acceptanceGranularity,
Expand Down Expand Up @@ -510,6 +513,14 @@ final class SuggestionSettingsModel: ObservableObject {
store.saveAutoAcceptTrailingPunctuation(enabled)
}

func setAddSpaceAfterAccept(_ enabled: Bool) {
guard addSpaceAfterAccept != enabled else {
return
}
addSpaceAfterAccept = enabled
store.saveAddSpaceAfterAccept(enabled)
}

func setAcceptanceGranularity(_ granularity: AcceptanceGranularity) {
guard acceptanceGranularity != granularity else {
return
Expand Down Expand Up @@ -920,11 +931,13 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
$responseLanguages,
$enabledSpellingDictionaryCodes
),
// The two acceptance toggles share this slot via a paired `CombineLatest` so the new
// setting costs no extra upstream in a tuple already at Combine's four-input cap.
Publishers.CombineLatest4(
$debounceMilliseconds,
$focusPollIntervalMilliseconds,
$isMultiLineEnabled,
$autoAcceptTrailingPunctuation
Publishers.CombineLatest($autoAcceptTrailingPunctuation, $addSpaceAfterAccept)
)
)
// The outer CombineLatest stack is already at Combine's per-operator cap, so each new
Expand All @@ -950,7 +963,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
let (clipboardContextEnabled, fastModeEnabled, mirrorPreference, typoToggles) = presentationToggles
let (suppressOnTypo, offerCorrections, automaticallyFixTypos) = typoToggles
let (userName, customRules, responseLanguages, enabledSpellingDictionaryCodes) = profile
let (debounce, focusPoll, multiLine, autoAcceptPunctuation) = timing
let (debounce, focusPoll, multiLine, acceptToggles) = timing
let (autoAcceptPunctuation, addSpaceAfterAccept) = acceptToggles
let (isCustomActive, customLow, customHigh) = customRangeTuple
let (extendedContext, suggestInIntegratedTerminals) = extendedContextTuple
return SuggestionSettingsSnapshot(
Expand All @@ -970,6 +984,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
focusPollIntervalMilliseconds: focusPoll,
isMultiLineEnabled: multiLine,
autoAcceptTrailingPunctuation: autoAcceptPunctuation,
addSpaceAfterAccept: addSpaceAfterAccept,
isFastModeEnabled: fastModeEnabled,
mirrorPreference: mirrorPreference,
acceptanceGranularity: granularity,
Expand Down
21 changes: 21 additions & 0 deletions Cotabby/Support/SuggestionSessionReconciler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,27 @@ enum SuggestionSessionReconciler {
return String(chunk.drop(while: { $0.unicodeScalars.allSatisfy(CharacterSet.whitespaces.contains) }))
}

/// Appends a single trailing space to the text inserted by an accept that *exhausts* the
/// suggestion, so the user can keep typing the next word without reaching for the space bar.
///
/// The caller gates this on exhaustion: only the final chunk of a suggestion is eligible. A
/// mid-suggestion word accept is already followed by the next chunk's own leading space, so a
/// space here would double it. The space is also suppressed unless the inserted text ends on a
/// finished word — a letter or digit that is not a space-less-script (CJK, Thai, ...) glyph.
/// Trailing punctuation (`done.`, `(yes)`, `really?!`) and existing whitespace already mark a
/// boundary, and space-less scripts never separate words with spaces, so for all of those the
/// chunk is returned untouched. This is the opt-in counterpart to the WYSIWYG default that
/// `insertionChunk` documents: the space is a deliberate convenience the user enabled, not a
/// separator silently synthesized behind a suggestion they never saw.
static func insertionChunkAppendingTrailingSpace(_ chunk: String) -> String {
guard let last = chunk.last,
last.isAcceptanceWordCharacter,
!last.beginsSpacelessScriptWord else {
return chunk
}
return chunk + " "
}

/// Counts word-like tokens so punctuation-only accepts do not inflate productivity metrics.
static func acceptedWordCount(in text: String) -> Int {
text
Expand Down
11 changes: 11 additions & 0 deletions Cotabby/Support/SuggestionSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ struct SuggestionSettingsStore {
private static let preferredEmojiSkinToneDefaultsKey = "cotabbyPreferredEmojiSkinTone"
private static let preferredEmojiGenderDefaultsKey = "cotabbyPreferredEmojiGender"
private static let autoAcceptTrailingPunctuationDefaultsKey = "cotabbyAutoAcceptTrailingPunctuation"
private static let addSpaceAfterAcceptDefaultsKey = "cotabbyAddSpaceAfterAccept"
private static let acceptanceKeyCodeDefaultsKey = "cotabbyAcceptanceKeyCode"
private static let acceptanceKeyModifiersDefaultsKey = "cotabbyAcceptanceKeyModifiers"
private static let acceptanceKeyLabelDefaultsKey = "cotabbyAcceptanceKeyLabel"
Expand Down Expand Up @@ -280,6 +281,10 @@ struct SuggestionSettingsStore {
.flatMap(EmojiGender.init(rawValue:)) ?? .neutral
let resolvedAutoAcceptTrailingPunctuation =
userDefaults.object(forKey: Self.autoAcceptTrailingPunctuationDefaultsKey) as? Bool ?? true
// Defaults to false so the WYSIWYG accept behavior is unchanged for existing installs; the
// trailing space is opt-in from Settings.
let resolvedAddSpaceAfterAccept =
userDefaults.object(forKey: Self.addSpaceAfterAcceptDefaultsKey) as? Bool ?? false

let resolvedAcceptanceKeyCode = CGKeyCode(
userDefaults.object(forKey: Self.acceptanceKeyCodeDefaultsKey) as? Int
Expand Down Expand Up @@ -366,6 +371,7 @@ struct SuggestionSettingsStore {
preferredEmojiSkinTone: resolvedPreferredEmojiSkinTone,
preferredEmojiGender: resolvedPreferredEmojiGender,
autoAcceptTrailingPunctuation: resolvedAutoAcceptTrailingPunctuation,
addSpaceAfterAccept: resolvedAddSpaceAfterAccept,
acceptanceKeyCode: resolvedAcceptanceKeyCode,
acceptanceKeyModifiers: resolvedAcceptanceKeyModifiers,
acceptanceKeyLabel: resolvedAcceptanceKeyLabel,
Expand Down Expand Up @@ -418,6 +424,7 @@ struct SuggestionSettingsStore {
savePreferredEmojiSkinTone(data.preferredEmojiSkinTone)
savePreferredEmojiGender(data.preferredEmojiGender)
saveAutoAcceptTrailingPunctuation(data.autoAcceptTrailingPunctuation)
saveAddSpaceAfterAccept(data.addSpaceAfterAccept)
saveAcceptanceKey(
keyCode: data.acceptanceKeyCode,
modifiers: data.acceptanceKeyModifiers,
Expand Down Expand Up @@ -623,6 +630,10 @@ struct SuggestionSettingsStore {
userDefaults.set(enabled, forKey: Self.autoAcceptTrailingPunctuationDefaultsKey)
}

func saveAddSpaceAfterAccept(_ enabled: Bool) {
userDefaults.set(enabled, forKey: Self.addSpaceAfterAcceptDefaultsKey)
}

func saveAcceptanceKey(keyCode: CGKeyCode, modifiers: ShortcutModifierMask, label: String) {
userDefaults.set(Int(keyCode), forKey: Self.acceptanceKeyCodeDefaultsKey)
userDefaults.set(Int(modifiers.rawValue), forKey: Self.acceptanceKeyModifiersDefaultsKey)
Expand Down
16 changes: 16 additions & 0 deletions Cotabby/UI/Settings/Panes/GeneralPaneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ struct GeneralPaneView: View {
)
}

Toggle(isOn: addSpaceAfterAcceptBinding) {
SettingsRowLabel(
title: "Add Space After Accepting",
description: "When accepting a suggestion finishes a word, also add a space so you can " +
"keep typing. Skipped when it already ends in punctuation or a space.",
Comment on lines +77 to +78

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 description mentions punctuation and space as suppression cases but omits spaceless scripts (CJK, Thai, etc.). A user who writes in Japanese and enables this toggle will notice no space ever appears — the description as written doesn't tell them why. A small addition like "or in a language that doesn't use spaces (e.g. Chinese, Japanese)" would set the right expectation.

Suggested change
description: "When accepting a suggestion finishes a word, also add a space so you can " +
"keep typing. Skipped when it already ends in punctuation or a space.",
description: "When accepting a suggestion finishes a word, also add a space so you can " +
"keep typing. Skipped when it already ends in punctuation, a space, or a language " +
"that doesn't use spaces (e.g. Chinese, Japanese).",

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

systemImage: "space"
)
}

Toggle(isOn: macroExpansionEnabledBinding) {
SettingsRowLabel(
title: "Inline Macros",
Expand Down Expand Up @@ -182,6 +191,13 @@ struct GeneralPaneView: View {
)
}

private var addSpaceAfterAcceptBinding: Binding<Bool> {
Binding(
get: { suggestionSettings.addSpaceAfterAccept },
set: { suggestionSettings.setAddSpaceAfterAccept($0) }
)
}

private var macroExpansionEnabledBinding: Binding<Bool> {
Binding(
get: { suggestionSettings.isMacroExpansionEnabled },
Expand Down
8 changes: 7 additions & 1 deletion Cotabby/UI/Settings/SettingsIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case includeClipboardContext
case allowMultiLine
case acceptPunctuation
case addSpaceAfterAccept
case inlineMacros
case onboarding
// Appearance
Expand Down Expand Up @@ -89,6 +90,7 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case .includeClipboardContext: return "Include Clipboard Context"
case .allowMultiLine: return "Allow Multi-line Suggestions"
case .acceptPunctuation: return "Accept Punctuation With Word"
case .addSpaceAfterAccept: return "Add Space After Accepting"
case .inlineMacros: return "Inline Macros"
case .onboarding: return "Onboarding"
case .suggestionDisplay: return "Suggestion Display"
Expand Down Expand Up @@ -152,6 +154,7 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case .includeClipboardContext: return "doc.on.clipboard"
case .allowMultiLine: return "text.alignleft"
case .acceptPunctuation: return "textformat.abc"
case .addSpaceAfterAccept: return "space"
case .inlineMacros: return "slash.circle"
case .onboarding: return "graduationcap"
case .suggestionDisplay: return "text.cursor"
Expand Down Expand Up @@ -210,7 +213,7 @@ enum SettingsItem: String, CaseIterable, Identifiable {
var category: SettingsCategory {
switch self {
case .enableGlobally, .fastMode, .openAtLogin, .includeClipboardContext,
.allowMultiLine, .acceptPunctuation, .inlineMacros, .onboarding:
.allowMultiLine, .acceptPunctuation, .addSpaceAfterAccept, .inlineMacros, .onboarding:
return .general
case .suggestionDisplay, .showFieldIndicator, .showWordCount, .showKeyHint,
.ghostTextColor, .ghostTextOpacity, .ghostTextSize:
Expand Down Expand Up @@ -262,6 +265,9 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case .acceptPunctuation:
return ["punctuation", "comma", "period", "accept", "trailing", "auto accept",
"auto-accept", "space"]
case .addSpaceAfterAccept:
return ["space", "spacebar", "trailing space", "auto space", "add space",
"accept", "after accept", "whitespace", "gap", "separator"]
case .inlineMacros:
return ["macro", "macros", "math", "convert", "currency", "date", "random",
"expansion", "slash", "snippet", "shortcut", "formula", "calculator"]
Expand Down
2 changes: 2 additions & 0 deletions CotabbyTests/CotabbyTestFixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ enum CotabbyTestFixtures {
focusPollIntervalMilliseconds: Int = 50,
isMultiLineEnabled: Bool = false,
autoAcceptTrailingPunctuation: Bool = true,
addSpaceAfterAccept: Bool = false,
isFastModeEnabled: Bool = false,
mirrorPreference: MirrorPreference = .auto,
acceptanceGranularity: AcceptanceGranularity = .word,
Expand All @@ -263,6 +264,7 @@ enum CotabbyTestFixtures {
focusPollIntervalMilliseconds: focusPollIntervalMilliseconds,
isMultiLineEnabled: isMultiLineEnabled,
autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation,
addSpaceAfterAccept: addSpaceAfterAccept,
isFastModeEnabled: isFastModeEnabled,
mirrorPreference: mirrorPreference,
acceptanceGranularity: acceptanceGranularity,
Expand Down
52 changes: 52 additions & 0 deletions CotabbyTests/SuggestionSessionReconcilerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,58 @@ final class SuggestionSessionReconcilerTests: XCTestCase {
)
}

func test_insertionChunkAppendingTrailingSpace_appendsAfterFinishedWord() {
XCTAssertEqual(
SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("hello"),
"hello "
)
}

func test_insertionChunkAppendingTrailingSpace_appendsAfterTrailingDigit() {
XCTAssertEqual(
SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("section 12"),
"section 12 "
)
}

func test_insertionChunkAppendingTrailingSpace_skipsWhenEndingInPunctuation() {
XCTAssertEqual(
SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("done."),
"done."
)
XCTAssertEqual(
SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("really?!"),
"really?!"
)
XCTAssertEqual(
SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("(yes)"),
"(yes)"
)
}

func test_insertionChunkAppendingTrailingSpace_skipsWhenAlreadyEndingInWhitespace() {
XCTAssertEqual(
SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("hello "),
"hello "
)
}

func test_insertionChunkAppendingTrailingSpace_skipsForSpacelessScript() {
// CJK glyphs are letters, but their scripts never separate words with spaces, so a trailing
// space would be wrong. The space-less-script guard suppresses it.
XCTAssertEqual(
SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("資料"),
"資料"
)
}

func test_insertionChunkAppendingTrailingSpace_leavesEmptyChunkUntouched() {
XCTAssertEqual(
SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace(""),
""
)
}

func test_acceptedWordCount_countsOnlyTokensWithAlphanumerics() {
let count = SuggestionSessionReconciler.acceptedWordCount(
in: "hello, !!! world 123 --"
Expand Down