aboutsummaryrefslogtreecommitdiff
path: root/menubar
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-20 11:53:23 +0300
committerIgor Soarez <igor@soarez.org>2026-10-20 11:53:23 +0300
commit3509e48c5776c4153d8f90aff83a796c6d383208 (patch)
treeb19a75889a12f7c8ae828f61b8d70130e6ebe968 /menubar
parentfed066bc3908d18ede5490130975203a56c48fc2 (diff)
adopt herdr's five-status model with symbols style and focus detection
statuses are now herdr's verbatim: working (yellow), blocked (red), done (teal, finished and unattended), idle (green, attended since its last transition), unknown (gray) - priority blocked > done > working > idle > unknown. the daemon watches tmux focus (pane_active of attached clients) to mark panes attended, which is what separates done from idle. menu bar renders symbols-style glyphs with herdr's colors in priority order; the tmux segment stays after the clock and renders done/blocked/ working counts from numeric options. deployment note: do not strip the binary - it invalidates the ad-hoc signature and macOS kills it on launch.
Diffstat (limited to 'menubar')
-rw-r--r--menubar/main.swift145
1 files changed, 77 insertions, 68 deletions
diff --git a/menubar/main.swift b/menubar/main.swift
index 6c6c9a3..d0a3b30 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -1,9 +1,10 @@
// dingger menu bar: live agent states from tmux pane user options
// (@agent / @agent_state), written by the dingger daemon.
//
-// Visual language is herdr's agent panel: green = ready (finished, waiting
-// for you), yellow = working, red = blocked; filled glyph = in the ready
-// queue (unseen), outline = already seen. Priority order = queue order.
+// 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).
import AppKit
@@ -16,8 +17,9 @@ struct PaneRow {
let red = NSColor.systemRed
let yellow = NSColor.systemYellow
+let teal = NSColor.systemTeal
let green = NSColor.systemGreen
-let dim = NSColor.systemGray
+let gray = NSColor.systemGray
/// Tint a symbol image (non-template, so it keeps its color in menus).
func tinted(_ image: NSImage?, _ color: NSColor) -> NSImage? {
@@ -38,13 +40,24 @@ func symbol(_ name: String, _ color: NSColor? = nil) -> NSImage? {
return color.map { tinted(image, $0) } ?? image
}
-func rowSymbol(_ state: String, queued: Bool) -> NSImage? {
- switch (state, queued) {
- case ("blocked", true): return symbol("exclamationmark.circle.fill", red)
- case ("idle", true): return symbol("checkmark.circle.fill", green)
- case ("working", _): return symbol("circle.dotted", yellow)
- case ("blocked", _): return symbol("exclamationmark.circle", dim)
- default: return symbol("checkmark.circle", dim)
+/// 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)
+ }
+}
+
+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
}
}
@@ -56,7 +69,7 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
func applicationDidFinishLaunching(_: Notification) {
NSApp.setActivationPolicy(.accessory) // no Dock icon
- item.button?.image = symbol("bell", dim)
+ item.button?.image = symbol("bell", gray)
item.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
item.menu = menu
menu.delegate = self
@@ -88,22 +101,21 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
private func fetchPanes() -> [PaneRow] {
- let fmt = "#{pane_id}\t#{@agent}\t#{@agent_state}\t#{@dingger_ack}\t#{session_name}\t#{window_name}\t#{window_panes}\t#{pane_index}"
+ 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 >= 8 else { return nil }
+ guard fields.count >= 7 else { return nil }
let agent = String(fields[1])
let state = fields[2].isEmpty ? "unknown" : String(fields[2])
- // Only tracked panes with a live state belong in the menu.
- guard !agent.isEmpty, ["idle", "working", "blocked"].contains(state) else {
+ guard !agent.isEmpty, ["working", "blocked", "done", "idle"].contains(state) else {
return nil
}
- // Display target: session:window-name, with the pane index only
- // when the window is split. (Switching uses the pane id.)
- var target = "\(fields[4]):\(fields[5])"
- if Int(fields[6]) ?? 1 > 1 {
- target += ".\(fields[7])"
+ // 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)
}
@@ -115,6 +127,23 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
.split(separator: ",").map { $0.split(separator: ":").first.map(String.init) ?? "" }
}
+ 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
+ }
+ return rows
+ }
+
// MARK: title (timer-driven; never touches the open menu)
private func refreshTitle() {
@@ -122,28 +151,32 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
queue = fetchQueue()
FileHandle.standardError.write(Data(
"\(Date()) menubar: panes=\(panes.count) queue=\(queue.count)\n".utf8))
- let queued = panes.filter { row in queue.contains(row.paneId) }
- let blocked = queued.filter { $0.state == "blocked" }.count
+ redrawTitle()
+ }
+
+ 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
- let (name, color, count): (String, NSColor, String)
+ let (image, count): (NSImage?, String)
if blocked > 0 {
- (name, color, count) = ("bell.fill", red, String(queued.count))
- } else if !queued.isEmpty {
- (name, color, count) = ("bell.fill", green, String(queued.count))
+ (image, count) = (symbol("bell.fill", red), String(blocked))
+ } else if done > 0 {
+ (image, count) = (symbol("bell.fill", teal), String(done))
} else if working > 0 {
- (name, color, count) = ("bell", yellow, "")
+ (image, count) = (symbol("bell", yellow), "")
} else {
- (name, color, count) = ("bell", dim, "")
+ (image, count) = (symbol("bell", gray), "")
}
- item.button?.image = symbol(name, color)
+ item.button?.image = image
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 done > 0 { tip.append("\(done) done") }
if working > 0 { tip.append("\(working) working") }
- let tipText = tip.isEmpty ? "Nothing waiting" : tip.joined(separator: " · ")
+ let tipText = tip.isEmpty ? "No agents" : tip.joined(separator: " · ")
item.button?.toolTip = tipText
item.button?.setAccessibilityLabel("dingger, " + tipText)
}
@@ -157,39 +190,25 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
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 rows = orderedPanes()
+ if rows.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 {
+ var previousPriority: Int?
+ for pane in rows {
+ let priority = statusPriority(pane.state)
+ if let previous = previousPriority, previous != priority {
menu.addItem(.separator())
}
- previousWaiting = waiting
+ previousPriority = priority
let row = NSMenuItem(title: "\(pane.agent)\t\(pane.target)",
action: #selector(paneClicked(_:)), keyEquivalent: "")
row.target = self
- row.image = rowSymbol(pane.state, queued: queued)
+ row.image = statusSymbol(pane.state)
row.attributedTitle = attributedRow(agent: pane.agent, target: pane.target)
row.toolTip = "\(pane.state) — \(pane.target)"
row.representedObject = pane.paneId
@@ -228,22 +247,12 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
// MARK: actions
func printPanesForDebug() {
- let rows = fetchPanes()
- let queue = fetchQueue()
+ panes = fetchPanes()
+ queue = fetchQueue()
+ let rows = orderedPanes()
if rows.isEmpty { print("(no agents)") }
for pane in rows {
- let queued = queue.contains(pane.paneId)
- print("\(emojilessState(pane.state, queued: queued)) \(pane.agent) \(pane.target)")
- }
- }
-
- private func emojilessState(_ state: String, queued: Bool) -> String {
- switch (state, queued) {
- case ("blocked", true): return "[blocked!]"
- case ("idle", true): return "[waiting]"
- case ("working", _): return "[working]"
- case ("blocked", _): return "[blocked]"
- default: return "[idle]"
+ print("\(pane.state) \(pane.agent) \(pane.target)")
}
}