// dingger menu bar: herdr's agent panel as a macOS status item, plus a // floating always-on-top panel (panel.swift) and agent search (search.swift), // each on a global shortcut (hotkey.swift). Rows and colors come from // model.swift. // // Reads the pane options the dingger daemon writes and renders herdr's // Symbols style with herdr's palette: // // blocked × red done ✓ teal working ◐ yellow idle ○ green // // in herdr's priority order, oldest waiting first. Clicking a row runs // `dingger focus --pane`, the same tested code path as the tmux status-line // click. // // Debug flags (used by tests/e2e.sh): // --print print the title and rows the menu would show, then exit // (rows: " \t\t<subtitle>") // --print-panel [--full] [--toggle] // print what the floating panel would show, then exit // --snapshot-panel <png> [--full] // render the panel offscreen (after a different render, to // catch stale content) over white and black, then exit // --print-search <query> // print what agent search would list for <query>, then exit // --snapshot-search <png> <query> [<down>] // render agent search offscreen, then exit // --click <pane> perform a row click for <pane>, then exit // --selftest check the panel and search rules, then exit import AppKit func log(_ message: String) { FileHandle.standardError.write(Data("\(Date()) \(message)\n".utf8)) } final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let menu = NSMenu() private var panel: PanelController! private var searchWindow: SearchController! private var visibility = Menubar.loadVisibility() private var settings = Settings() private var settingsModified: Date? private var hotkey: Hotkey? private var hotkeyRegistered = false private var jumpHotkey: Hotkey? private var jumpHotkeyRegistered = false private var searchHotkey: Hotkey? private var searchHotkeyRegistered = false private var prefersFull = UserDefaults.standard.bool(forKey: "panelFull") private var rows: [Row] = [] /// The panel settings; the first run after the on/off split migrates the /// old three-way mode. private static func loadVisibility() -> PanelVisibility { let defaults = UserDefaults.standard guard defaults.object(forKey: "panelEnabled") != nil else { let migrated = PanelVisibility.migrated(fromMode: defaults.string(forKey: "panelMode") ?? "auto") defaults.set(migrated.enabled, forKey: "panelEnabled") defaults.set(migrated.policy.rawValue, forKey: "panelPolicy") defaults.removeObject(forKey: "panelMode") return migrated } return PanelVisibility( enabled: defaults.bool(forKey: "panelEnabled"), policy: PanelPolicy(rawValue: defaults.string(forKey: "panelPolicy") ?? "") ?? .whenWaiting) } func applicationDidFinishLaunching(_: Notification) { menu.delegate = self item.menu = menu panel = PanelController( onClick: { [weak self] paneId in self?.jump(to: paneId) }, onToggleFull: { [weak self] in self?.toggleFull() }) searchWindow = SearchController(onJump: { [weak self] paneId in self?.jump(to: paneId) }) Settings.ensureFile() reloadSettings() refresh() let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in self?.refresh() } RunLoop.main.add(timer, forMode: .common) } // MARK: settings and shortcut private func reloadSettings() { settingsModified = Settings.modified() settings = Settings.load() (hotkey, hotkeyRegistered) = registerShortcut(.panel, settings.hotkey, "panel") { [weak self] in self?.togglePanel() } (jumpHotkey, jumpHotkeyRegistered) = registerShortcut(.jump, settings.jumpHotkey, "jump") { [weak self] in self?.jumpToTopWaiting() } (searchHotkey, searchHotkeyRegistered) = registerShortcut(.search, settings.searchHotkey, "search") { [weak self] in self?.toggleSearch() } } private func registerShortcut(_ id: GlobalHotkey.Id, _ spec: String, _ name: String, action: @escaping () -> Void) -> (Hotkey?, Bool) { guard let parsed = Hotkey.parse(spec) else { GlobalHotkey.unregister(id) log("settings: invalid \(name) shortcut \"\(spec)\"") return (nil, false) } let registered = GlobalHotkey.register(id, parsed, action: action) log("settings: \(name) shortcut \(parsed.display) \(registered ? "registered" : "in use by another app")") return (parsed, registered) } // MARK: refresh private func refresh() { if Settings.modified() != settingsModified { reloadSettings() } rows = fetchRows() item.button?.attributedTitle = title(for: rows) var tip: [String] = [] for status in [Status.blocked, .done, .working, .idle] { let count = rows.filter { $0.status == status }.count if count > 0 { tip.append("\(count) \(status.rawValue)") } } let tipText = tip.isEmpty ? "No agents" : tip.joined(separator: " · ") item.button?.toolTip = tipText item.button?.setAccessibilityLabel("dingger, " + tipText) visibility.anyWaiting = rows.contains(where: \.waiting) applyPanel() searchWindow.update(rows: rows) } /// The search shortcut and menu item: open agent search, or close it. @objc private func toggleSearch() { if searchWindow.isOpen { searchWindow.close("shortcut") } else { rows = fetchRows() searchWindow.open(rows: rows) } } private var panelContent: PanelContent { PanelContent(rows: rows, full: panelShowsFull(policy: visibility.policy, prefersFull: prefersFull), canToggle: visibility.policy == .always) } private func applyPanel() { if visibility.visible(at: Date()) { if panel.render(panelContent) { let content = panelContent log("panel: render \(content.rows.filter(\.waiting).count) waiting, \(content.full ? "full" : "compact")") } if !panel.isVisible { panel.show() log("panel: shown (\(panelState))") } } else if panel.isVisible { panel.hide() log("panel: hidden (\(panelState))") } } private var panelState: String { "\(visibility.enabled ? "on" : "off"), \(visibility.policy.rawValue), " + (visibility.anyWaiting ? "agents waiting" : "nothing waiting") } /// The shortcut and the menu item: turn the panel on or off, persistently. @objc private func togglePanel() { visibility.toggle(at: Date()) UserDefaults.standard.set(visibility.enabled, forKey: "panelEnabled") log("panel: turned \(visibility.enabled ? "on" : "off")") applyPanel() } private func toggleFull() { prefersFull.toggle() UserDefaults.standard.set(prefersFull, forKey: "panelFull") applyPanel() } /// The jump shortcut: same as clicking the tmux status line. @objc private func jumpToTopWaiting() { DispatchQueue.global().async { let result = runReporting(dinggerBinary, ["focus"]) log("jump: dingger focus \(result.ok ? "ok" : "failed: \(result.output)")") DispatchQueue.main.async { self.refresh() } } } @objc private func selectPolicy(_ sender: NSMenuItem) { guard let raw = sender.representedObject as? String, let policy = PanelPolicy(rawValue: raw) else { return } visibility.policy = policy UserDefaults.standard.set(policy.rawValue, forKey: "panelPolicy") applyPanel() } @objc private func openSettings() { Settings.ensureFile() _ = run("/usr/bin/open", ["-t", Settings.path]) } private func jump(to paneId: String) { log("jump: to \(paneId)") DispatchQueue.global().async { focus(paneId) DispatchQueue.main.async { self.refresh() } } } // MARK: menu (rebuilt from fresh data every time it opens) func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() rows = fetchRows() if rows.isEmpty { let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "") none.isEnabled = false menu.addItem(none) } for (index, row) in rows.enumerated() { // One separator between the agents waiting for you and the rest. if index > 0, rows[index - 1].waiting, !row.waiting { menu.addItem(.separator()) } let entry = NSMenuItem(title: row.label, action: #selector(rowClicked(_:)), keyEquivalent: "") entry.target = self entry.attributedTitle = rowTitle(row) entry.subtitle = row.subtitle entry.toolTip = row.name.isEmpty ? nil : row.name entry.representedObject = row.paneId menu.addItem(entry) } menu.addItem(.separator()) let toggle = NSMenuItem(title: "Show Panel", action: #selector(togglePanel), keyEquivalent: "") toggle.target = self toggle.state = visibility.enabled ? .on : .off if let hotkey, hotkeyRegistered { toggle.keyEquivalent = hotkey.key.count == 1 ? hotkey.key : "" toggle.keyEquivalentModifierMask = hotkey.modifierFlags } menu.addItem(toggle) let jumpItem = NSMenuItem(title: "Jump to Waiting Agent", action: #selector(jumpToTopWaiting), keyEquivalent: "") jumpItem.target = self jumpItem.isEnabled = rows.contains(where: \.waiting) if let jumpHotkey, jumpHotkeyRegistered { jumpItem.keyEquivalent = jumpHotkey.key.count == 1 ? jumpHotkey.key : "" jumpItem.keyEquivalentModifierMask = jumpHotkey.modifierFlags } menu.addItem(jumpItem) let searchItem = NSMenuItem(title: "Search Agents…", action: #selector(toggleSearch), keyEquivalent: "") searchItem.target = self searchItem.isEnabled = !rows.isEmpty if let searchHotkey, searchHotkeyRegistered { searchItem.keyEquivalent = searchHotkey.key.count == 1 ? searchHotkey.key : "" searchItem.keyEquivalentModifierMask = searchHotkey.modifierFlags } menu.addItem(searchItem) let policyMenu = NSMenu() for policy in PanelPolicy.allCases { let option = NSMenuItem(title: policy.label, action: #selector(selectPolicy(_:)), keyEquivalent: "") option.target = self option.representedObject = policy.rawValue option.state = visibility.policy == policy ? .on : .off policyMenu.addItem(option) } let policyItem = NSMenuItem(title: "Panel Shows", action: nil, keyEquivalent: "") policyItem.submenu = policyMenu menu.addItem(policyItem) for (shortcut, registered, spec) in [(hotkey, hotkeyRegistered, settings.hotkey), (jumpHotkey, jumpHotkeyRegistered, settings.jumpHotkey), (searchHotkey, searchHotkeyRegistered, settings.searchHotkey)] { guard !registered else { continue } let problem = shortcut.map { "Shortcut \($0.display) is used by another app" } ?? "Shortcut \"\(spec)\" is not valid" let item = NSMenuItem(title: problem, action: nil, keyEquivalent: "") item.isEnabled = false menu.addItem(item) } let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",") settingsItem.target = self menu.addItem(settingsItem) menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Quit dingger", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) } @objc private func rowClicked(_ sender: NSMenuItem) { guard let paneId = sender.representedObject as? String else { return } jump(to: paneId) } } let arguments = CommandLine.arguments if arguments.contains("--selftest") { var failures = panelSelftest() + searchSelftest() if Hotkey.parse("ctrl+opt+cmd+e")?.display != "⌃⌥⌘E" { failures.append("hotkey: parse ctrl+opt+cmd+e") } if Hotkey.parse("e") != nil { failures.append("hotkey: a key without modifiers is rejected") } if Hotkey.parse("ctrl+opt+bogus") != nil { failures.append("hotkey: unknown keys are rejected") } if Hotkey.parse("cmd+shift+f12")?.display != "⇧⌘F12" { failures.append("hotkey: parse cmd+shift+f12") } if Hotkey.parse(Settings().jumpHotkey)?.display != "⌃⌥⌘U" { failures.append("hotkey: default jump is ⌃⌥⌘U") } if Hotkey.parse(Settings().searchHotkey)?.display != "⌃⌥⇧⌘U" { failures.append("hotkey: default search is ⌃⌥⇧⌘U") } failures.forEach { print("FAIL \($0)") } print(failures.isEmpty ? "selftest ok" : "selftest: \(failures.count) failed") exit(failures.isEmpty ? 0 : 1) } if arguments.contains("--print") { let rows = fetchRows() print("title: \(plainTitle(for: rows))") for row in rows { print("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)") } exit(0) } if arguments.contains("--print-panel") { let full = arguments.contains("--full") print(PanelContent(rows: fetchRows(), full: full, canToggle: full || arguments.contains("--toggle")).plainText) exit(0) } if let index = arguments.firstIndex(of: "--snapshot-panel"), index + 1 < arguments.count { _ = NSApplication.shared let controller = PanelController(persistFrame: false, onClick: { _ in }, onToggleFull: {}) // First render something else, so stale views would show in the image. controller.render(PanelContent(rows: [Row( paneId: "%stale", agent: "pi", name: "STALE ROW FROM A PREVIOUS RENDER", status: .blocked, session: "old", window: "old", since: 0)])) let full = arguments.contains("--full") controller.render(PanelContent(rows: fetchRows(), full: full, canToggle: full)) exit(controller.snapshot(to: arguments[index + 1]) ? 0 : 1) } if let index = arguments.firstIndex(of: "--print-search") { var state = SearchState(rows: fetchRows()) state.setQuery(index + 1 < arguments.count ? arguments[index + 1] : "") print(state.plainText) exit(0) } if let index = arguments.firstIndex(of: "--snapshot-search"), index + 2 < arguments.count { _ = NSApplication.shared let controller = SearchController(onJump: { _ in }) let down = index + 3 < arguments.count ? Int(arguments[index + 3]) ?? 0 : 0 exit(controller.snapshot(rows: fetchRows(), query: arguments[index + 2], down: down, to: arguments[index + 1]) ? 0 : 1) } if let index = arguments.firstIndex(of: "--click"), index + 1 < arguments.count { focus(arguments[index + 1]) exit(0) } let app = NSApplication.shared let delegate = Menubar() app.delegate = delegate app.setActivationPolicy(.accessory) app.run()