// Menu bar behavior: title dots, per-profile menu, Manage Profiles window. // Reads live state from LiveState (push-based, no polling) and calls // ProfileStore/ClaudeControl in-process — no subprocess, no text parsing. import AppKit final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate { let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) let menu = NSMenu() let liveState = LiveState() private var currentInfos: [ProfileInfo] = [] private var collisionAlertShown = false func applicationDidFinishLaunching(_ notification: Notification) { menu.delegate = self menu.autoenablesItems = false statusItem.menu = menu statusItem.button?.image = menuBarIcon() statusItem.button?.imagePosition = .imageLeft liveState.delegate = self ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() } liveState.reconcile() // 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. if currentInfos.filter(\.running).isEmpty { showStartupPicker(currentInfos) } UpdateChecker.checkAutomaticallyIfDue() } // Claude's own app icon (bundled as Contents/Resources/AppIcon.icns — // currently a placeholder, see icon/generate-icon.swift), scaled down // for the menu bar. private func menuBarIcon() -> NSImage { let source = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName) ?? NSImage() let resized = NSImage(size: NSSize(width: 18, height: 18)) resized.lockFocus() source.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18), from: .zero, operation: .sourceOver, fraction: 1.0) resized.unlockFocus() return resized } // MARK: - LiveStateDelegate func liveStateDidChange(_ infos: [ProfileInfo]) { collisionAlertShown = false currentInfos = infos applyTitle(infos) ManageWindowController.shared.update(infos) } // Two profiles sharing a dir is a fatal misconfiguration (would // silently merge their Claude sessions) — surfaced with an alert // rather than crashing the app outright, since that would leave the // user with no way to fix it short of hand-editing files blind. func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision]) { guard !collisionAlertShown else { return } collisionAlertShown = true let lines = collisions.map { "\u{2022} \($0.kind): \"\($0.profileA)\" and \"\($0.profileB)\" both use \($0.dir)" } let alert = NSAlert() alert.alertStyle = .critical alert.messageText = "shannoncoat: configuration error" alert.informativeText = "Two profiles can't share a directory:\n\n" + lines.joined(separator: "\n") alert.addButton(withTitle: "OK") alert.runModal() } // Title reflects what's ACTUALLY running — one profile shows as a dot // + name; several show as dots only, to keep the menu bar from growing // unbounded, with the full list available via tooltip and the dropdown. private func applyTitle(_ profiles: [ProfileInfo]) { guard let button = statusItem.button else { return } let running = profiles.filter(\.running) switch running.count { case 0: button.attributedTitle = NSAttributedString(string: "") button.toolTip = "Claude not running" case 1: let name = running[0].profile.name let title = NSMutableAttributedString( string: "\u{25cf} ", attributes: [.foregroundColor: ProfileColor.dotColor(for: name)]) title.append(NSAttributedString(string: name, attributes: [.foregroundColor: NSColor.labelColor])) button.attributedTitle = title button.toolTip = nil default: let title = NSMutableAttributedString() for info in running { title.append(NSAttributedString( string: "\u{25cf}", attributes: [.foregroundColor: ProfileColor.dotColor(for: info.profile.name)])) } button.attributedTitle = title button.toolTip = running.map(\.profile.name).joined(separator: ", ") } } // MARK: - Menu func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() liveState.reconcile() // correctness backstop; live updates are the primary mechanism let profiles = currentInfos let runningCount = profiles.filter(\.running).count let header = NSMenuItem( title: runningCount == 0 ? "Claude not running" : runningCount == 1 ? "1 profile running" : "\(runningCount) profiles running", action: nil, keyEquivalent: "") header.isEnabled = false menu.addItem(header) menu.addItem(.separator()) let hint = NSMenuItem(title: "\u{21e7}-click to open alongside", action: nil, keyEquivalent: "") hint.isEnabled = false menu.addItem(hint) menu.addItem(.separator()) for info in profiles { // Click = exclusive switch (quits every other running profile). // Shift-click = open alongside instead, without disturbing // anything else that's running. let title = info.profile.name == liveState.lastActiveName ? "\(info.profile.name) \u{21c6}" : info.profile.name let item = NSMenuItem(title: title, action: #selector(pick(_:)), keyEquivalent: "") item.target = self item.representedObject = info.profile.name item.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running) item.state = info.running ? .on : .off item.toolTip = info.running ? "Running — click to focus" : "Click to switch • Shift-click to open alongside" menu.addItem(item) } menu.addItem(.separator()) let manageItem = NSMenuItem( title: "Manage Profiles\u{2026}", action: #selector(openManageWindow), keyEquivalent: ",") manageItem.keyEquivalentModifierMask = [.command] manageItem.target = self menu.addItem(manageItem) let closeItem = NSMenuItem(title: "Close Profile", action: nil, keyEquivalent: "") let closeMenu = NSMenu() let closeable = profiles.filter(\.running) if closeable.isEmpty { let none = NSMenuItem(title: "(none running)", action: nil, keyEquivalent: "") none.isEnabled = false closeMenu.addItem(none) } else { for info in closeable { let mi = NSMenuItem(title: info.profile.name, action: #selector(closeProfile(_:)), keyEquivalent: "") mi.target = self mi.representedObject = info.profile.name closeMenu.addItem(mi) } } closeItem.submenu = closeMenu menu.addItem(closeItem) menu.addItem(.separator()) let aboutItem = NSMenuItem(title: "About shannoncoat", action: #selector(showAbout), keyEquivalent: "") aboutItem.target = self menu.addItem(aboutItem) menu.addItem(.separator()) let quitClaudeItem = NSMenuItem(title: "Quit Claude", action: #selector(quitClaude), keyEquivalent: "") quitClaudeItem.target = self menu.addItem(quitClaudeItem) let quitSelfItem = NSMenuItem(title: "Quit shannoncoat", action: #selector(quitSelf), keyEquivalent: "q") quitSelfItem.target = self menu.addItem(quitSelfItem) } // Shown once at launch when nothing is running. A checklist rather // than a single pick: unlike the menu's click/Shift-click (one // profile, exclusive vs. alongside), there's nothing running yet to be // "alongside" of, so this lets several profiles be selected to start // together in one go. private func showStartupPicker(_ profiles: [ProfileInfo]) { guard !profiles.isEmpty else { return } let alert = NSAlert() alert.messageText = "Start Claude" alert.informativeText = "Nothing is running. Choose which profiles to start:" alert.addButton(withTitle: "Start") alert.addButton(withTitle: "Cancel") let rowHeight: CGFloat = 22 let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: rowHeight * CGFloat(profiles.count))) var checkboxes: [(box: NSButton, name: String)] = [] for (i, info) in profiles.enumerated() { let y = rowHeight * CGFloat(profiles.count - 1 - i) let box = NSButton(checkboxWithTitle: info.profile.name, target: nil, action: nil) box.frame = NSRect(x: 0, y: y, width: 240, height: rowHeight) box.state = info.profile.name == liveState.lastActiveName ? .on : .off container.addSubview(box) checkboxes.append((box, info.profile.name)) } alert.accessoryView = container guard alert.runModal() == .alertFirstButtonReturn else { return } for (box, name) in checkboxes where box.state == .on { guard let profile = profiles.first(where: { $0.profile.name == name })?.profile else { continue } liveState.markActive(name) ClaudeControl.launch(profile) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() } } // MARK: - Actions @objc func pick(_ sender: NSMenuItem) { guard let name = sender.representedObject as? String, let profile = currentInfos.first(where: { $0.profile.name == name })?.profile else { return } liveState.markActive(name) if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { openProfile(profile) } else { switchTo(profile) } } private func openProfile(_ profile: ResolvedProfile) { if let pid = ClaudeControl.pid(for: profile) { ClaudeControl.focus(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } } else { ClaudeControl.launch(profile) // The launch notification usually beats this, but a short // explicit poke keeps the UI snappy even if it doesn't. DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() } } } private func switchTo(_ profile: ResolvedProfile) { let others = currentInfos.filter { $0.running && $0.profile.name != profile.name } guard !others.isEmpty else { openProfile(profile); return } var remaining = others.count for info in others { guard let pid = info.pid else { remaining -= 1; continue } ClaudeControl.quit(pid: pid) { [weak self] _ in remaining -= 1 if remaining == 0 { DispatchQueue.main.async { self?.openProfile(profile) } } } } } @objc func closeProfile(_ sender: NSMenuItem) { guard let name = sender.representedObject as? String, let info = currentInfos.first(where: { $0.profile.name == name }), let pid = info.pid else { return } ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } } @objc func openManageWindow() { ManageWindowController.shared.update(currentInfos) ManageWindowController.shared.show() } // About lives as a tab in the same window (see ManageWindow.swift) // rather than the standard NSApp About panel — the standard panel // auto-appends its own "Version X (Y)" line from Info.plist's // CFBundleVersion/CFBundleShortVersionString, which are identical in // this build, so it was showing that duplicated alongside our own // version line. @objc func showAbout() { ManageWindowController.shared.update(currentInfos) ManageWindowController.shared.show(tab: .about) } @objc func quitClaude() { ClaudeControl.quitAll { DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } } @objc func quitSelf() { NSApp.terminate(nil) } }