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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,39 @@ extension SuggestionCoordinator {
acceptSuggestion(fullText: true, keyName: "full-accept")
}

/// Entry point for a real press of the Accept Word key.
///
/// With double-tap enabled, the first press still accepts one word immediately, and a second
/// press within `DoubleTapAcceptanceState.window` on the same suggestion accepts what remains.
/// Promoting the second press instead of delaying the first keeps single-word acceptance as fast
/// as before; the pair still commits the whole suggestion. The queued post-exhaustion accept
/// calls `acceptCurrentSuggestion` directly, so a Tab buffered during regeneration never turns
/// into an accept-everything of a continuation the user has not seen yet.
func acceptForWordAcceptKeyPress() -> Bool {
guard settingsSnapshot.doubleTapAcceptsEntireSuggestion else {
doubleTapAcceptanceState.reset()
return acceptCurrentSuggestion()
}

let now = ProcessInfo.processInfo.systemUptime
if let session = interactionState.activeSession, !session.kind.isCorrection,
doubleTapAcceptanceState.consumeDoubleTap(of: .init(session: session), at: now) {
return acceptSuggestion(fullText: true, keyName: "double-tap")
Comment on lines +37 to +39

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.

P1 Key repeat triggers full acceptance

If a held Accept Word key repeats within 300 ms, the repeated key-down is treated as a second press because the accept path does not distinguish repeats. It can insert the entire remaining suggestion when the user pressed the key only once. Exclude repeated key-downs from double-tap recognition.

Knowledge Base Used: Keyboard input and text insertion

Fix in Codex Fix in Claude Code

}

let accepted = acceptCurrentSuggestion()
// Arm only when this press left a continuation with text still to accept. An exhausted
// suggestion hands Tab to the post-exhaustion window instead, and a correction commits as
// a unit, so neither has a "rest" for a second press to take.
if accepted, let advanced = interactionState.activeSession, !advanced.kind.isCorrection,
!advanced.isExhausted {
doubleTapAcceptanceState.recordWordAccept(of: .init(session: advanced), at: now)
} else {
doubleTapAcceptanceState.reset()
}
return accepted
}

/// Shared acceptance path used by both word-by-word and full acceptance.
private func acceptSuggestion(
fullText: Bool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,14 @@ extension SuggestionCoordinator {
completion: session.remainingText, at: ProcessInfo.processInfo.systemUptime)
}

