// dingger menu bar: live agent states from tmux pane user options // (@agent / @agent_state), written by the dingger daemon. // // Design (per design panel): one question answered โ€” "how many agents are // waiting on me?" The status item is a bell plus the ready-queue depth; // shape carries state (filled = needs you, outline = seen/calm, dotted = // working); color is reserved for the tmux segment's blocked accent. import AppKit struct PaneRow { let paneId: String let target: String let agent: String let state: String } func symbol(_ name: String) -> NSImage? { let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) return image?.withSymbolConfiguration(.init(pointSize: 13, weight: .regular)) } func stateSymbol(_ state: String, queued: Bool) -> NSImage? { switch (state, queued) { case ("blocked", true): return symbol("exclamationmark.circle.fill") case ("idle", true): return symbol("circle.fill") case ("working", _): return symbol("circle.dotted") ?? symbol("hourglass") case ("blocked", _): return symbol("exclamationmark.circle") default: return symbol("circle") } } final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let menu = NSMenu() private var panes: [PaneRow] = [] private var queue: [String] = [] // pane ids, oldest waiting first func applicationDidFinishLaunching(_: Notification) { NSApp.setActivationPolicy(.accessory) // no Dock icon item.button?.image = symbol("bell") item.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) item.menu = menu menu.delegate = self let timer = Timer(timeInterval: 2.0, repeats: true) { [weak self] _ in self?.refreshTitle() } RunLoop.main.add(timer, forMode: .common) refreshTitle() } // MARK: data private func runTmux(_ args: [String]) -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/tmux") process.arguments = args let pipe = Pipe() process.standardOutput = pipe process.standardError = FileHandle.nullDevice do { try process.run() } catch { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } return String(data: data, encoding: .utf8) } private func fetchPanes() -> [PaneRow] { let fmt = "#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{@agent}\t#{@agent_state}\t#{@dingger_ack}\t#{window_panes}" guard let out = runTmux(["-u", "list-panes", "-a", "-F", fmt]) else { return [] } return out.split(separator: "\n").compactMap { line in let fields = line.split(separator: "\t", omittingEmptySubsequences: false) guard fields.count >= 6 else { return nil } let agent = String(fields[2]) let state = fields[3].isEmpty ? "unknown" : String(fields[3]) // Only tracked panes with a live state belong in the menu. guard !agent.isEmpty, ["idle", "working", "blocked"].contains(state) else { return nil } var target = "\(fields[1].split(separator: ".", maxSplits: 1).first ?? fields[1])" if Int(fields[5]) ?? 1 > 1 { target = String(fields[1]) } return PaneRow(paneId: String(fields[0]), target: target, agent: agent, state: state) } } private func fetchQueue() -> [String] { guard let out = runTmux(["-u", "show-option", "-gv", "@dingger_queue"]) else { return [] } return out.trimmingCharacters(in: .whitespacesAndNewlines) .split(separator: ",").map { $0.split(separator: ":").first.map(String.init) ?? "" } } // MARK: title (timer-driven; never touches the open menu) private func refreshTitle() { panes = fetchPanes() queue = fetchQueue() let queued = panes.filter { row in queue.contains(row.paneId) } let blocked = queued.filter { $0.state == "blocked" }.count let working = panes.filter { $0.state == "working" }.count let (name, count): (String, String) if blocked > 0 { (name, count) = ("bell.fill", String(queued.count)) } else if !queued.isEmpty { (name, count) = ("bell", String(queued.count)) } else if working > 0 { (name, count) = ("bell", "") } else { (name, count) = ("bell", "") } item.button?.image = symbol(name) item.button?.title = count var tip: [String] = [] if blocked > 0 { tip.append("\(blocked) blocked") } if queued.count - blocked > 0 { tip.append("\(queued.count - blocked) waiting") } if working > 0 { tip.append("\(working) working") } let tipText = tip.isEmpty ? "Nothing waiting" : tip.joined(separator: " ยท ") item.button?.toolTip = tipText item.button?.setAccessibilityLabel("dingger, " + tipText) } // MARK: menu (rebuilt when opened, so rows don't reshuffle under the cursor) func menuNeedsUpdate(_ menu: NSMenu) { rebuildMenu() } private func rebuildMenu() { menu.removeAllItems() // Queue order (oldest waiting first), then the rest by target. var ordered: [PaneRow] = [] for paneId in queue { if let row = panes.first(where: { $0.paneId == paneId && $0.state != "working" }) { ordered.append(row) } } let orderedIds = ordered.map { $0.paneId } let rest = panes .filter { !orderedIds.contains($0.paneId) } .sorted { $0.target < $1.target } ordered.append(contentsOf: rest) if panes.isEmpty { let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "") none.isEnabled = false menu.addItem(none) } var previousWaiting: Bool? for pane in ordered { let queued = queue.contains(pane.paneId) let waiting = pane.state == "idle" || pane.state == "blocked" // One separator between waiting agents and busy ones. if let previous = previousWaiting, previous && !waiting { menu.addItem(.separator()) } previousWaiting = waiting let row = NSMenuItem(title: "\(pane.agent)\t\(pane.target)", action: #selector(paneClicked(_:)), keyEquivalent: "") row.target = self row.image = stateSymbol(pane.state, queued: queued) row.attributedTitle = attributedRow(agent: pane.agent, target: pane.target) row.toolTip = "\(pane.state) โ€” \(pane.target)" row.representedObject = pane.paneId menu.addItem(row) } menu.addItem(.separator()) let quit = NSMenuItem(title: "Quit dingger", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") menu.addItem(quit) } /// Agent left, tmux target right-aligned at a computed tab stop โ€” like a /// native menu's key-equivalent column. private func attributedRow(agent: String, target: String) -> NSAttributedString { let menuFont = NSFont.menuFont(ofSize: 0) let monoFont = NSFont.monospacedSystemFont( ofSize: menuFont.pointSize - 1, weight: .regular) let agentWidth = (agent as NSString).size(withAttributes: [.font: menuFont]).width let targetWidth = (target as NSString).size(withAttributes: [.font: monoFont]).width let tabStop = ceil(agentWidth + 40 + targetWidth) let paragraph = NSMutableParagraphStyle() paragraph.tabStops = [NSTextTab(textAlignment: .right, location: tabStop)] let text = NSMutableAttributedString() text.append(NSAttributedString( string: agent, attributes: [.font: menuFont, .paragraphStyle: paragraph])) text.append(NSAttributedString(string: "\t")) text.append(NSAttributedString( string: target, attributes: [.font: monoFont, .paragraphStyle: paragraph])) return text } // MARK: actions @objc private func paneClicked(_ sender: NSMenuItem) { guard let paneId = sender.representedObject as? String else { return } // Ack like `dingger focus` does, so both counts drop together. _ = runTmux(["-u", "set-option", "-p", "-t", paneId, "@dingger_ack", "1"]) let target = panes.first { $0.paneId == paneId }?.target ?? "" guard !target.isEmpty else { return } _ = runTmux(["-u", "switch-client", "-t", target]) for bundle in ["com.mitchellh.ghostty", "org.alacritty", "com.apple.Terminal"] { if let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundle).first { app.activate() break } } } } let app = NSApplication.shared let delegate = Menubar() app.delegate = delegate app.setActivationPolicy(.accessory) app.run()