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
10 changes: 10 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@
4882DF865737ECDC4925F44B /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; };
48A2F371756723299597F5A1 /* TypoGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCE0E0C38A776383B11809 /* TypoGate.swift */; };
494DA4B28C173F2B3E18DBF9 /* EmojiPickerControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD02052E51AFE56BECB92E4 /* EmojiPickerControllerTests.swift */; };
49EFEFB496CEFAEAEEF1AEBB /* SuggestionEngineSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83256754ED5DD82BD749D6CE /* SuggestionEngineSelectionPolicy.swift */; };
4A460D26B773DC213B0E6471 /* DeviceInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D5E77EB08030489DAFA4DC /* DeviceInfoTests.swift */; };
4A69EB9EEA4620D2EC35B9B2 /* ClipboardContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B70455CA75A08FAD0F7409 /* ClipboardContextProvider.swift */; };
4AC255BE2D0CCC67B8882C7A /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */; };
Expand Down Expand Up @@ -485,6 +486,7 @@
7C56674EF8DA8869D5116EE6 /* GhostSpaceBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD9262617968D8D1EF1EA45 /* GhostSpaceBoundaryTests.swift */; };
7C5BD8FDACC491EF62665FB2 /* TerminalAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DC2CCFDB2BF1F1E1375620 /* TerminalAppDetector.swift */; };
7CD9B73CE933F15B490B3605 /* SettingsSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DB1F528070F8F9108720DB /* SettingsSearchResultRow.swift */; };
7D4D95E3626A1263DF8A0AC4 /* SuggestionEngineSelectionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B95D6456663C72DE3A6AF1 /* SuggestionEngineSelectionPolicyTests.swift */; };
7D87C0AEF85FA0653C9C6031 /* PerformanceMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA991392A72658B758551BBA /* PerformanceMetricsStoreTests.swift */; };
7DE4F91CADF4E65BCCF4DB17 /* SettingsRowLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44768629365FAD1A66DA3A51 /* SettingsRowLabel.swift */; };
7E11BEAD32E9FF170C92000A /* HuggingFaceModelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C696204AAEBA357129F77646 /* HuggingFaceModelBrowserView.swift */; };
Expand Down Expand Up @@ -623,6 +625,7 @@
A262EF1BD871C12840DA75F2 /* SentenceBoundaryClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FC72BD286ABA5B3CEF82F7 /* SentenceBoundaryClassifier.swift */; };
A275EDDC0B43865C587C33AA /* TrailingDuplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD851386E85D3ED0A1F17E47 /* TrailingDuplicationFilter.swift */; };
A29869C9E187C7EEF756E08F /* CaretTokenPositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A7F2B20877E19BD2B27D1E /* CaretTokenPositionTests.swift */; };
A2993441AFFF43C25193BC98 /* SuggestionEngineSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83256754ED5DD82BD749D6CE /* SuggestionEngineSelectionPolicy.swift */; };
A2E77B6BE41060407B2C0508 /* SwiftLog-Apache-2.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 19B257134CCCD0D4370E5287 /* SwiftLog-Apache-2.0.txt */; };
A2FCC90388ABD94CB3C9F9AA /* SuggestionOverlayAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4850AE68E84879254CBFEDE6 /* SuggestionOverlayAcceptanceTests.swift */; };
A30F28CB21A2DDE9E44FC10A /* BundleVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A951C04DF783B1F1EFDD1E5 /* BundleVersion.swift */; };
Expand Down Expand Up @@ -1311,6 +1314,7 @@
81C4E4D11C9BE3B53F79FD36 /* TypingPredictionCandidateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingPredictionCandidateTests.swift; sourceTree = "<group>"; };
81D7F428477976398CFC5CCF /* DecodeStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeStopPolicyTests.swift; sourceTree = "<group>"; };
82420F9505E69AE2ADE9F583 /* BlockBreakAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockBreakAlignment.swift; sourceTree = "<group>"; };
83256754ED5DD82BD749D6CE /* SuggestionEngineSelectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineSelectionPolicy.swift; sourceTree = "<group>"; };
83457CCF1A50CE83428C363D /* Aria2OutputParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2OutputParser.swift; sourceTree = "<group>"; };
8368940C6C15CC3F932B8DFC /* ContextLivePreviewField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextLivePreviewField.swift; sourceTree = "<group>"; };
846A1C3328B3B1CB42E2E46B /* EmojiUsageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageStore.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1584,6 +1588,7 @@
F0A61DC16ED76D694A59BF14 /* SuggestionSessionTypingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionTypingTests.swift; sourceTree = "<group>"; };
F155556C751FB074B31CCC64 /* WelcomeHeroDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeHeroDemo.swift; sourceTree = "<group>"; };
F1A4C5DEA32868F99E953D99 /* CompletionSeamGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSeamGuard.swift; sourceTree = "<group>"; };
F1B95D6456663C72DE3A6AF1 /* SuggestionEngineSelectionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineSelectionPolicyTests.swift; sourceTree = "<group>"; };
F1E69CFA319C5F9E4C30F699 /* SettingsQuickLinkCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsQuickLinkCard.swift; sourceTree = "<group>"; };
F23A711EA8EF13B480FF34DE /* HostBaselineCalibratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBaselineCalibratorTests.swift; sourceTree = "<group>"; };
F26F50467C83B202FB4BAC2A /* WelcomePersonalizeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePersonalizeStepView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1888,6 +1893,7 @@
6F49C55FFF4352A6763BAE1A /* PerDomainDisableSettings.swift */,
79AAAB1C46A0E5A9C6FC7D3D /* SettingsAttentionEvaluator.swift */,
57B29DAC600626783649475A /* SettingsSearchRanker.swift */,
83256754ED5DD82BD749D6CE /* SuggestionEngineSelectionPolicy.swift */,
8AB17696CE79982039061F29 /* SuggestionSettingsStore.swift */,
);
path = Settings;
Expand Down Expand Up @@ -2015,6 +2021,7 @@
9227DE16CA5CE2E085C1A891 /* PerAppShortcutOverrideStoreTests.swift */,
6E53B16C1E0CE667E9666C71 /* SettingsAttentionEvaluatorTests.swift */,
95C62A4403E4815004C6FCB8 /* SettingsSearchRankerTests.swift */,
F1B95D6456663C72DE3A6AF1 /* SuggestionEngineSelectionPolicyTests.swift */,
35C0B587D81D87ACB952C95E /* SuggestionSettingsStoreTests.swift */,
);
path = Settings;
Expand Down Expand Up @@ -4089,6 +4096,7 @@
1996768B9B6FAE54583C2ABE /* SuggestionDismissalMemory.swift in Sources */,
468477E87700B5D18BCD7A7A /* SuggestionEngineModels.swift in Sources */,
9973763A9F4EA4D8B4AE59EB /* SuggestionEngineRouter.swift in Sources */,
49EFEFB496CEFAEAEEF1AEBB /* SuggestionEngineSelectionPolicy.swift in Sources */,
CCA8847AD15933EC1EB2C238 /* SuggestionFadeInPolicy.swift in Sources */,
CF2EADEEEF5AA63FB9B9EA8E /* SuggestionInserter.swift in Sources */,
B413327721F83D9B5C8D4BCE /* SuggestionInteractionState.swift in Sources */,
Expand Down Expand Up @@ -4415,6 +4423,7 @@
FECD5A1F9317317983B49D34 /* SuggestionDismissalMemory.swift in Sources */,
CDDEF3C29AEACA986034C01C /* SuggestionEngineModels.swift in Sources */,
52518CF0760DFEE9AF7C786C /* SuggestionEngineRouter.swift in Sources */,
A2993441AFFF43C25193BC98 /* SuggestionEngineSelectionPolicy.swift in Sources */,
ADB10BCA76295920FE4A9FE1 /* SuggestionFadeInPolicy.swift in Sources */,
2FC40D4BFDD05C2401D7A5E9 /* SuggestionInserter.swift in Sources */,
D795377F4F24F49740808960 /* SuggestionInteractionState.swift in Sources */,
Expand Down Expand Up @@ -4689,6 +4698,7 @@
85EDFFAB94CB8577CDBAF49B /* SuggestionDismissalMemoryTests.swift in Sources */,
3A3B49975BCF1F648F8C1782 /* SuggestionEngineModelsTests.swift in Sources */,
F76ECB9663E46AA806E2C7DE /* SuggestionEngineRouterTests.swift in Sources */,
7D4D95E3626A1263DF8A0AC4 /* SuggestionEngineSelectionPolicyTests.swift in Sources */,
66F5E2CB7F4ADFA7309E7892 /* SuggestionFadeInPolicyTests.swift in Sources */,
16A1C0C9B93983231FAD809B /* SuggestionFocusFreshnessTests.swift in Sources */,
66F8D175E7F36D269C6E0659 /* SuggestionInsertionChunkTests.swift in Sources */,
Expand Down
43 changes: 43 additions & 0 deletions Cotabby/Support/Settings/SuggestionEngineSelectionPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation

