diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index 33b009b4..01dc2e5f 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -134,17 +134,27 @@ extension SuggestionCoordinator { // Resetting the flag here would instead double-schedule a drain for one partial. streamRenderedText = nil pendingStreamPartial = nil + // Streaming the ghost text token-by-token is opt-in. Read the flag here on the main actor so + // the work closure captures a plain Bool. When off, the closure passes no `onPartial`, so the + // engine skips its per-token main-actor hops entirely and the suggestion appears once, fully + // formed, through `apply` below; when on, each partial renders as an acceptable session the + // user can Tab into early. + let shouldStreamPartials = settingsSnapshot.streamSuggestionsWhileGenerating workController.replaceGenerationWork(for: workID) { [weak self] in guard let self else { return } do { + let onPartial: (@MainActor (SuggestionResult) -> Void)? + if shouldStreamPartials { + onPartial = { [weak self] partial in self?.queueStreamedPartial(partial, workID: workID) } + } else { + onPartial = nil + } let result = try await suggestionEngine.generateSuggestion( for: request, - onPartial: { [weak self] partial in - self?.queueStreamedPartial(partial, workID: workID) - } + onPartial: onPartial ) guard !Task.isCancelled, self.workController.isCurrent(workID) else { return diff --git a/Cotabby/Models/SuggestionEngineModels.swift b/Cotabby/Models/SuggestionEngineModels.swift index 49b6b9de..a2f155e8 100644 --- a/Cotabby/Models/SuggestionEngineModels.swift +++ b/Cotabby/Models/SuggestionEngineModels.swift @@ -122,6 +122,11 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable { /// 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, ghost text is streamed token-by-token as the model decodes (each partial an + /// acceptable session); when false (the default) the suggestion appears once after generation + /// finishes. Travels in the snapshot so the prediction path reads the live value when deciding + /// whether to pass an `onPartial` handler to the engine. + let streamSuggestionsWhileGenerating: 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 diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index 42ffc3ce..7a6ecc7d 100644 --- a/Cotabby/Models/SuggestionSettingsData.swift +++ b/Cotabby/Models/SuggestionSettingsData.swift @@ -71,6 +71,10 @@ struct SuggestionSettingsData: Equatable { /// 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 + /// When on, ghost text is revealed token-by-token as the model decodes, and each partial is an + /// acceptable session the user can Tab into early. When off (the default), the suggestion appears + /// once, fully formed, after generation finishes. + var streamSuggestionsWhileGenerating: Bool var acceptanceKeyCode: CGKeyCode var acceptanceKeyModifiers: ShortcutModifierMask var acceptanceKeyLabel: String diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index a29acdb9..25ccf1d0 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -103,6 +103,7 @@ final class SuggestionSettingsModel: ObservableObject { @Published private(set) var preferredEmojiGender: EmojiGender @Published private(set) var autoAcceptTrailingPunctuation: Bool @Published private(set) var addSpaceAfterAccept: Bool + @Published private(set) var streamSuggestionsWhileGenerating: Bool @Published private(set) var acceptanceKeyCode: CGKeyCode @Published private(set) var acceptanceKeyModifiers: ShortcutModifierMask @Published private(set) var acceptanceKeyLabel: String @@ -187,6 +188,7 @@ final class SuggestionSettingsModel: ObservableObject { preferredEmojiGender = data.preferredEmojiGender autoAcceptTrailingPunctuation = data.autoAcceptTrailingPunctuation addSpaceAfterAccept = data.addSpaceAfterAccept + streamSuggestionsWhileGenerating = data.streamSuggestionsWhileGenerating acceptanceKeyCode = data.acceptanceKeyCode acceptanceKeyModifiers = data.acceptanceKeyModifiers acceptanceKeyLabel = data.acceptanceKeyLabel @@ -232,6 +234,7 @@ final class SuggestionSettingsModel: ObservableObject { isMultiLineEnabled: isMultiLineEnabled, autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, addSpaceAfterAccept: addSpaceAfterAccept, + streamSuggestionsWhileGenerating: streamSuggestionsWhileGenerating, isFastModeEnabled: isFastModeEnabled, mirrorPreference: mirrorPreference, acceptanceGranularity: acceptanceGranularity, @@ -535,6 +538,14 @@ final class SuggestionSettingsModel: ObservableObject { store.saveAddSpaceAfterAccept(enabled) } + func setStreamSuggestionsWhileGenerating(_ enabled: Bool) { + guard streamSuggestionsWhileGenerating != enabled else { + return + } + streamSuggestionsWhileGenerating = enabled + store.saveStreamSuggestionsWhileGenerating(enabled) + } + func setAcceptanceGranularity(_ granularity: AcceptanceGranularity) { guard acceptanceGranularity != granularity else { return @@ -945,13 +956,18 @@ 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. + // The acceptance toggles and the streaming-reveal toggle share this slot via a grouped + // `CombineLatest3` so new settings cost no extra upstream in a tuple already at Combine's + // four-input cap. Publishers.CombineLatest4( $debounceMilliseconds, $focusPollIntervalMilliseconds, $isMultiLineEnabled, - Publishers.CombineLatest($autoAcceptTrailingPunctuation, $addSpaceAfterAccept) + Publishers.CombineLatest3( + $autoAcceptTrailingPunctuation, + $addSpaceAfterAccept, + $streamSuggestionsWhileGenerating + ) ) ) // The outer CombineLatest stack is already at Combine's per-operator cap, so each new @@ -979,7 +995,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { let (suppressOnTypo, offerCorrections, automaticallyFixTypos) = typoToggles let (userName, customRules, responseLanguages, enabledSpellingDictionaryCodes) = profile let (debounce, focusPoll, multiLine, acceptToggles) = timing - let (autoAcceptPunctuation, addSpaceAfterAccept) = acceptToggles + let (autoAcceptPunctuation, addSpaceAfterAccept, streamWhileGenerating) = acceptToggles let (isCustomActive, customLow, customHigh) = customRangeTuple let (extendedContext, suggestInIntegratedTerminals, surfaceContextEnabled) = extendedContextTuple return SuggestionSettingsSnapshot( @@ -1001,6 +1017,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { isMultiLineEnabled: multiLine, autoAcceptTrailingPunctuation: autoAcceptPunctuation, addSpaceAfterAccept: addSpaceAfterAccept, + streamSuggestionsWhileGenerating: streamWhileGenerating, isFastModeEnabled: fastModeEnabled, mirrorPreference: mirrorPreference, acceptanceGranularity: granularity, diff --git a/Cotabby/Support/SuggestionSettingsStore.swift b/Cotabby/Support/SuggestionSettingsStore.swift index 726cfc6c..966fa5cb 100644 --- a/Cotabby/Support/SuggestionSettingsStore.swift +++ b/Cotabby/Support/SuggestionSettingsStore.swift @@ -106,6 +106,7 @@ struct SuggestionSettingsStore { private static let preferredEmojiGenderDefaultsKey = "cotabbyPreferredEmojiGender" private static let autoAcceptTrailingPunctuationDefaultsKey = "cotabbyAutoAcceptTrailingPunctuation" private static let addSpaceAfterAcceptDefaultsKey = "cotabbyAddSpaceAfterAccept" + private static let streamWhileGeneratingDefaultsKey = "cotabbyStreamSuggestionsWhileGenerating" private static let acceptanceKeyCodeDefaultsKey = "cotabbyAcceptanceKeyCode" private static let acceptanceKeyModifiersDefaultsKey = "cotabbyAcceptanceKeyModifiers" private static let acceptanceKeyLabelDefaultsKey = "cotabbyAcceptanceKeyLabel" @@ -291,6 +292,10 @@ struct SuggestionSettingsStore { // trailing space is opt-in from Settings. let resolvedAddSpaceAfterAccept = userDefaults.object(forKey: Self.addSpaceAfterAcceptDefaultsKey) as? Bool ?? false + // Defaults to false so the suggestion appears once, fully formed; token-by-token streaming + // is opt-in from Settings. + let resolvedStreamSuggestionsWhileGenerating = + userDefaults.object(forKey: Self.streamWhileGeneratingDefaultsKey) as? Bool ?? false let resolvedAcceptanceKeyCode = CGKeyCode( userDefaults.object(forKey: Self.acceptanceKeyCodeDefaultsKey) as? Int @@ -379,6 +384,7 @@ struct SuggestionSettingsStore { preferredEmojiGender: resolvedPreferredEmojiGender, autoAcceptTrailingPunctuation: resolvedAutoAcceptTrailingPunctuation, addSpaceAfterAccept: resolvedAddSpaceAfterAccept, + streamSuggestionsWhileGenerating: resolvedStreamSuggestionsWhileGenerating, acceptanceKeyCode: resolvedAcceptanceKeyCode, acceptanceKeyModifiers: resolvedAcceptanceKeyModifiers, acceptanceKeyLabel: resolvedAcceptanceKeyLabel, @@ -433,6 +439,7 @@ struct SuggestionSettingsStore { savePreferredEmojiGender(data.preferredEmojiGender) saveAutoAcceptTrailingPunctuation(data.autoAcceptTrailingPunctuation) saveAddSpaceAfterAccept(data.addSpaceAfterAccept) + saveStreamSuggestionsWhileGenerating(data.streamSuggestionsWhileGenerating) saveAcceptanceKey( keyCode: data.acceptanceKeyCode, modifiers: data.acceptanceKeyModifiers, @@ -646,6 +653,10 @@ struct SuggestionSettingsStore { userDefaults.set(enabled, forKey: Self.addSpaceAfterAcceptDefaultsKey) } + func saveStreamSuggestionsWhileGenerating(_ enabled: Bool) { + userDefaults.set(enabled, forKey: Self.streamWhileGeneratingDefaultsKey) + } + func saveAcceptanceKey(keyCode: CGKeyCode, modifiers: ShortcutModifierMask, label: String) { userDefaults.set(Int(keyCode), forKey: Self.acceptanceKeyCodeDefaultsKey) userDefaults.set(Int(modifiers.rawValue), forKey: Self.acceptanceKeyModifiersDefaultsKey) diff --git a/Cotabby/UI/Settings/Panes/AppearancePaneView.swift b/Cotabby/UI/Settings/Panes/AppearancePaneView.swift index 34badf0d..d201c125 100644 --- a/Cotabby/UI/Settings/Panes/AppearancePaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppearancePaneView.swift @@ -27,6 +27,15 @@ struct AppearancePaneView: View { } .pickerStyle(.menu) + Toggle(isOn: streamWhileGeneratingBinding) { + SettingsRowLabel( + title: "Stream Suggestions While Generating", + description: "Reveal ghost text token-by-token as the model writes it, and let you accept " + + "early. Off shows each suggestion once it's fully written.", + systemImage: "text.append" + ) + } + Toggle(isOn: showIndicatorBinding) { SettingsRowLabel( title: "Show Field Indicator", @@ -145,6 +154,13 @@ struct AppearancePaneView: View { // MARK: - Bindings + private var streamWhileGeneratingBinding: Binding { + Binding( + get: { suggestionSettings.streamSuggestionsWhileGenerating }, + set: { suggestionSettings.setStreamSuggestionsWhileGenerating($0) } + ) + } + private var showIndicatorBinding: Binding { Binding( get: { suggestionSettings.showIndicator }, diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index ef13b543..07ecdf15 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -22,6 +22,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case onboarding // Appearance case suggestionDisplay + case streamWhileGenerating case showFieldIndicator case showWordCount case showKeyHint @@ -96,6 +97,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .inlineMacros: return "Inline Macros" case .onboarding: return "Onboarding" case .suggestionDisplay: return "Suggestion Display" + case .streamWhileGenerating: return "Stream Suggestions While Generating" case .showFieldIndicator: return "Show Field Indicator" case .showWordCount: return "Show Word Count in Menu Bar" case .showKeyHint: return "Show Accept-Key Hint" @@ -161,6 +163,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .inlineMacros: return "slash.circle" case .onboarding: return "graduationcap" case .suggestionDisplay: return "text.cursor" + case .streamWhileGenerating: return "text.append" case .showFieldIndicator: return "dot.viewfinder" case .showWordCount: return "number" case .showKeyHint: return "keyboard" @@ -218,7 +221,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .enableGlobally, .fastMode, .openAtLogin, .includeClipboardContext, .includeAppContext, .allowMultiLine, .acceptPunctuation, .addSpaceAfterAccept, .inlineMacros, .onboarding: return .general - case .suggestionDisplay, .showFieldIndicator, .showWordCount, .showKeyHint, + case .suggestionDisplay, .streamWhileGenerating, .showFieldIndicator, .showWordCount, .showKeyHint, .ghostTextColor, .ghostTextOpacity, .ghostTextSize: return .appearance case .emojiPicker, .emojiSkinTone, .emojiPeopleStyle, .emojiHistory: @@ -283,6 +286,10 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .suggestionDisplay: return ["inline", "popup", "ghost", "card", "display", "mirror", "auto", "appearance", "style", "show suggestion", "rendering", "ui"] + case .streamWhileGenerating: + return ["stream", "streaming", "live", "typewriter", "token", "incremental", + "progressive", "word by word", "character by character", "reveal", + "while generating", "as it types", "decode", "partial", "all at once"] case .showFieldIndicator: return ["indicator", "icon", "field", "ready", "dot", "marker", "badge", "show", "hide"] diff --git a/CotabbyTests/CotabbyTestFixtures.swift b/CotabbyTests/CotabbyTestFixtures.swift index 09f60508..fb78df21 100644 --- a/CotabbyTests/CotabbyTestFixtures.swift +++ b/CotabbyTests/CotabbyTestFixtures.swift @@ -254,6 +254,7 @@ enum CotabbyTestFixtures { isMultiLineEnabled: Bool = false, autoAcceptTrailingPunctuation: Bool = true, addSpaceAfterAccept: Bool = false, + streamSuggestionsWhileGenerating: Bool = false, isFastModeEnabled: Bool = false, mirrorPreference: MirrorPreference = .auto, acceptanceGranularity: AcceptanceGranularity = .word, @@ -281,6 +282,7 @@ enum CotabbyTestFixtures { isMultiLineEnabled: isMultiLineEnabled, autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, addSpaceAfterAccept: addSpaceAfterAccept, + streamSuggestionsWhileGenerating: streamSuggestionsWhileGenerating, isFastModeEnabled: isFastModeEnabled, mirrorPreference: mirrorPreference, acceptanceGranularity: acceptanceGranularity,