diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-20 12:43:07 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-20 12:43:07 +0300 |
| commit | 4af6a4f625b1965b783c9efb857f8ac665adb8eb (patch) | |
| tree | dd2dac9627735fd02b3372a4f569125a702ba57a /menubar | |
| parent | 943fc940d9c387916708cb3fdc912323c92ef6dc (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.swift | 440 |
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() |
