Skip to content
Draft
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
4 changes: 4 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
E10000052F93000100DDD005 /* DisplayCoordinateConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000152F93000100DDD015 /* DisplayCoordinateConverterTests.swift */; };
F10000012FA0000100EEE001 /* TextDirectionDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000112FA0000100EEE011 /* TextDirectionDetectorTests.swift */; };
G10000012FB0000100FFF001 /* WordCountFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = G10000112FB0000100FFF011 /* WordCountFormatterTests.swift */; };
H10000012FC0000100GGG001 /* ComposeContextNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = H10000112FC0000100GGG011 /* ComposeContextNormalizerTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -73,6 +74,7 @@
F29623C5C0A67B992D383A3C /* LlamaPromptRendererTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LlamaPromptRendererTests.swift; sourceTree = "<group>"; };
F9D35DB9E86506B9FAE1CFE9 /* ModelFileValidatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ModelFileValidatorTests.swift; sourceTree = "<group>"; };
G10000112FB0000100FFF011 /* WordCountFormatterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WordCountFormatterTests.swift; sourceTree = "<group>"; };
H10000112FC0000100GGG011 /* ComposeContextNormalizerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeContextNormalizerTests.swift; sourceTree = "<group>"; };

/* End PBXFileReference section */

Expand Down Expand Up @@ -150,6 +152,7 @@
G20000112FB0000100FFF011 /* ClipboardRelevanceFilterTests.swift */,
G10000122FB0000100FFF012 /* ClipboardContentDistillerTests.swift */,
G10000112FB0000100FFF011 /* WordCountFormatterTests.swift */,
H10000112FC0000100GGG011 /* ComposeContextNormalizerTests.swift */,
);
path = CotabbyTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -293,6 +296,7 @@
G20000012FB0000100FFF001 /* ClipboardRelevanceFilterTests.swift in Sources */,
G10000022FB0000100FFF002 /* ClipboardContentDistillerTests.swift in Sources */,
G10000012FB0000100FFF001 /* WordCountFormatterTests.swift in Sources */,
H10000012FC0000100GGG001 /* ComposeContextNormalizerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
5 changes: 4 additions & 1 deletion Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ extension SuggestionCoordinator {
if SuggestionAvailabilityEvaluator.shouldSchedulePrediction(
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
interactionMode: settingsSnapshot.selectedInteractionMode,
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
screenRecordingGranted: permissionManager.screenRecordingGranted,
focusSnapshot: focusModel.snapshot
Expand Down Expand Up @@ -42,6 +43,7 @@ extension SuggestionCoordinator {
if let disabledReason = SuggestionAvailabilityEvaluator.disabledReason(
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
interactionMode: settingsSnapshot.selectedInteractionMode,
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
screenRecordingGranted: permissionManager.screenRecordingGranted,
focusSnapshot: snapshot
Expand Down Expand Up @@ -87,6 +89,7 @@ extension SuggestionCoordinator {
if let disabledReason = SuggestionAvailabilityEvaluator.disabledReason(
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
interactionMode: settingsSnapshot.selectedInteractionMode,
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
screenRecordingGranted: permissionManager.screenRecordingGranted,
focusSnapshot: focusModel.snapshot
Expand All @@ -103,7 +106,7 @@ extension SuggestionCoordinator {
return acceptEntireSuggestion()
}

if let activeSession = interactionState.activeSession {
if let activeSession = interactionState.activeAutocompleteSession {
return handleInputEvent(event, with: activeSession)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ extension SuggestionCoordinator {
if SuggestionAvailabilityEvaluator.shouldSchedulePrediction(
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
interactionMode: settingsSnapshot.selectedInteractionMode,
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
screenRecordingGranted: permissionManager.screenRecordingGranted,
focusSnapshot: focusModel.snapshot
Expand Down
20 changes: 19 additions & 1 deletion Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
// MARK: - Prediction Pipeline

func schedulePrediction() {
guard settingsSnapshot.selectedInteractionMode == .autocomplete else {
disablePredictionsPreservingVisualContext(reason: composeModePendingReason)
return
}

if let disabledReason = SuggestionAvailabilityEvaluator.disabledReason(
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
Expand All @@ -32,7 +37,12 @@
}

/// Refreshes focus after debounce, materializes a stable context, and starts generation.
func generateFromCurrentFocus(workID: UInt64) async {

Check warning on line 40 in Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Function should have complexity 10 or less; currently complexity is 11 (cyclomatic_complexity)
guard settingsSnapshot.selectedInteractionMode == .autocomplete else {
disablePredictionsPreservingVisualContext(reason: composeModePendingReason)
return
}

guard workController.isCurrent(workID) else {
return
}
Expand Down Expand Up @@ -276,7 +286,7 @@
/// This is the heart of partial acceptance: a text change is not automatically "stale" anymore.
/// It may instead mean "the user consumed the next expected part of the suggestion."
func reconcileActiveSession(with snapshot: FocusSnapshot) {
guard interactionState.activeSession != nil else {
guard interactionState.activeAutocompleteSession != nil else {
if overlayState.isVisible {
hideOverlay(reason: "Overlay hidden because no ready suggestion remains.")
}
Expand Down Expand Up @@ -426,6 +436,10 @@
/// Once screenshot context becomes ready, regenerate only if the user is still in the same
/// field and there is enough typed text for a real inline completion request.
func schedulePredictionForCurrentFocusIfPossible(matching identity: FocusedInputIdentity) {
guard settingsSnapshot.selectedInteractionMode == .autocomplete else {
return
}

focusModel.refreshNow()
let snapshot = focusModel.snapshot

Expand All @@ -438,4 +452,8 @@

schedulePrediction()
}

private var composeModePendingReason: String {
"Compose Mode is selected. Draft generation will be enabled after the Compose request pipeline is installed."
}
}
3 changes: 3 additions & 0 deletions Cotabby/App/Coordinators/SuggestionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ final class SuggestionCoordinator: ObservableObject {
let clipboardContextProvider: any ClipboardContextProviding
let clipboardRelevanceFilter: any ClipboardRelevanceFiltering
let visualContextCoordinator: any VisualContextCoordinating
let composeContextCollector: ComposeContextCollector
let interactionState: SuggestionInteractionState
let workController: SuggestionWorkController
let configuration: SuggestionConfiguration
Expand Down Expand Up @@ -73,6 +74,7 @@ final class SuggestionCoordinator: ObservableObject {
clipboardContextProvider: any ClipboardContextProviding,
clipboardRelevanceFilter: any ClipboardRelevanceFiltering,
visualContextCoordinator: any VisualContextCoordinating,
composeContextCollector: ComposeContextCollector,
interactionState: SuggestionInteractionState,
workController: SuggestionWorkController,
configuration: SuggestionConfiguration,
Expand All @@ -91,6 +93,7 @@ final class SuggestionCoordinator: ObservableObject {
self.clipboardContextProvider = clipboardContextProvider
self.clipboardRelevanceFilter = clipboardRelevanceFilter
self.visualContextCoordinator = visualContextCoordinator
self.composeContextCollector = composeContextCollector
self.interactionState = interactionState
self.workController = workController
self.configuration = configuration
Expand Down
6 changes: 5 additions & 1 deletion Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ final class CotabbyAppEnvironment {
screenshotContextGenerator: screenshotContextGenerator,
screenRecordingPermissionProvider: { permissionManager.screenRecordingGranted }
)
let composeContextCollector = ComposeContextCollector()

let foundationModelEngine: any SuggestionGenerating
#if canImport(FoundationModels)
if #available(macOS 26.0, *) {
Expand All @@ -123,14 +125,15 @@ final class CotabbyAppEnvironment {
TabbyLogger.app.info("Foundation model engine unavailable (SDK)")
#endif

let llamaEngine = LlamaSuggestionEngine(runtimeManager: runtimeManager)
let mlxEngine: any SuggestionGenerating = MLXSuggestionEngine(
runtimeManager: mlxRuntimeManager
)

let suggestionEngine: any SuggestionGenerating = SuggestionEngineRouter(
suggestionSettings: suggestionSettings,
foundationModelEngine: foundationModelEngine,
llamaEngine: LlamaSuggestionEngine(runtimeManager: runtimeManager),
llamaEngine: llamaEngine,
mlxEngine: mlxEngine
)

Expand All @@ -147,6 +150,7 @@ final class CotabbyAppEnvironment {
clipboardContextProvider: clipboardContextProvider,
clipboardRelevanceFilter: clipboardRelevanceFilter,
visualContextCoordinator: visualContextCoordinator,
composeContextCollector: composeContextCollector,
interactionState: interactionState,
workController: workController,
configuration: configuration
Expand Down
6 changes: 6 additions & 0 deletions Cotabby/Models/LlamaRuntimeModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ struct RemoteModelFile: Equatable, Hashable, Sendable {
}

enum RuntimeModelCatalog {
static let composeRequiredFilename = "gemma-3n-E4B-it-Q4_K_M.gguf"

static func supportsCompose(filename: String?) -> Bool {
filename == composeRequiredFilename
}

static func displayName(for filename: String) -> String {
switch filename {
case "Qwen3-0.6B-Q4_K_M.gguf":
Expand Down
64 changes: 62 additions & 2 deletions Cotabby/Models/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,40 @@
import Foundation

/// File overview:
/// Defines the product-facing engine choices for Cotabby's autocomplete pipeline.
/// Defines the product-facing writing mode and engine choices for Cotabby's suggestion pipeline.
/// This file exists because "which engine is active?" is a domain concept, not a UI-only detail.
/// The same applies to the interaction mode: runtime code needs an immutable value that says
/// whether Cotabby is completing a short inline tail or preparing for a deliberate full draft.
///
/// The important architectural distinction is:
/// - a local GGUF file is a model option inside the llama runtime
/// - autocomplete vs. compose is an interaction contract
/// - a local GGUF/MLX file is a model option inside its respective runtime
/// - Apple Intelligence vs. local llama vs. MLX is an engine choice above the runtime layer
enum SuggestionInteractionMode: String, CaseIterable, Equatable, Hashable, Sendable, Identifiable {
case autocomplete
case compose

var id: String { rawValue }

var displayLabel: String {
switch self {
case .autocomplete:
return "Autocomplete"
case .compose:
return "Compose"
}
}

var explanatoryText: String {
switch self {
case .autocomplete:
return "Predicts a short inline continuation near the caret."
case .compose:
return "Prepares a full draft for deliberate review before typing."
}
}
}

enum SuggestionEngineKind: String, CaseIterable, Equatable, Hashable, Sendable, Identifiable {
case appleIntelligence
case llamaOpenSource
Expand Down Expand Up @@ -63,13 +91,45 @@
struct SuggestionSettingsSnapshot: Equatable, Sendable {
let isGloballyEnabled: Bool
let disabledAppBundleIdentifiers: Set<String>
let selectedInteractionMode: SuggestionInteractionMode
let selectedEngine: SuggestionEngineKind
let selectedWordCountPreset: SuggestionWordCountPreset
let isClipboardContextEnabled: Bool
/// User-authored profile data for Cotabby's single instruction-rendered completion prompt.
/// This travels in the snapshot so generation uses the same value the Settings UI shows.
let userName: String
/// Optional user-authored tags used by Compose Mode prompts.
/// Currently always empty; the model does not yet surface a tag editor, but Compose's prompt
/// renderer reads this field so future tagging UI can land without re-plumbing.
let userTags: [String]
let debounceMilliseconds: Int
let focusPollIntervalMilliseconds: Int
let isMultiLineEnabled: Bool

// swiftlint:disable:next function_parameter_count
init(

Check warning on line 110 in Cotabby/Models/SuggestionEngineModels.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

SwiftLint rule 'function_parameter_count' did not trigger a violation in the disabled region; remove the disable command (superfluous_disable_command)
isGloballyEnabled: Bool,
disabledAppBundleIdentifiers: Set<String>,
selectedInteractionMode: SuggestionInteractionMode = .autocomplete,
selectedEngine: SuggestionEngineKind,
selectedWordCountPreset: SuggestionWordCountPreset,
isClipboardContextEnabled: Bool,
userName: String,
userTags: [String] = [],
debounceMilliseconds: Int,
focusPollIntervalMilliseconds: Int,
isMultiLineEnabled: Bool = false
) {
self.isGloballyEnabled = isGloballyEnabled
self.disabledAppBundleIdentifiers = disabledAppBundleIdentifiers
self.selectedInteractionMode = selectedInteractionMode
self.selectedEngine = selectedEngine
self.selectedWordCountPreset = selectedWordCountPreset
self.isClipboardContextEnabled = isClipboardContextEnabled
self.userName = userName
self.userTags = userTags
self.debounceMilliseconds = debounceMilliseconds
self.focusPollIntervalMilliseconds = focusPollIntervalMilliseconds
self.isMultiLineEnabled = isMultiLineEnabled
}
}
Loading
Loading