aboutsummaryrefslogtreecommitdiff
path: root/menubar
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-20 12:43:07 +0300
committerIgor Soarez <igor@soarez.org>2026-10-20 12:43:07 +0300
commit4af6a4f625b1965b783c9efb857f8ac665adb8eb (patch)
treedd2dac9627735fd02b3372a4f569125a702ba57a /menubar
parent943fc940d9c387916708cb3fdc912323c92ef6dc (diff)
rebuild the status model as herdr's state machine, event-driven and tested
the seen logic was inverted: first observations were marked done and real completions idle, so the menu showed teal for agents that did nothing and green for agents that finished, and attending changed nothing visible. - watcher::advance is herdr's apply_pane_state_change + mark_active_tab_seen as a pure function, unit-tested against herdr's own cases; attendance is window-level like herdr's active tab - hook reports are events applied in order, so a turn shorter than a poll is still a completion - event-driven: hook reports, dingger focus, and tmux focus hooks wake the watcher; notifications are delivered off-thread (the macos call blocked ticks for 2+ seconds) after the pane options are written - all per-pane state persists in tmux pane options, so restarts and deploys lose nothing, including the authoritative hook state - every click goes through dingger focus: the menu bar shells out to it and the status-line binding runs it; it picks the right client, skips the window already shown, and activates the terminal app hosting the client - the ready queue is ordered like the menu (blocked first, then oldest done) - menu bar and tmux segment render herdr's symbols in herdr's palette - tests/e2e.sh: 60 assertions against an isolated tmux server with a real client, a 30s poll interval, and a slow fake notifier
Diffstat (limited to 'menubar')
-rw-r--r--menubar/main.swift440
1 files changed, 213 insertions, 227 deletions
diff --git a/menubar/main.swift b/menubar/main.swift
index d0a3b30..602b5cc 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -1,286 +1,272 @@
-// dingger menu bar: live agent states from tmux pane user options
-// (@agent / @agent_state), written by the dingger daemon.
+// dingger menu bar: herdr's agent panel as a macOS status item.
//
-// Visual language is herdr's agent panel, verbatim: five statuses
-// (working ◐ yellow, blocked × red, done ✓ teal, idle ○ green,
-// unknown · gray), priority order blocked > done > working > idle,
-// and the number in the title is herdr's actionable set (done + blocked).
+// Reads the pane options the dingger daemon writes (@agent, @agent_state,
+// @dingger_since) and renders herdr's Symbols style with herdr's palette:
+//
+// blocked × red done ✓ teal working ◐ yellow idle ○ green
+//
+// in herdr's priority order (blocked > done > working > idle), 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
+// --click <pane> perform a row click for <pane>, then exit
import AppKit
-struct PaneRow {
- let paneId: String
- let target: String
- let agent: String
- let state: String
-}
-
-let red = NSColor.systemRed
-let yellow = NSColor.systemYellow
-let teal = NSColor.systemTeal
-let green = NSColor.systemGreen
-let gray = NSColor.systemGray
+// MARK: herdr's palette (catppuccin mocha on dark, latte on light)
-/// Tint a symbol image (non-template, so it keeps its color in menus).
-func tinted(_ image: NSImage?, _ color: NSColor) -> NSImage? {
- guard let image = image else { return nil }
- let img = NSImage(size: image.size, flipped: false) { rect in
- image.draw(in: rect)
- color.set()
- rect.fill(using: .sourceAtop)
- return true
+func herdrColor(dark: (CGFloat, CGFloat, CGFloat), light: (CGFloat, CGFloat, CGFloat)) -> NSColor {
+ NSColor(name: nil) { appearance in
+ let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ let (r, g, b) = isDark ? dark : light
+ return NSColor(srgbRed: r / 255, green: g / 255, blue: b / 255, alpha: 1)
}
- img.isTemplate = false
- return img
}
-func symbol(_ name: String, _ color: NSColor? = nil) -> NSImage? {
- let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)?
- .withSymbolConfiguration(.init(pointSize: 13, weight: .regular))
- return color.map { tinted(image, $0) } ?? image
-}
+let herdrRed = herdrColor(dark: (243, 139, 168), light: (210, 15, 57))
+let herdrTeal = herdrColor(dark: (148, 226, 213), light: (23, 146, 153))
+let herdrYellow = herdrColor(dark: (249, 226, 175), light: (223, 142, 29))
+let herdrGreen = herdrColor(dark: (166, 227, 161), light: (64, 160, 43))
+let herdrGray = herdrColor(dark: (108, 112, 134), light: (156, 160, 176))
-/// Herdr's status_color + Symbols style, as SF Symbols.
-func statusSymbol(_ status: String) -> NSImage? {
- switch status {
- case "working": return symbol("circle.dotted", yellow)
- case "blocked": return symbol("exclamationmark.circle.fill", red)
- case "done": return symbol("checkmark.circle.fill", teal)
- case "idle": return symbol("checkmark.circle", green)
- default: return symbol("questionmark.circle", gray)
- }
-}
+// MARK: herdr's status vocabulary
-func statusPriority(_ status: String) -> Int {
- switch status {
- case "blocked": return 4
- case "done": return 3
- case "working": return 2
- case "idle": return 1
- default: return 0
+enum Status: String {
+ case blocked, done, working, idle, unknown
+
+ var symbol: String {
+ switch self {
+ case .blocked: return "×"
+ case .done: return "✓"
+ case .working: return "◐"
+ case .idle: return "○"
+ case .unknown: return "·"
+ }
}
-}
-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
+ var color: NSColor {
+ switch self {
+ case .blocked: return herdrRed
+ case .done: return herdrTeal
+ case .working: return herdrYellow
+ case .idle: return herdrGreen
+ case .unknown: return herdrGray
+ }
+ }
- func applicationDidFinishLaunching(_: Notification) {
- NSApp.setActivationPolicy(.accessory) // no Dock icon
- item.button?.image = symbol("bell", gray)
- 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()
+ var priority: Int {
+ switch self {
+ case .blocked: return 4
+ case .done: return 3
+ case .working: return 2
+ case .idle: return 1
+ case .unknown: return 0
}
- RunLoop.main.add(timer, forMode: .common)
- refreshTitle()
}
+}
- // MARK: data
+struct Row {
+ let paneId: String
+ let agent: String
+ let status: Status
+ let target: String
+ let since: Int
+}
- 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)
+// MARK: tmux and dingger access
+
+let environment = ProcessInfo.processInfo.environment
+
+func run(_ path: String, _ args: [String]) -> String? {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: path)
+ 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#{@agent}\t#{@agent_state}\t#{@dingger_ack}\t#{session_name}:#{window_name}\t#{window_panes}\t#{pane_index}"
- 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 >= 7 else { return nil }
- let agent = String(fields[1])
- let state = fields[2].isEmpty ? "unknown" : String(fields[2])
- guard !agent.isEmpty, ["working", "blocked", "done", "idle"].contains(state) else {
- return nil
- }
- // fields[4] is already `session:window-name`; add the pane index
- // only when the window is split.
- var target = String(fields[4])
- if Int(fields[5]) ?? 1 > 1 {
- target += ".\(fields[6])"
- }
- return PaneRow(paneId: String(fields[0]), target: target, agent: agent, state: state)
- }
+func tmux(_ args: [String]) -> String? {
+ var all = ["-u"]
+ if let socket = environment["DINGGER_TMUX_SOCKET"] {
+ all += ["-L", socket]
}
+ return run(environment["DINGGER_TMUX"] ?? "/opt/homebrew/bin/tmux", all + args)
+}
- 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) ?? "" }
+let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger")
+
+/// Every agent pane, in herdr's priority order, oldest waiting first.
+func fetchRows() -> [Row] {
+ let format = [
+ "#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}",
+ "#{session_name}:#{window_name}", "#{window_panes}", "#{pane_index}",
+ ].joined(separator: "\t")
+ guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] }
+ let rows: [Row] = out.split(separator: "\n").compactMap { line in
+ let f = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
+ guard f.count >= 7, !f[1].isEmpty else { return nil }
+ let status = Status(rawValue: f[2]) ?? .unknown
+ guard status != .unknown else { return nil }
+ let target = (Int(f[5]) ?? 1) > 1 ? "\(f[4]).\(f[6])" : f[4]
+ return Row(paneId: f[0], agent: f[1], status: status, target: target,
+ since: Int(f[3]) ?? Int.max)
+ }
+ return rows.sorted {
+ if $0.status.priority != $1.status.priority { return $0.status.priority > $1.status.priority }
+ if $0.since != $1.since { return $0.since < $1.since }
+ return $0.target < $1.target
}
+}
+
+/// Show the pane and mark it attended; the same code path as the tmux
+/// status-line click.
+func focus(_ paneId: String) {
+ _ = run(dinggerBinary, ["focus", "--pane", paneId])
+}
- private func orderedPanes() -> [PaneRow] {
- // herdr's priority: blocked > done > working > idle; FIFO within the
- // queue (oldest waiting first), target order for the rest.
- let priority = { (row: PaneRow) -> Int in statusPriority(row.state) }
- var rows = panes
- rows.sort {
- let (pa, pb) = (priority($0), priority($1))
- if pa != pb { return pa > pb }
- let (qa, qb) = (queue.firstIndex(of: $0.paneId), queue.firstIndex(of: $1.paneId))
- if let qa, let qb { return qa < qb }
- if qa != nil { return true }
- if qb != nil { return false }
- return $0.target < $1.target
+// MARK: rendering
+
+/// herdr-style summary: `×1 ✓3 ◐2` in priority order; `○` when every agent
+/// is idle; `·` when there are none.
+func title(for rows: [Row]) -> NSAttributedString {
+ let font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium)
+ let text = NSMutableAttributedString()
+ for status in [Status.blocked, .done, .working] {
+ let count = rows.filter { $0.status == status }.count
+ guard count > 0 else { continue }
+ if text.length > 0 {
+ text.append(NSAttributedString(string: " ", attributes: [.font: font]))
}
- return rows
+ text.append(NSAttributedString(
+ string: "\(status.symbol)\(count)",
+ attributes: [.font: font, .foregroundColor: status.color]))
}
+ if text.length == 0 {
+ let status: Status = rows.isEmpty ? .unknown : .idle
+ text.append(NSAttributedString(
+ string: status.symbol, attributes: [.font: font, .foregroundColor: status.color]))
+ }
+ return text
+}
- // MARK: title (timer-driven; never touches the open menu)
+func plainTitle(for rows: [Row]) -> String {
+ title(for: rows).string
+}
- private func refreshTitle() {
- panes = fetchPanes()
- queue = fetchQueue()
- FileHandle.standardError.write(Data(
- "\(Date()) menubar: panes=\(panes.count) queue=\(queue.count)\n".utf8))
- redrawTitle()
- }
+func rowTitle(_ row: Row, tabStop: CGFloat) -> NSAttributedString {
+ let font = NSFont.menuFont(ofSize: 0)
+ let mono = NSFont.monospacedSystemFont(ofSize: font.pointSize - 1, weight: .regular)
+ let paragraph = NSMutableParagraphStyle()
+ paragraph.tabStops = [NSTextTab(textAlignment: .right, location: tabStop)]
+ let text = NSMutableAttributedString()
+ text.append(NSAttributedString(
+ string: "\(row.status.symbol) ",
+ attributes: [.font: mono, .foregroundColor: row.status.color, .paragraphStyle: paragraph]))
+ text.append(NSAttributedString(
+ string: row.agent, attributes: [.font: font, .paragraphStyle: paragraph]))
+ text.append(NSAttributedString(
+ string: "\t\(row.target)",
+ attributes: [.font: mono, .foregroundColor: NSColor.secondaryLabelColor,
+ .paragraphStyle: paragraph]))
+ return text
+}
- private func redrawTitle() {
- let blocked = panes.filter { $0.state == "blocked" }.count
- let done = panes.filter { $0.state == "done" }.count
- let working = panes.filter { $0.state == "working" }.count
+// MARK: app
- let (image, count): (NSImage?, String)
- if blocked > 0 {
- (image, count) = (symbol("bell.fill", red), String(blocked))
- } else if done > 0 {
- (image, count) = (symbol("bell.fill", teal), String(done))
- } else if working > 0 {
- (image, count) = (symbol("bell", yellow), "")
- } else {
- (image, count) = (symbol("bell", gray), "")
+final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
+ private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ private let menu = NSMenu()
+
+ func applicationDidFinishLaunching(_: Notification) {
+ menu.delegate = self
+ item.menu = menu
+ refreshTitle()
+ let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
+ self?.refreshTitle()
}
- item.button?.image = image
- item.button?.title = count
+ RunLoop.main.add(timer, forMode: .common)
+ }
+ private func refreshTitle() {
+ let rows = fetchRows()
+ item.button?.attributedTitle = title(for: rows)
var tip: [String] = []
- if blocked > 0 { tip.append("\(blocked) blocked") }
- if done > 0 { tip.append("\(done) done") }
- if working > 0 { tip.append("\(working) working") }
+ 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)
}
- // MARK: menu (rebuilt when opened, so rows don't reshuffle under the cursor)
-
+ // Rebuilt from fresh data every time the menu opens.
func menuNeedsUpdate(_ menu: NSMenu) {
- rebuildMenu()
- }
-
- private func rebuildMenu() {
menu.removeAllItems()
-
- let rows = orderedPanes()
+ let rows = fetchRows()
if rows.isEmpty {
let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "")
none.isEnabled = false
menu.addItem(none)
}
-
- var previousPriority: Int?
- for pane in rows {
- let priority = statusPriority(pane.state)
- if let previous = previousPriority, previous != priority {
- menu.addItem(.separator())
- }
- previousPriority = priority
-
- let row = NSMenuItem(title: "\(pane.agent)\t\(pane.target)",
- action: #selector(paneClicked(_:)), keyEquivalent: "")
- row.target = self
- row.image = statusSymbol(pane.state)
- row.attributedTitle = attributedRow(agent: pane.agent, target: pane.target)
- row.toolTip = "\(pane.state) — \(pane.target)"
- row.representedObject = pane.paneId
- menu.addItem(row)
+ let font = NSFont.menuFont(ofSize: 0)
+ let mono = NSFont.monospacedSystemFont(ofSize: font.pointSize - 1, weight: .regular)
+ let widest = rows.map { row -> CGFloat in
+ ("\(row.status.symbol) " as NSString).size(withAttributes: [.font: mono]).width
+ + (row.agent as NSString).size(withAttributes: [.font: font]).width
+ + (row.target as NSString).size(withAttributes: [.font: mono]).width
+ }.max() ?? 0
+ let tabStop = ceil(widest + 40)
+ for row in rows {
+ let entry = NSMenuItem(title: row.agent, action: #selector(rowClicked(_:)), keyEquivalent: "")
+ entry.target = self
+ entry.attributedTitle = rowTitle(row, tabStop: tabStop)
+ entry.toolTip = "\(row.status.rawValue) · \(row.target)"
+ entry.representedObject = row.paneId
+ menu.addItem(entry)
}
-
menu.addItem(.separator())
- let quit = NSMenuItem(title: "Quit dingger", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
- menu.addItem(quit)
+ menu.addItem(NSMenuItem(title: "Quit dingger", action: #selector(NSApplication.terminate(_:)),
+ keyEquivalent: "q"))
}
- /// 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
-
- func printPanesForDebug() {
- panes = fetchPanes()
- queue = fetchQueue()
- let rows = orderedPanes()
- if rows.isEmpty { print("(no agents)") }
- for pane in rows {
- print("\(pane.state) \(pane.agent) \(pane.target)")
+ @objc private func rowClicked(_ sender: NSMenuItem) {
+ guard let paneId = sender.representedObject as? String else { return }
+ DispatchQueue.global().async {
+ focus(paneId)
+ DispatchQueue.main.async { self.refreshTitle() }
}
}
+}
- @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 arguments = CommandLine.arguments
+if arguments.contains("--print") {
+ let rows = fetchRows()
+ print("title: \(plainTitle(for: rows))")
+ for row in rows {
+ print("\(row.status.symbol) \(row.status.rawValue) \(row.agent) \(row.target)")
}
+ exit(0)
+}
+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)
-
-// Debug: print the rows the menu would show and exit.
-if CommandLine.arguments.contains("--print") {
- delegate.printPanesForDebug()
- exit(0)
-}
-
app.run()