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
16 changes: 13 additions & 3 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions Cotabby/Models/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Cotabby/Models/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 21 additions & 4 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -232,6 +234,7 @@ final class SuggestionSettingsModel: ObservableObject {
isMultiLineEnabled: isMultiLineEnabled,
autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation,
addSpaceAfterAccept: addSpaceAfterAccept,
streamSuggestionsWhileGenerating: streamSuggestionsWhileGenerating,
isFastModeEnabled: isFastModeEnabled,
mirrorPreference: mirrorPreference,
acceptanceGranularity: acceptanceGranularity,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -1001,6 +1017,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
isMultiLineEnabled: multiLine,
autoAcceptTrailingPunctuation: autoAcceptPunctuation,
addSpaceAfterAccept: addSpaceAfterAccept,
streamSuggestionsWhileGenerating: streamWhileGenerating,
isFastModeEnabled: fastModeEnabled,
mirrorPreference: mirrorPreference,
acceptanceGranularity: granularity,
Expand Down
11 changes: 11 additions & 0 deletions Cotabby/Support/SuggestionSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -379,6 +384,7 @@ struct SuggestionSettingsStore {
preferredEmojiGender: resolvedPreferredEmojiGender,
autoAcceptTrailingPunctuation: resolvedAutoAcceptTrailingPunctuation,
addSpaceAfterAccept: resolvedAddSpaceAfterAccept,
streamSuggestionsWhileGenerating: resolvedStreamSuggestionsWhileGenerating,
acceptanceKeyCode: resolvedAcceptanceKeyCode,
acceptanceKeyModifiers: resolvedAcceptanceKeyModifiers,
acceptanceKeyLabel: resolvedAcceptanceKeyLabel,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions Cotabby/UI/Settings/Panes/AppearancePaneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -145,6 +154,13 @@ struct AppearancePaneView: View {

// MARK: - Bindings

private var streamWhileGeneratingBinding: Binding<Bool> {
Binding(
get: { suggestionSettings.streamSuggestionsWhileGenerating },
set: { suggestionSettings.setStreamSuggestionsWhileGenerating($0) }
)
}

private var showIndicatorBinding: Binding<Bool> {
Binding(
get: { suggestionSettings.showIndicator },
Expand Down
9 changes: 8 additions & 1 deletion Cotabby/UI/Settings/SettingsIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case onboarding
// Appearance
case suggestionDisplay
case streamWhileGenerating
case showFieldIndicator
case showWordCount
case showKeyHint
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"]
Expand Down
2 changes: 2 additions & 0 deletions CotabbyTests/CotabbyTestFixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -281,6 +282,7 @@ enum CotabbyTestFixtures {
isMultiLineEnabled: isMultiLineEnabled,
autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation,
addSpaceAfterAccept: addSpaceAfterAccept,
streamSuggestionsWhileGenerating: streamSuggestionsWhileGenerating,
isFastModeEnabled: isFastModeEnabled,
mirrorPreference: mirrorPreference,
acceptanceGranularity: acceptanceGranularity,
Expand Down