diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 66ce36c6..126ca0b3 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -547,6 +547,7 @@ F4CFF323CDA02530ED0EBE57 /* MPL-2.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4E283DF8948B10268B46811F /* MPL-2.0.txt */; }; F4EEE6291095B0BF2D3FBA21 /* GhostTextColorPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E44E393DD58B978B1EAB6CF /* GhostTextColorPreset.swift */; }; F596D7438459A7A4246A39CE /* GhostTextPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */; }; + F71FD79FAC8B59C1CBD9E2E0 /* SettingsIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A2FFC2055C52FB837DEEB8F /* SettingsIndexTests.swift */; }; F7237FDB0665465F1C7EDCDE /* CustomRulesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F7F7355967725162DF2D1B /* CustomRulesEditor.swift */; }; F77DB394E9D6C6C482131BF9 /* VisualContextModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE97A8169438D593C6C23412 /* VisualContextModels.swift */; }; F78F594F77C26C233377E71F /* KeyCodeLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAAE6B395FAB604DF059280A /* KeyCodeLabels.swift */; }; @@ -685,6 +686,7 @@ 5976600F428C1265121D4C0C /* SystemSettingsWindowLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSettingsWindowLocator.swift; sourceTree = ""; }; 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTextExtractor.swift; sourceTree = ""; }; 5A03E565A11581FD2150B142 /* CompletionRenderMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderMode.swift; sourceTree = ""; }; + 5A2FFC2055C52FB837DEEB8F /* SettingsIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsIndexTests.swift; sourceTree = ""; }; 5A3B81B92E0743C6152ED8DD /* EmojiPickerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerController.swift; sourceTree = ""; }; 5A567677424A82D9EEF47495 /* KeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyRecorderView.swift; sourceTree = ""; }; 5AD3F4F9FBE82007E4E15F58 /* GhostSuggestionLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostSuggestionLayoutTests.swift; sourceTree = ""; }; @@ -1263,6 +1265,7 @@ D5A5591BEB9EE7B6E9064412 /* SelfCaptureGateTests.swift */, 2D7360A6D4261989A66658ED /* SentenceBoundaryClassifierTests.swift */, 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */, + 5A2FFC2055C52FB837DEEB8F /* SettingsIndexTests.swift */, 0850B07CCDBA67C756C6EC59 /* ShortcutConflictTests.swift */, D562A73C7C680F2AA65F9F7F /* SpellingDictionaryResourceTests.swift */, E0871985CB1F877EC422E18C /* SpellingLanguageResolverTests.swift */, @@ -2177,6 +2180,7 @@ AF26E77871200BB1FAAEBE79 /* SelfCaptureGateTests.swift in Sources */, 1D1C6FF0B8F50AC14A1000F4 /* SentenceBoundaryClassifierTests.swift in Sources */, C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */, + F71FD79FAC8B59C1CBD9E2E0 /* SettingsIndexTests.swift in Sources */, 8441299082E6B68F7F88911B /* ShortcutConflictTests.swift in Sources */, 303652F15C0FE55595669D81 /* SpellingDictionaryResourceTests.swift in Sources */, 66D0D9F605AF462F569A5CFD /* SpellingLanguageResolverTests.swift in Sources */, diff --git a/Cotabby/UI/Settings/Panes/AboutPaneView.swift b/Cotabby/UI/Settings/Panes/AboutPaneView.swift index db50692b..bb4f8357 100644 --- a/Cotabby/UI/Settings/Panes/AboutPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AboutPaneView.swift @@ -46,8 +46,10 @@ struct AboutPaneView: View { Spacer(minLength: 12) - Button("Check for Updates") { + Button { appUpdateManager.checkForUpdates() + } label: { + Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath") } } .padding(.vertical, 4) diff --git a/Cotabby/UI/Settings/Panes/AppsPaneView.swift b/Cotabby/UI/Settings/Panes/AppsPaneView.swift index baa4249f..f39954d1 100644 --- a/Cotabby/UI/Settings/Panes/AppsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppsPaneView.swift @@ -42,10 +42,10 @@ struct AppsPaneView: View { Toggle(isOn: suggestInIntegratedTerminalsBinding) { SettingsRowLabel( title: "Suggest in Integrated Terminals", - description: "Show ghost text in the VS Code and Cursor integrated terminal. " - + "Off by default so suggestions don't overlap shell prompts and command " - + "output — the code editor and Copilot chat in the same window keep " - + "suggesting either way." + description: "Show ghost text in VS Code and Cursor integrated terminals. " + + "Off by default so suggestions stay out of shell prompts; the editor " + + "and chat in the same window keep suggesting either way.", + systemImage: "terminal" ) } } diff --git a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift index 747563c4..f94ec1b3 100644 --- a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift +++ b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift @@ -94,7 +94,7 @@ struct EngineAndModelPaneView: View { ) ) { SettingsRowLabel( - title: "Switch based on power source", + title: "Switch Based on Power Source", description: "Use a different engine or model on battery vs. while plugged in. " + "For example, Apple Intelligence on battery to save power and a larger local " + "model while charging.", @@ -173,11 +173,18 @@ struct EngineAndModelPaneView: View { @ViewBuilder private var appleIntelligenceSections: some View { Section("Apple Intelligence") { - LabeledContent("Availability") { + LabeledContent { Text(foundationModelAvailabilityService.userVisibleMessage) .foregroundStyle(foundationModelAvailabilityService.isAvailable ? .green : .orange) .multilineTextAlignment(.trailing) .fixedSize(horizontal: false, vertical: true) + } label: { + SettingsRowLabel( + title: "Availability", + description: "Whether this Mac can run Apple Intelligence. Requires a supported " + + "Apple Silicon Mac with Apple Intelligence turned on in System Settings.", + systemImage: "apple.logo" + ) } } } @@ -194,7 +201,7 @@ struct EngineAndModelPaneView: View { .fixedSize(horizontal: false, vertical: true) } label: { SettingsRowLabel( - title: "Status", + title: "Model Status", description: "Whether the local model is loaded and ready to generate. " + "Loading takes a few seconds the first time.", systemImage: "info.circle" @@ -245,7 +252,7 @@ struct EngineAndModelPaneView: View { } Section("Folder") { - LabeledContent("Path") { + LabeledContent { VStack(alignment: .trailing, spacing: 8) { Text(modelDownloadManager.modelsDirectoryPath) .font(.callout.monospaced()) @@ -262,11 +269,17 @@ struct EngineAndModelPaneView: View { } } } + } label: { + SettingsRowLabel( + title: "Models Folder", + description: "Where downloaded model files are stored on this Mac.", + systemImage: "folder" + ) } Toggle(isOn: $lmStudioSourceEnabled) { SettingsRowLabel( - title: "Also use LM Studio models", + title: "Also Use LM Studio Models", description: lmStudioModelsURL == nil ? "Install LM Studio to load models from its library here." : "Add models from your LM Studio library (~/.lmstudio/models) to the picker " + diff --git a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift index c8cad252..e795907b 100644 --- a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift @@ -125,7 +125,7 @@ struct ShortcutsPaneView: View { ) } label: { SettingsRowLabel( - title: "Toggle Tabby", + title: "Toggle Cotabby", description: "Turn Cotabby on or off globally without opening the menu bar.", systemImage: "power.circle" ) diff --git a/Cotabby/UI/Settings/Panes/WritingPaneView.swift b/Cotabby/UI/Settings/Panes/WritingPaneView.swift index 9a20a6a5..8d5874b3 100644 --- a/Cotabby/UI/Settings/Panes/WritingPaneView.swift +++ b/Cotabby/UI/Settings/Panes/WritingPaneView.swift @@ -49,14 +49,16 @@ struct WritingPaneView: View { Toggle(isOn: suppressCompletionsOnTypoBinding) { SettingsRowLabel( title: "Hide Suggestions on Typo", - description: "Stops normal completions while the current word appears misspelled." + description: "Stops normal completions while the current word appears misspelled.", + systemImage: "eye.slash" ) } Toggle(isOn: offerTypoCorrectionsBinding) { SettingsRowLabel( title: "Offer Corrections on Typo", - description: "Shows a green replacement you can apply with your accept key." + description: "Shows a green replacement you can apply with your accept key.", + systemImage: "checkmark.bubble" ) } .disabled(!suggestionSettings.suppressCompletionsOnTypo) @@ -64,7 +66,8 @@ struct WritingPaneView: View { Toggle(isOn: automaticallyFixTyposBinding) { SettingsRowLabel( title: "Automatically Fix Typos", - description: "After you press Space, replaces a misspelled word without requiring your accept key." + description: "After you press Space, replaces a misspelled word without requiring your accept key.", + systemImage: "checkmark.circle" ) } .disabled(!suggestionSettings.suppressCompletionsOnTypo) diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 0c4f96d8..aea0d28b 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -25,6 +25,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case showKeyHint case ghostTextColor case ghostTextOpacity + case ghostTextSize // Emoji case emojiPicker case emojiSkinTone @@ -45,6 +46,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { // Engine & Model case engine case appleIntelligenceAvailability + case modelStatus case selectedModel case powerBasedModelSwitching case batteryModel @@ -60,6 +62,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case toggleTabby // Apps case disabledApps + case suggestInIntegratedTerminals // Permissions case accessibility case inputMonitoring @@ -94,6 +97,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .showKeyHint: return "Show Accept-Key Hint" case .ghostTextColor: return "Ghost Text Color" case .ghostTextOpacity: return "Ghost Text Opacity" + case .ghostTextSize: return "Ghost Text Size" case .emojiPicker: return "Inline Emoji Picker" case .emojiSkinTone: return "Skin Tone" case .emojiPeopleStyle: return "People Emoji Style" @@ -110,10 +114,11 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .contextLivePreview: return "Live Preview" case .engine: return "Engine" case .appleIntelligenceAvailability: return "Apple Intelligence Availability" + case .modelStatus: return "Model Status" case .selectedModel: return "Selected Model" - case .powerBasedModelSwitching: return "Switch Models Based on Power Source" - case .batteryModel: return "Battery Model" - case .pluggedInModel: return "Plugged-in Model" + case .powerBasedModelSwitching: return "Switch Based on Power Source" + case .batteryModel: return "On Battery" + case .pluggedInModel: return "Plugged In" case .downloadModels: return "Download Models" case .huggingFaceBrowser: return "Hugging Face Model Browser" case .modelsFolder: return "Models Folder" @@ -121,12 +126,13 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptanceMode: return "Acceptance Mode" case .acceptWord: return "Accept Word" case .acceptEntireSuggestion: return "Accept Entire Suggestion" - case .toggleTabby: return "Toggle Tabby" + case .toggleTabby: return "Toggle Cotabby" case .disabledApps: return "Disabled Apps" + case .suggestInIntegratedTerminals: return "Suggest in Integrated Terminals" case .accessibility: return "Accessibility" case .inputMonitoring: return "Input Monitoring" case .screenRecording: return "Screen Recording" - case .performanceTracking: return "Performance Tracking" + case .performanceTracking: return "Enable Performance Tracking" case .resourceUsage: return "Live Resource Usage" case .recentRequests: return "Recent Requests" case .checkForUpdates: return "Check for Updates" @@ -154,6 +160,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .showKeyHint: return "keyboard" case .ghostTextColor: return "paintpalette" case .ghostTextOpacity: return "circle.lefthalf.filled" + case .ghostTextSize: return "textformat.size" case .emojiPicker: return "face.smiling" case .emojiSkinTone: return "hand.raised.fingers.spread" case .emojiPeopleStyle: return "person.2" @@ -170,9 +177,10 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .contextLivePreview: return "text.cursor" case .engine: return "cpu" case .appleIntelligenceAvailability: return "apple.logo" + case .modelStatus: return "info.circle" case .selectedModel: return "shippingbox" - case .powerBasedModelSwitching: return "battery.100" - case .batteryModel: return "battery.50" + case .powerBasedModelSwitching: return "battery.100.bolt" + case .batteryModel: return "battery.25" case .pluggedInModel: return "powerplug" case .downloadModels: return "arrow.down.circle" case .huggingFaceBrowser: return "magnifyingglass" @@ -183,6 +191,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptEntireSuggestion: return "text.insert" case .toggleTabby: return "power.circle" case .disabledApps: return "nosign" + case .suggestInIntegratedTerminals: return "terminal" case .accessibility: return "accessibility" case .inputMonitoring: return "keyboard" case .screenRecording: return "camera.viewfinder" @@ -204,7 +213,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { .allowMultiLine, .acceptPunctuation, .inlineMacros, .onboarding: return .general case .suggestionDisplay, .showFieldIndicator, .showWordCount, .showKeyHint, - .ghostTextColor, .ghostTextOpacity: + .ghostTextColor, .ghostTextOpacity, .ghostTextSize: return .appearance case .emojiPicker, .emojiSkinTone, .emojiPeopleStyle, .emojiHistory: return .emoji @@ -213,13 +222,13 @@ enum SettingsItem: String, CaseIterable, Identifiable { return .writing case .extendedContext, .contextLivePreview: return .context - case .engine, .appleIntelligenceAvailability, .selectedModel, + case .engine, .appleIntelligenceAvailability, .modelStatus, .selectedModel, .powerBasedModelSwitching, .batteryModel, .pluggedInModel, .downloadModels, .huggingFaceBrowser, .modelsFolder, .lmStudio: return .engineAndModel case .acceptanceMode, .acceptWord, .acceptEntireSuggestion, .toggleTabby: return .shortcuts - case .disabledApps: + case .disabledApps, .suggestInIntegratedTerminals: return .apps case .accessibility, .inputMonitoring, .screenRecording: return .permissions @@ -277,6 +286,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .ghostTextOpacity: return ["opacity", "transparency", "fade", "alpha", "translucent", "dim", "brightness", "visibility"] + case .ghostTextSize: + return ["size", "font size", "scale", "bigger", "smaller", "larger", "text size", + "zoom", "multiplier", "too big", "too small"] case .emojiPicker: return ["emoji", "smile", "picker", "inline", "colon", "emoticon", "face", "symbol"] @@ -326,6 +338,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .appleIntelligenceAvailability: return ["apple intelligence", "availability", "available", "supported", "compatibility", "status", "macos", "device support"] + case .modelStatus: + return ["status", "loaded", "ready", "runtime", "running", "health", + "model loaded", "loading"] case .selectedModel: return ["model", "gguf", "pick", "selected", "active model", "choose model", "current model", "default model"] @@ -366,6 +381,10 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .disabledApps: return ["apps", "disable", "exclude", "block", "ignore", "blacklist", "deny list", "exception", "app exclusion", "skip", "off in"] + case .suggestInIntegratedTerminals: + return ["terminal", "terminals", "integrated terminal", "vscode", "vs code", + "cursor", "shell", "xterm", "command line", "cli", "console", + "ghost text in terminal"] case .accessibility: return ["accessibility", "ax", "permission", "access", "system settings", "privacy", "grant", "allow"] diff --git a/CotabbyTests/SettingsIndexTests.swift b/CotabbyTests/SettingsIndexTests.swift new file mode 100644 index 00000000..af7fc8f7 --- /dev/null +++ b/CotabbyTests/SettingsIndexTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import Cotabby + +/// Pins the hygiene rules of the Settings search index. The index drifts silently when a new +/// setting ships without an entry (it simply never appears in search), so these tests make the +/// cheap invariants loud: every item must carry a non-empty title, symbol, and keyword set, and +/// the queries users actually type for recently shipped settings must land on them. +final class SettingsIndexTests: XCTestCase { + func test_everyItemHasTitleSymbolAndKeywords() { + for item in SettingsItem.allCases { + XCTAssertFalse(item.title.isEmpty, "\(item) needs a title") + XCTAssertFalse(item.systemImage.isEmpty, "\(item) needs an SF Symbol") + XCTAssertFalse(item.keywords.isEmpty, "\(item) needs search keywords") + } + } + + func test_itemIdsAreUnique() { + let ids = SettingsItem.allCases.map(\.id) + XCTAssertEqual(ids.count, Set(ids).count, "duplicate SettingsItem ids break Identifiable lists") + } + + func test_searchFindsRecentlyShippedSettings() { + // Each pair pins one real query for a setting that previously shipped without an index + // entry. If one of these fails, a rename or removal broke search for that setting. + let expectations: [(query: String, item: SettingsItem)] = [ + ("ghost text size", .ghostTextSize), + ("terminal", .suggestInIntegratedTerminals), + ("vscode", .suggestInIntegratedTerminals), + ("typo", .automaticallyFixTypos), + ("model status", .modelStatus), + ("battery", .batteryModel), + ("plugged", .pluggedInModel) + ] + for expectation in expectations { + XCTAssertTrue( + SettingsItem.results(for: expectation.query).contains(expectation.item), + "query \"\(expectation.query)\" should surface \(expectation.item)" + ) + } + } + + func test_blankQueryReturnsNothing() { + XCTAssertTrue(SettingsItem.results(for: " ").isEmpty) + XCTAssertTrue(SettingsItem.results(for: "").isEmpty) + } +}