/// Decides which suggestion engines a user may pick from an engine picker, and how each option is
/// labelled, given what this Mac can currently run.
///
/// Why this exists as its own type: onboarding (`WelcomeTemplateStepView`) and the power-profile
/// pickers already refuse Apple Intelligence when `FoundationModelAvailabilityService` reports it
/// unavailable, but the Settings and menu bar engine pickers listed every `SuggestionEngineKind`
/// unconditionally. Putting the rule in one pure `Support/` policy lets every picker share the same
/// answer and lets tests pin it without SwiftUI or a real `SystemLanguageModel`.
///
/// Scope: this gates *new* selections only. A previously persisted Apple Intelligence selection is
/// still honored as stored; the Settings callout and the engine's own `unavailable` error explain
/// why nothing is generated, and the choice comes back to life if Apple Intelligence becomes
/// available later (for example after its model finishes downloading).
enum SuggestionEngineSelectionPolicy {
/// Whether `engine` may be chosen right now. Only Apple Intelligence depends on runtime
/// availability; Open Source and the local endpoint are always selectable because their own
/// panes guide the user through downloading a model or configuring a server.
static func isSelectable(
_ engine: SuggestionEngineKind,
foundationModelAvailable: Bool
) -> Bool {
switch engine {
case .appleIntelligence:
return foundationModelAvailable
case .llamaOpenSource, .openAICompatible:
return true
}
}

/// The picker row text. Unselectable engines carry an "(Unavailable)" suffix so a greyed-out
/// menu item still explains itself; the detailed reason stays in the pane callout.
static func pickerLabel(
for engine: SuggestionEngineKind,
foundationModelAvailable: Bool
) -> String {
guard isSelectable(engine, foundationModelAvailable: foundationModelAvailable) else {
return "\(engine.displayLabel) (Unavailable)"
}
return engine.displayLabel
}
}
21 changes: 19 additions & 2 deletions Cotabby/UI/MenuBar/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,17 @@ struct MenuBarView: View {
Group {
MenuBarPickerRow(title: "Engine") {
Picker("Engine", selection: selectedEngineBinding) {
// Same gating as Settings and onboarding: an unavailable engine is shown
// greyed out with an "(Unavailable)" suffix and cannot be picked.
ForEach(SuggestionEngineKind.allCases) { engine in
Text(engine.displayLabel)
.tag(engine)
Text(
SuggestionEngineSelectionPolicy.pickerLabel(
for: engine,
foundationModelAvailable: foundationModelAvailabilityService.isAvailable
)
)
.tag(engine)
.selectionDisabled(!isEngineSelectable(engine))
}
}
.labelsHidden()
Expand Down Expand Up @@ -347,10 +355,19 @@ struct MenuBarView: View {
)
}

private func isEngineSelectable(_ engine: SuggestionEngineKind) -> Bool {
SuggestionEngineSelectionPolicy.isSelectable(
engine,
foundationModelAvailable: foundationModelAvailabilityService.isAvailable
)
}

private var selectedEngineBinding: Binding<SuggestionEngineKind> {
Binding(
get: { suggestionSettings.selectedEngine },
set: { engine in
// Never persist an engine this Mac cannot run, as a guard behind the disabled item.
guard isEngineSelectable(engine) else { return }
// With power-based switching on, the active engine is owned by the current power
// source's profile. Editing it here writes that profile (battery vs. plugged-in)
// instead of `selectedEngine`, which the switcher would otherwise revert. The profile
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,19 @@ extension EngineAndModelPaneView {
var selectedEngineBinding: Binding<SuggestionEngineKind> {
Binding(
get: { suggestionSettings.selectedEngine },
set: { suggestionSettings.selectEngine($0) }
set: { engine in
// Second line of defense behind the disabled menu item: never persist an engine the
// Mac cannot run, even if a future picker style ignores `selectionDisabled`.
guard isEngineSelectable(engine) else { return }
suggestionSettings.selectEngine(engine)
}
)
}

func isEngineSelectable(_ engine: SuggestionEngineKind) -> Bool {
SuggestionEngineSelectionPolicy.isSelectable(
engine,
foundationModelAvailable: foundationModelAvailabilityService.isAvailable
)
}

Expand Down
12 changes: 11 additions & 1 deletion Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,18 @@ struct EngineAndModelPaneView: View {
SettingsPaneScaffold(callout: callout) {
Section("Engine") {
Picker(selection: selectedEngineBinding) {
// Match onboarding: an engine this Mac cannot run stays listed (so a stored
// selection still renders) but is greyed out and labelled, never selectable.
// `selectionDisabled` (macOS 14+) disables just that menu item.
ForEach(SuggestionEngineKind.allCases) { engine in
Text(engine.displayLabel).tag(engine)
Text(
SuggestionEngineSelectionPolicy.pickerLabel(
for: engine,
foundationModelAvailable: foundationModelAvailabilityService.isAvailable
)
)
.tag(engine)
.selectionDisabled(!isEngineSelectable(engine))
}
} label: {
SettingsRowLabel(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import XCTest
@testable import Cotabby

/// Pins the engine-picker gating shared by Settings and the menu bar so Apple Intelligence can never
/// again be offered as a selectable engine on a Mac that cannot run it, while the other engines stay
/// selectable regardless of Apple Intelligence availability.
final class SuggestionEngineSelectionPolicyTests: XCTestCase {

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 Picker paths lack test coverage The new tests check the policy’s answers, but neither picker’s binding setter nor its disabled option. A guard could be removed from Settings or the menu bar and these tests would still pass, even though users could then select an unavailable engine. Please cover accepted and rejected selections through both paths, or move their selection handling into a testable helper.

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

func test_isSelectable_appleIntelligenceFollowsAvailability() {
XCTAssertTrue(
SuggestionEngineSelectionPolicy.isSelectable(.appleIntelligence, foundationModelAvailable: true)
)
XCTAssertFalse(
SuggestionEngineSelectionPolicy.isSelectable(.appleIntelligence, foundationModelAvailable: false)
)
}

func test_isSelectable_otherEnginesIgnoreAppleAvailability() {
for engine in [SuggestionEngineKind.llamaOpenSource, .openAICompatible] {
for available in [true, false] {
XCTAssertTrue(
SuggestionEngineSelectionPolicy.isSelectable(engine, foundationModelAvailable: available),
"\(engine) available=\(available)"
)
}
}
}

func test_pickerLabel_marksOnlyUnselectableEngines() {
XCTAssertEqual(
SuggestionEngineSelectionPolicy.pickerLabel(for: .appleIntelligence, foundationModelAvailable: false),
"Apple Intelligence (Unavailable)"
)
for engine in SuggestionEngineKind.allCases {
XCTAssertEqual(
SuggestionEngineSelectionPolicy.pickerLabel(for: engine, foundationModelAvailable: true),
engine.displayLabel
)
}
XCTAssertEqual(
SuggestionEngineSelectionPolicy.pickerLabel(for: .llamaOpenSource, foundationModelAvailable: false),
SuggestionEngineKind.llamaOpenSource.displayLabel
)
}
}
Loading