// Anything between two Accept Word presses (typing, navigation, the full-accept key) means
// they are not one double tap, even if both land inside the timing window.
if event.kind != .acceptance {
doubleTapAcceptanceState.reset()
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

if event.kind == .acceptance {
return acceptCurrentSuggestion()
return acceptForWordAcceptKeyPress()
}

if event.kind == .fullAcceptance {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ final class SuggestionCoordinator: ObservableObject {
/// coordinator continues to own the timer and input-monitor effects around these transitions.
var postExhaustionAcceptanceState = PostExhaustionAcceptanceState()

/// Pure state for recognizing a quick second press of the Accept Word key. Only real key presses
/// feed it; the queued post-exhaustion accept stays a plain one-word accept.
var doubleTapAcceptanceState = DoubleTapAcceptanceState()

init(
permissionManager: any SuggestionPermissionProviding,
lowPowerModeProvider: any SuggestionLowPowerModeProviding,
Expand Down
9 changes: 9 additions & 0 deletions Cotabby/Models/Settings/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ struct SuggestionShortcutSettings: Equatable {
var fullAcceptance: SuggestionShortcutBindingSettings
var globalToggle: SuggestionShortcutBindingSettings
var perAppOverrides: [PerAppShortcutOverride]
/// When true, pressing the Accept Word key twice in quick succession accepts the whole
/// suggestion. It modifies the existing word-accept binding rather than adding a new binding, so
/// it applies to per-app accept overrides as well.
var doubleTapAcceptsEntireSuggestion: Bool
}

/// Pure domain representation of every durable suggestion preference.
Expand Down Expand Up @@ -469,4 +473,9 @@ extension SuggestionSettingsData {
get { shortcuts.perAppOverrides }
set { shortcuts.perAppOverrides = newValue }
}

var doubleTapAcceptsEntireSuggestion: Bool {
get { shortcuts.doubleTapAcceptsEntireSuggestion }
set { shortcuts.doubleTapAcceptsEntireSuggestion = newValue }
}
}
87 changes: 81 additions & 6 deletions Cotabby/Models/Settings/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ final class SuggestionSettingsModel: ObservableObject {
/// Per-app accept/full-accept overrides. Published so the input monitor's event-time provider
/// closures (via `ShortcutResolver`) and the Apps settings pane both observe the live list.
@Published private(set) var perAppShortcutOverrides: [PerAppShortcutOverride]
/// Whether Accept Entire Suggestion is bound to a quick double press of the Accept Word key.
/// The Accept Entire Suggestion slot holds one shortcut, so while this is true the one-press
/// full-accept key is unbound; the setters below keep the two mutually exclusive.
@Published private(set) var doubleTapAcceptsEntireSuggestion: Bool
@Published private(set) var acceptanceGranularity: AcceptanceGranularity
@Published private(set) var isPowerBasedModelSwitchingEnabled: Bool
@Published private(set) var batteryEngine: SuggestionEngineKind
Expand Down Expand Up @@ -284,6 +288,7 @@ final class SuggestionSettingsModel: ObservableObject {
globalToggleKeyModifiers = data.globalToggleKeyModifiers
globalToggleKeyLabel = data.globalToggleKeyLabel
perAppShortcutOverrides = data.perAppShortcutOverrides
doubleTapAcceptsEntireSuggestion = data.doubleTapAcceptsEntireSuggestion
acceptanceGranularity = data.acceptanceGranularity
isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled
batteryEngine = data.batteryEngine
Expand Down Expand Up @@ -367,6 +372,7 @@ final class SuggestionSettingsModel: ObservableObject {
globalToggleKeyModifiers = data.globalToggleKeyModifiers
globalToggleKeyLabel = data.globalToggleKeyLabel
perAppShortcutOverrides = data.perAppShortcutOverrides
doubleTapAcceptsEntireSuggestion = data.doubleTapAcceptsEntireSuggestion
acceptanceGranularity = data.acceptanceGranularity
isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled
batteryEngine = data.batteryEngine
Expand Down Expand Up @@ -480,7 +486,8 @@ final class SuggestionSettingsModel: ObservableObject {
modifiers: globalToggleKeyModifiers,
label: globalToggleKeyLabel
),
perAppOverrides: perAppShortcutOverrides
perAppOverrides: perAppShortcutOverrides,
doubleTapAcceptsEntireSuggestion: doubleTapAcceptsEntireSuggestion
)
)
}
Expand Down Expand Up @@ -521,7 +528,8 @@ final class SuggestionSettingsModel: ObservableObject {
suppressCompletionsOnTypo: settings.correction.suppressCompletionsOnTypo,
offerTypoCorrections: settings.correction.offerTypoCorrections,
enabledSpellingDictionaryCodes: settings.correction.enabledSpellingDictionaryCodes,
automaticallyFixTypos: settings.correction.automaticallyFixTypos
automaticallyFixTypos: settings.correction.automaticallyFixTypos,
doubleTapAcceptsEntireSuggestion: settings.shortcuts.doubleTapAcceptsEntireSuggestion
)
}

Expand Down Expand Up @@ -903,6 +911,60 @@ final class SuggestionSettingsModel: ObservableObject {
)
}

private func setDoubleTapAcceptsEntireSuggestion(_ enabled: Bool) {
guard doubleTapAcceptsEntireSuggestion != enabled else {
return
}
doubleTapAcceptsEntireSuggestion = enabled
store.saveDoubleTapAcceptsEntireSuggestion(enabled)
}

/// Binds Accept Entire Suggestion to a double press of the Accept Word key, replacing any
/// one-press key in that slot. Ignored while Accept Word is unbound, since there is no key to
/// press twice.
func setDoubleTapFullAcceptance() {
guard acceptanceKeyCode != Self.disabledKeyCode else { return }
setFullAcceptanceKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel)

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.

P1 Double-tap removes full-accept key

When a user records double-tap acceptance, this call clears the existing one-press full-accept binding. With the default shortcuts, ⌥Tab then stops accepting the entire suggestion, even though it is meant to keep working. A customized full-accept key is discarded too.

Knowledge Base Used: Settings experience and persistence

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

setDoubleTapAcceptsEntireSuggestion(true)
}

/// True when the double-tap binding can actually fire. It rides on the Accept Word key, so it
/// is inert while that key is unbound.
var isDoubleTapFullAcceptanceActive: Bool {
doubleTapAcceptsEntireSuggestion && acceptanceKeyCode != Self.disabledKeyCode
}

/// The Accept Entire Suggestion shortcut as users should read it: the one-press key, or the
/// Accept Word key written twice ("Tab Tab").
var fullAcceptanceDisplayLabel: String {
isDoubleTapFullAcceptanceActive ? "\(acceptanceKeyLabel) \(acceptanceKeyLabel)" : fullAcceptanceKeyLabel
}

