From 79395ef9d61b4894034b8bfe69cbff2f2d7f4c40 Mon Sep 17 00:00:00 2001 From: bdeshi Date: Sat, 22 Aug 2026 12:13:17 +0600 Subject: [PATCH] Add setting to hide the startup picker with a one-time menu-bar hint Lets someone skip the startup picker entirely; the first time it would've shown with nothing running, a small popover on the menu bar icon points there instead, then never again. Co-Authored-By: Claude Sonnet 5 --- Sources/AppDelegate.swift | 70 ++++++++++++++++++++++++++++++++++++-- Sources/ManageWindow.swift | 15 ++++++-- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index ca30b53..337bf04 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -41,6 +41,30 @@ enum MenuClickBehavior { } } +// Whether the startup picker (see AppDelegate.showStartupPicker) should +// appear at all when nothing's running at launch. Off by default, so the +// existing picker keeps showing up unless someone opts out of it. +enum StartupBehavior { + private static let hidePickerKey = "HideStartupPicker" + + static var hidePicker: Bool { + get { UserDefaults.standard.bool(forKey: hidePickerKey) } + set { UserDefaults.standard.set(newValue, forKey: hidePickerKey) } + } +} + +// A person who's opted out of the startup picker still needs to discover +// the menu bar icon somehow the very first time — this fires once, ever, +// the first time that situation actually arises, then never again. +private enum MenuBarHintShown { + private static let key = "MenuBarHintShown" + + static var value: Bool { + get { UserDefaults.standard.bool(forKey: key) } + set { UserDefaults.standard.set(newValue, forKey: key) } + } +} + final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate { let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) let menu = NSMenu() @@ -53,6 +77,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt private var accessibilityRecheckScheduled = false private var activationObserver: NSObjectProtocol? private var appearanceObserver: NSKeyValueObservation? + private var menuBarHintPopover: NSPopover? func applicationDidFinishLaunching(_ notification: Notification) { menu.delegate = self @@ -91,9 +116,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt // Launching this app fresh with nothing running means there's no // menu-bar title/dots to click yet — offer a picker up front - // instead of leaving the user to discover the dropdown. + // instead of leaving the user to discover the dropdown, unless + // that picker's been turned off, in which case a one-time hint + // takes its place (see MenuBarHintShown). if currentInfos.filter(\.running).isEmpty { - showStartupPicker(currentInfos) + if StartupBehavior.hidePicker { + showMenuBarHintIfNeeded() + } else { + showStartupPicker(currentInfos) + } } UpdateChecker.checkAutomaticallyIfDue() @@ -403,6 +434,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() } } + // Stands in for showStartupPicker when that's been turned off — but + // only the very first time nothing's running at launch, ever. After + // that, someone who opted out of the picker gets silence, same as + // launching with something already running. + private func showMenuBarHintIfNeeded() { + guard !MenuBarHintShown.value, let button = statusItem.button else { return } + MenuBarHintShown.value = true + + let label = NSTextField(wrappingLabelWithString: "Claude isn't running — click here to start a profile.") + label.font = .systemFont(ofSize: 13) + label.isEditable = false + label.isBordered = false + label.drawsBackground = false + label.preferredMaxLayoutWidth = 200 + + let container = NSView() + container.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: container.topAnchor, constant: 12), + label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12), + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14), + label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14), + ]) + + let viewController = NSViewController() + viewController.view = container + + let popover = NSPopover() + popover.contentViewController = viewController + popover.behavior = .transient + menuBarHintPopover = popover + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY) + } + // MARK: - Actions @objc func pick(_ sender: NSMenuItem) { diff --git a/Sources/ManageWindow.swift b/Sources/ManageWindow.swift index 4ca1c6d..844903f 100644 --- a/Sources/ManageWindow.swift +++ b/Sources/ManageWindow.swift @@ -508,6 +508,8 @@ private final class SettingsViewController: NSViewController { private let autoUpdateCheckbox = NSButton( checkboxWithTitle: "Automatically check for updates", target: nil, action: nil) private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch at Login", target: nil, action: nil) + private let hideStartupPickerCheckbox = NSButton( + checkboxWithTitle: "Hide the startup picker when no profiles are running", target: nil, action: nil) private let checkNowButton = NSButton(title: "Check for Updates\u{2026}", target: nil, action: nil) private let tagStyleLabel = NSTextField(labelWithString: "Window tag:") private let tagStylePopUp = NSPopUpButton(frame: .zero, pullsDown: false) @@ -564,12 +566,17 @@ private final class SettingsViewController: NSViewController { autoUpdateCheckbox.action = #selector(toggleAutoUpdate) autoUpdateCheckbox.state = UpdateChecker.automaticCheckEnabled ? .on : .off + hideStartupPickerCheckbox.target = self + hideStartupPickerCheckbox.action = #selector(toggleHideStartupPicker) + hideStartupPickerCheckbox.state = StartupBehavior.hidePicker ? .on : .off + checkNowButton.target = self checkNowButton.action = #selector(checkNow) checkNowButton.bezelStyle = .rounded stack = NSStackView(views: [ - launchAtLoginCheckbox, clickActionRow, tagStyleRow, autoUpdateCheckbox, checkNowButton, + launchAtLoginCheckbox, clickActionRow, tagStyleRow, hideStartupPickerCheckbox, + autoUpdateCheckbox, checkNowButton, ]) stack.orientation = .vertical stack.alignment = .leading @@ -610,7 +617,7 @@ private final class SettingsViewController: NSViewController { clickActionLabel.intrinsicContentSize.width + clickActionPopUp.intrinsicContentSize.width + clickActionRow.spacing, ] - let widest = ([launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton] + let widest = ([launchAtLoginCheckbox, hideStartupPickerCheckbox, autoUpdateCheckbox, checkNowButton] .map(\.intrinsicContentSize.width) + rowWidths) .max() ?? 0 preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height) @@ -648,6 +655,10 @@ private final class SettingsViewController: NSViewController { UpdateChecker.automaticCheckEnabled = autoUpdateCheckbox.state == .on } + @objc private func toggleHideStartupPicker() { + StartupBehavior.hidePicker = hideStartupPickerCheckbox.state == .on + } + @objc private func checkNow() { UpdateChecker.checkManually { result in DispatchQueue.main.async {