From 49fa781ec5113dafbb75a4ed56f3fe4c9ccd947a Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:21:21 -0400 Subject: [PATCH 1/2] feat: implemented initial version of hiding feature --- Cotabby/App/Core/AppDelegate.swift | 16 ++++++++++++++ Cotabby/App/Core/CotabbyApp.swift | 21 +++++++++++++++++-- Cotabby/Models/SuggestionSettingsData.swift | 3 +++ Cotabby/Models/SuggestionSettingsModel.swift | 13 ++++++++++++ Cotabby/Support/SuggestionSettingsStore.swift | 13 ++++++++++++ .../Settings/Panes/AppearancePaneView.swift | 16 ++++++++++++++ Cotabby/UI/Settings/SettingsIndex.swift | 10 ++++++++- .../SuggestionSettingsModelTests.swift | 2 ++ .../SuggestionSettingsStoreTests.swift | 10 +++++++++ 9 files changed, 101 insertions(+), 3 deletions(-) diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index 74536883..b047b5bc 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -149,6 +149,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// Launch Services sends a reopen event when the user opens Cotabby while this accessory app is + /// already running. When the status item is hidden, Settings is the app's only visible recovery + /// surface, so reopening must reveal it instead of leaving the user with no apparent response. + func applicationShouldHandleReopen( + _ sender: NSApplication, + hasVisibleWindows flag: Bool + ) -> Bool { + guard !suggestionSettings.isMenuBarIconVisible else { + return true + } + + CotabbyLogger.app.info("Opening Settings because Cotabby was reopened with its menu bar icon hidden") + settingsCoordinator.showSettings() + return false + } + /// One-time default: enable Open at Login for every user (new and existing) the first time this /// build runs. The applied-flag persists the decision so any later opt-out the user makes is /// respected on subsequent launches and we only ever flip the toggle once per user. diff --git a/Cotabby/App/Core/CotabbyApp.swift b/Cotabby/App/Core/CotabbyApp.swift index 43e41d28..2629ca05 100644 --- a/Cotabby/App/Core/CotabbyApp.swift +++ b/Cotabby/App/Core/CotabbyApp.swift @@ -1,7 +1,7 @@ import SwiftUI /// File overview: -/// Declares the SwiftUI app entry point and hosts the single menu-bar scene that renders +/// Declares the SwiftUI app entry point and hosts the optional menu-bar scene that renders /// Cotabby's compact status UI. Shared services are injected through `AppDelegate`. /// /// `@main` marks the single process entry point for a Swift app. @@ -9,10 +9,15 @@ import SwiftUI struct CotabbyApp: App { /// Bridges old-style AppKit lifecycle callbacks into a SwiftUI app. @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + /// Scene declarations cannot directly observe the delegate's nested settings object. This + /// projection watches the same durable key so SwiftUI re-evaluates status-item insertion as + /// soon as Settings changes it; the settings model remains the app-facing preference API. + @AppStorage(SuggestionSettingsStore.menuBarIconVisibleDefaultsKey) + private var isMenuBarIconVisible = true /// Defines the menu bar extra that surfaces Cotabby's runtime, focus, and suggestion state. var body: some Scene { - MenuBarExtra { + MenuBarExtra(isInserted: menuBarIconVisibilityBinding) { MenuBarView( permissionManager: appDelegate.permissionManager, runtimeModel: appDelegate.runtimeModel, @@ -44,4 +49,16 @@ struct CotabbyApp: App { } .menuBarExtraStyle(.window) } + + /// SwiftUI owns insertion/removal of the status item, while the settings model remains the + /// single durable source of truth. The setter also captures removal initiated by the system. + private var menuBarIconVisibilityBinding: Binding { + Binding( + get: { isMenuBarIconVisible }, + set: { visible in + isMenuBarIconVisible = visible + appDelegate.suggestionSettings.setMenuBarIconVisible(visible) + } + ) + } } diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index 7a6ecc7d..54af9f3f 100644 --- a/Cotabby/Models/SuggestionSettingsData.swift +++ b/Cotabby/Models/SuggestionSettingsData.swift @@ -52,6 +52,9 @@ struct SuggestionSettingsData: Equatable { /// green preview while typing, disable it, or use both behaviors together. var automaticallyFixTypos: Bool var isPerformanceTrackingEnabled: Bool + /// Controls whether SwiftUI inserts Cotabby's `MenuBarExtra` into the system menu bar. The app + /// keeps running when this is false; reopening Cotabby provides the recovery path to Settings. + var isMenuBarIconVisible: Bool var isMenuBarWordCountVisible: Bool var mirrorPreference: MirrorPreference var userName: String diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index 25ccf1d0..31e824a3 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -77,6 +77,9 @@ final class SuggestionSettingsModel: ObservableObject { /// default user never pays any extra storage or write cost — recording only kicks in once the /// user opts in from Settings. @Published private(set) var isPerformanceTrackingEnabled: Bool + /// Whether Cotabby's status item is inserted into the menu bar. The process and suggestion + /// pipeline remain active when hidden; launching the app again opens Settings as the recovery path. + @Published private(set) var isMenuBarIconVisible: Bool /// Whether the accepted-word counter is drawn next to the menu bar icon. Off hides the badge /// entirely; the count itself keeps accruing so toggling it back on restores the running total. @Published private(set) var isMenuBarWordCountVisible: Bool @@ -173,6 +176,7 @@ final class SuggestionSettingsModel: ObservableObject { enabledSpellingDictionaryCodes = data.enabledSpellingDictionaryCodes automaticallyFixTypos = data.automaticallyFixTypos isPerformanceTrackingEnabled = data.isPerformanceTrackingEnabled + isMenuBarIconVisible = data.isMenuBarIconVisible isMenuBarWordCountVisible = data.isMenuBarWordCountVisible mirrorPreference = data.mirrorPreference userName = data.userName @@ -460,6 +464,15 @@ final class SuggestionSettingsModel: ObservableObject { store.savePerformanceTrackingEnabled(enabled) } + func setMenuBarIconVisible(_ visible: Bool) { + guard isMenuBarIconVisible != visible else { + return + } + + isMenuBarIconVisible = visible + store.saveMenuBarIconVisible(visible) + } + func setMenuBarWordCountVisible(_ visible: Bool) { guard isMenuBarWordCountVisible != visible else { return diff --git a/Cotabby/Support/SuggestionSettingsStore.swift b/Cotabby/Support/SuggestionSettingsStore.swift index 966fa5cb..815e30f1 100644 --- a/Cotabby/Support/SuggestionSettingsStore.swift +++ b/Cotabby/Support/SuggestionSettingsStore.swift @@ -89,6 +89,9 @@ struct SuggestionSettingsStore { private static let spellingDictionaryCodesDefaultsKey = "cotabbyEnabledSpellingDictionaryCodes" private static let automaticallyFixTyposDefaultsKey = "cotabbyAutomaticallyFixTypos" private static let performanceTrackingEnabledDefaultsKey = "cotabbyPerformanceTrackingEnabled" + /// Shared with `CotabbyApp` because SwiftUI's scene-level `@AppStorage` is what invalidates the + /// `MenuBarExtra` insertion binding when the settings model writes this preference. + static let menuBarIconVisibleDefaultsKey = "cotabbyMenuBarIconVisible" private static let menuBarWordCountVisibleDefaultsKey = "cotabbyMenuBarWordCountVisible" private static let mirrorPreferenceDefaultsKey = "cotabbyMirrorPreference" private static let userNameDefaultsKey = "cotabbyUserName" @@ -215,6 +218,10 @@ struct SuggestionSettingsStore { // in from the Performance pane. let resolvedPerformanceTrackingEnabled = userDefaults.object(forKey: Self.performanceTrackingEnabledDefaultsKey) as? Bool ?? false + // Existing installs keep the status item unless the user explicitly hides it. Hiding the + // item must never terminate the accessory app because autocomplete continues in the background. + let resolvedMenuBarIconVisible = + userDefaults.object(forKey: Self.menuBarIconVisibleDefaultsKey) as? Bool ?? true // Default to visible so existing installs keep the running-word-count badge they're used // to seeing. The toggle lets users who find the badge noisy hide it from the menu bar. let resolvedMenuBarWordCountVisible = @@ -369,6 +376,7 @@ struct SuggestionSettingsStore { enabledSpellingDictionaryCodes: resolvedEnabledSpellingDictionaryCodes, automaticallyFixTypos: resolvedAutomaticallyFixTypos, isPerformanceTrackingEnabled: resolvedPerformanceTrackingEnabled, + isMenuBarIconVisible: resolvedMenuBarIconVisible, isMenuBarWordCountVisible: resolvedMenuBarWordCountVisible, mirrorPreference: resolvedMirrorPreference, userName: resolvedUserName, @@ -424,6 +432,7 @@ struct SuggestionSettingsStore { saveEnabledSpellingDictionaryCodes(data.enabledSpellingDictionaryCodes) saveAutomaticallyFixTypos(data.automaticallyFixTypos) savePerformanceTrackingEnabled(data.isPerformanceTrackingEnabled) + saveMenuBarIconVisible(data.isMenuBarIconVisible) saveMenuBarWordCountVisible(data.isMenuBarWordCountVisible) saveMirrorPreference(data.mirrorPreference) saveUserName(data.userName) @@ -589,6 +598,10 @@ struct SuggestionSettingsStore { userDefaults.set(enabled, forKey: Self.performanceTrackingEnabledDefaultsKey) } + func saveMenuBarIconVisible(_ visible: Bool) { + userDefaults.set(visible, forKey: Self.menuBarIconVisibleDefaultsKey) + } + func saveMenuBarWordCountVisible(_ visible: Bool) { userDefaults.set(visible, forKey: Self.menuBarWordCountVisibleDefaultsKey) } diff --git a/Cotabby/UI/Settings/Panes/AppearancePaneView.swift b/Cotabby/UI/Settings/Panes/AppearancePaneView.swift index b65152ae..84382164 100644 --- a/Cotabby/UI/Settings/Panes/AppearancePaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppearancePaneView.swift @@ -56,6 +56,15 @@ struct AppearancePaneView: View { } .settingsItem(.showWordCount) + Toggle(isOn: menuBarIconVisibleBinding) { + SettingsRowLabel( + title: "Show Cotabby in Menu Bar", + description: "Keep Cotabby's icon in the menu bar. If hidden, open Cotabby again to show Settings.", + systemImage: "menubar.rectangle" + ) + } + .settingsItem(.showMenuBarIcon) + Toggle(isOn: showAcceptanceHintBinding) { HStack(alignment: .firstTextBaseline, spacing: 10) { Image(systemName: "keyboard") @@ -197,6 +206,13 @@ struct AppearancePaneView: View { ) } + private var menuBarIconVisibleBinding: Binding { + Binding( + get: { suggestionSettings.isMenuBarIconVisible }, + set: { suggestionSettings.setMenuBarIconVisible($0) } + ) + } + private var ghostTextOpacityBinding: Binding { Binding( get: { suggestionSettings.ghostTextOpacity }, diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 3bc649c3..e26be06b 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -26,6 +26,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case streamWhileGenerating case showFieldIndicator case showWordCount + case showMenuBarIcon case showKeyHint case ghostTextColor case ghostTextOpacity @@ -104,6 +105,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .streamWhileGenerating: return "Stream Suggestions While Generating" case .showFieldIndicator: return "Show Field Indicator" case .showWordCount: return "Show Word Count in Menu Bar" + case .showMenuBarIcon: return "Show Cotabby in Menu Bar" case .showKeyHint: return "Show Accept-Key Hint" case .ghostTextColor: return "Ghost Text Color" case .ghostTextOpacity: return "Ghost Text Opacity" @@ -171,6 +173,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .streamWhileGenerating: return "text.append" case .showFieldIndicator: return "dot.viewfinder" case .showWordCount: return "number" + case .showMenuBarIcon: return "menubar.rectangle" case .showKeyHint: return "keyboard" case .ghostTextColor: return "paintpalette" case .ghostTextOpacity: return "circle.lefthalf.filled" @@ -227,7 +230,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .enableGlobally, .fastMode, .openAtLogin, .includeClipboardContext, .includeAppContext, .allowMultiLine, .inlineMacros, .onboarding: return .general - case .suggestionDisplay, .streamWhileGenerating, .showFieldIndicator, .showWordCount, .showKeyHint, + case .suggestionDisplay, .streamWhileGenerating, .showFieldIndicator, .showWordCount, + .showMenuBarIcon, .showKeyHint, .ghostTextColor, .ghostTextOpacity, .ghostTextSize: return .appearance case .emojiPicker, .emojiSkinTone, .emojiPeopleStyle, .emojiHistory: @@ -273,6 +277,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .streamWhileGenerating: return "Reveal ghost text token by token as the model writes." case .showFieldIndicator: return "Show a small icon when a field is ready for suggestions." case .showWordCount: return "Count accepted words next to the menu bar icon." + case .showMenuBarIcon: return "Hide menu bar clutter while Cotabby keeps running." case .showKeyHint: return "Show the accept-key badge beside the ghost text." case .ghostTextColor: return "Pick the color of the inline suggestion." case .ghostTextOpacity: return "How faint the suggestion looks before you accept it." @@ -371,6 +376,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .showWordCount: return ["word count", "words", "menu bar", "menubar", "stats", "counter", "statistics", "show count"] + case .showMenuBarIcon: + return ["menu bar", "menubar", "status item", "icon", "hide", "hidden", + "clutter", "launch", "reopen", "settings"] case .showKeyHint: return ["hint", "badge", "keycap", "accept key", "tip", "label", "key hint", "show key"] diff --git a/CotabbyTests/SuggestionSettingsModelTests.swift b/CotabbyTests/SuggestionSettingsModelTests.swift index 80d80c8c..d89c9abb 100644 --- a/CotabbyTests/SuggestionSettingsModelTests.swift +++ b/CotabbyTests/SuggestionSettingsModelTests.swift @@ -51,6 +51,7 @@ final class SuggestionSettingsModelTests: XCTestCase { model.setOfferTypoCorrections(true) model.setAutomaticallyFixTypos(true) model.setPerformanceTrackingEnabled(true) + model.setMenuBarIconVisible(false) model.setMenuBarWordCountVisible(false) model.setMirrorPreference(.alwaysMirror) model.setMultiLineEnabled(true) @@ -88,6 +89,7 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertTrue(reloaded.offerTypoCorrections) XCTAssertTrue(reloaded.automaticallyFixTypos) XCTAssertTrue(reloaded.isPerformanceTrackingEnabled) + XCTAssertFalse(reloaded.isMenuBarIconVisible) XCTAssertFalse(reloaded.isMenuBarWordCountVisible) XCTAssertEqual(reloaded.mirrorPreference, .alwaysMirror) XCTAssertTrue(reloaded.isMultiLineEnabled) diff --git a/CotabbyTests/SuggestionSettingsStoreTests.swift b/CotabbyTests/SuggestionSettingsStoreTests.swift index 42b2d169..ce2ddb38 100644 --- a/CotabbyTests/SuggestionSettingsStoreTests.swift +++ b/CotabbyTests/SuggestionSettingsStoreTests.swift @@ -148,6 +148,14 @@ final class SuggestionSettingsStoreTests: XCTestCase { XCTAssertFalse(data.automaticallyFixTypos) } + func test_load_menuBarIconDefaultsVisible() async { + let defaults = makeIsolatedDefaults() + + let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) + + XCTAssertTrue(data.isMenuBarIconVisible) + } + func test_saveThenLoad_roundTripsScalarFields() async { let defaults = makeIsolatedDefaults() let store = SuggestionSettingsStore(userDefaults: defaults) @@ -158,6 +166,7 @@ final class SuggestionSettingsStoreTests: XCTestCase { store.saveGhostTextSizeMultiplier(0.8) store.saveFastModeEnabled(true) store.saveAutomaticallyFixTypos(true) + store.saveMenuBarIconVisible(false) store.saveMenuBarWordCountVisible(false) let data = store.load(configuration: .standard) @@ -168,6 +177,7 @@ final class SuggestionSettingsStoreTests: XCTestCase { XCTAssertEqual(data.ghostTextSizeMultiplier, 0.8, accuracy: 0.0001) XCTAssertTrue(data.isFastModeEnabled) XCTAssertTrue(data.automaticallyFixTypos) + XCTAssertFalse(data.isMenuBarIconVisible) XCTAssertFalse(data.isMenuBarWordCountVisible) } From c0b3663afceb3fe1288cb0e6a88494336fc7b020 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:48:02 -0400 Subject: [PATCH 2/2] fix: avoid reopening visible settings window --- Cotabby/App/Core/AppDelegate.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index b047b5bc..4275c18d 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -160,6 +160,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return true } + guard !flag else { + return false + } + CotabbyLogger.app.info("Opening Settings because Cotabby was reopened with its menu bar icon hidden") settingsCoordinator.showSettings() return false