/// What accepts the whole suggestion in one app when it inherits the global shortcut. The
/// double tap is a double press of that app's own Accept Word key, so a per-app Accept Word
/// override changes it too, and disabling Accept Word there leaves no double tap at all.
func inheritedFullAcceptanceDisplayLabel(forBundleIdentifier bundleIdentifier: String?) -> String {
let fullAccept = resolvedFullAcceptBinding(forBundleIdentifier: bundleIdentifier)
guard doubleTapAcceptsEntireSuggestion, fullAccept.keyCode == Self.disabledKeyCode else {
return fullAccept.label
}
let accept = resolvedAcceptBinding(forBundleIdentifier: bundleIdentifier)
guard accept.keyCode != Self.disabledKeyCode else { return fullAccept.label }
return "\(accept.label) \(accept.label)"
}

/// Whether any shortcut accepts the whole suggestion, so views can offer Clear.
var hasFullAcceptanceShortcut: Bool {
isDoubleTapFullAcceptanceActive || fullAcceptanceKeyCode != Self.disabledKeyCode
}

/// Whether the slot still holds the factory one-press key, so views can hide Reset.
var isFullAcceptanceShortcutDefault: Bool {
!isDoubleTapFullAcceptanceActive
&& fullAcceptanceKeyCode == Self.defaultFullAcceptanceKeyCode
&& fullAcceptanceKeyModifiers.isEmpty
}

func setAutoAcceptTrailingPunctuation(_ enabled: Bool) {
guard autoAcceptTrailingPunctuation != enabled else {
return
Expand Down Expand Up @@ -1328,10 +1390,18 @@ final class SuggestionSettingsModel: ObservableObject {
}

func clearAcceptanceKey() {
// The double-tap binding is a double press of this key, so it goes away with it rather than
// reappearing unexpectedly when a new Accept Word key is recorded later.
setDoubleTapAcceptsEntireSuggestion(false)
setAcceptanceKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel)
}

func setFullAcceptanceKey(keyCode: CGKeyCode, modifiers: ShortcutModifierMask, label: String) {
// A real key takes the slot over from a double tap. Unbinding leaves the flag alone so
// `setDoubleTapFullAcceptance` can clear the key without undoing itself.
if keyCode != Self.disabledKeyCode {
setDoubleTapAcceptsEntireSuggestion(false)
}
let normalizedModifiers = keyCode == Self.disabledKeyCode ? [] : modifiers
guard fullAcceptanceKeyCode != keyCode
|| fullAcceptanceKeyModifiers != normalizedModifiers
Expand All @@ -1353,6 +1423,7 @@ final class SuggestionSettingsModel: ObservableObject {
}

func clearFullAcceptanceKey() {
setDoubleTapAcceptsEntireSuggestion(false)
setFullAcceptanceKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel)
}

Expand Down Expand Up @@ -1647,10 +1718,13 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
// top-level setting gets layered above via another `CombineLatest`. `extendedContext` joins
// alongside `acceptanceGranularity` here for the same reason. The three custom-range fields
// travel together as a single tuple so they only cost one slot in this outer layer.
let customRange = Publishers.CombineLatest3(
// The double-tap toggle rides in this slot's last free input; it is unrelated to the range
// but costs no extra layer of nesting.
let customRange = Publishers.CombineLatest4(
$isUsingCustomWordCountRange,
$customWordCountLowWords,
$customWordCountHighWords
$customWordCountHighWords,
$doubleTapAcceptsEntireSuggestion
)
// The outer `CombineLatest4` is full, so these settings share its grouped publisher slot.
return Publishers.CombineLatest4(
Expand All @@ -1674,7 +1748,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
let (debounce, focusPoll, generationToggles, acceptToggles) = timing
let (multiLine, suggestWithinWords, showFollowingWords) = generationToggles
let (autoAcceptPunctuation, addSpaceAfterAccept, streamWhileGenerating, predictAhead) = acceptToggles
let (isCustomActive, customLow, customHigh) = customRangeTuple
let (isCustomActive, customLow, customHigh, doubleTapAcceptsEntireSuggestion) = customRangeTuple
let (extendedContext, suggestInIntegratedTerminals, surfaceContextEnabled, lowPowerModeAutoDisableEnabled) =
extendedContextTuple
return SuggestionSettingsSnapshot(
Expand Down Expand Up @@ -1708,7 +1782,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
suppressCompletionsOnTypo: suppressOnTypo,
offerTypoCorrections: offerCorrections,
enabledSpellingDictionaryCodes: enabledSpellingDictionaryCodes,
automaticallyFixTypos: automaticallyFixTypos
automaticallyFixTypos: automaticallyFixTypos,
doubleTapAcceptsEntireSuggestion: doubleTapAcceptsEntireSuggestion
)
}
.removeDuplicates()
Expand Down
4 changes: 4 additions & 0 deletions Cotabby/Models/Suggestion/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable {
/// commits the misspelled word with Space. The word boundary prevents pauses in unfinished words
/// from triggering destructive edits.
let automaticallyFixTypos: Bool
/// When true, a second press of the Accept Word key within `DoubleTapAcceptanceState.window`
/// accepts the rest of the suggestion. Travels in the snapshot so the acceptance path reads the
/// live value without subscribing to the settings model.
let doubleTapAcceptsEntireSuggestion: 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
14 changes: 13 additions & 1 deletion Cotabby/Support/Settings/SuggestionSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ struct SuggestionSettingsStore {
private static let pauseStateDefaultsKey = "cotabbySuggestionPauseState"
private static let disabledAppRulesDefaultsKey = "cotabbyDisabledAppRules"
private static let perAppShortcutOverridesDefaultsKey = "cotabbyPerAppShortcutOverrides"
private static let doubleTapAcceptsEntireSuggestionDefaultsKey = "cotabbyDoubleTapAcceptsEntireSuggestion"
private static let suggestInIntegratedTerminalsDefaultsKey = "cotabbySuggestInIntegratedTerminals"
private static let showCaretIndicatorDefaultsKey = "cotabbyShowCaretIndicator"
private static let selectedIndicatorModeDefaultsKey = "cotabbySelectedIndicatorMode"
Expand Down Expand Up @@ -209,6 +210,7 @@ struct SuggestionSettingsStore {
pauseStateDefaultsKey,
disabledAppRulesDefaultsKey,
perAppShortcutOverridesDefaultsKey,
doubleTapAcceptsEntireSuggestionDefaultsKey,
suggestInIntegratedTerminalsDefaultsKey,
showCaretIndicatorDefaultsKey,
selectedIndicatorModeDefaultsKey,
Expand Down Expand Up @@ -294,6 +296,10 @@ struct SuggestionSettingsStore {
let resolvedPauseState = persistedPauseState?.activeState()
let resolvedDisabledAppRules = loadDisabledAppRules()
let resolvedPerAppShortcutOverrides = loadPerAppShortcutOverrides()
// Off by default: a fast second Tab has always accepted one more word, and turning it into a
// whole-suggestion accept is an opt-in change to that muscle memory.
let resolvedDoubleTapAcceptsEntireSuggestion =
userDefaults.object(forKey: Self.doubleTapAcceptsEntireSuggestionDefaultsKey) as? Bool ?? false
let resolvedShowIndicator: Bool = if let modeString = userDefaults.string(
forKey: Self.selectedIndicatorModeDefaultsKey
) {
Expand Down Expand Up @@ -650,7 +656,8 @@ struct SuggestionSettingsStore {
modifiers: resolvedGlobalToggleKeyModifiers,
label: resolvedGlobalToggleKeyLabel
),
perAppOverrides: resolvedPerAppShortcutOverrides
perAppOverrides: resolvedPerAppShortcutOverrides,
doubleTapAcceptsEntireSuggestion: resolvedDoubleTapAcceptsEntireSuggestion
)
)

Expand Down Expand Up @@ -722,6 +729,7 @@ struct SuggestionSettingsStore {
label: data.globalToggleKeyLabel
)
savePerAppShortcutOverrides(data.perAppShortcutOverrides)
saveDoubleTapAcceptsEntireSuggestion(data.doubleTapAcceptsEntireSuggestion)
saveAcceptanceGranularity(data.acceptanceGranularity)
savePowerBasedModelSwitchingEnabled(data.isPowerBasedModelSwitchingEnabled)
saveBatteryEngine(data.batteryEngine)
Expand Down Expand Up @@ -786,6 +794,10 @@ struct SuggestionSettingsStore {
}

/// Removing the key for an empty list keeps reset state identical to a fresh install.
func saveDoubleTapAcceptsEntireSuggestion(_ enabled: Bool) {
userDefaults.set(enabled, forKey: Self.doubleTapAcceptsEntireSuggestionDefaultsKey)
}

func savePerAppShortcutOverrides(_ overrides: [PerAppShortcutOverride]) {
guard !overrides.isEmpty else {
userDefaults.removeObject(forKey: Self.perAppShortcutOverridesDefaultsKey)
Expand Down
Loading