aboutsummaryrefslogtreecommitdiff
path: root/menubar
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-19 16:55:30 +0300
committerIgor Soarez <igor@soarez.org>2026-10-19 16:55:30 +0300
commitea1dda2f056fdfd56bffe8b978c0b0786b82aa7d (patch)
tree2a6451f2a956cdd2345d6a77a2db00e0fff1d376 /menubar
parent9c6b10426bd897afe6cf6f0c022f45310c5ab3d3 (diff)
ready-queue fifo with click-to-focus; redesign presentation per design panel
daemon tracks when each agent became ready (idle/blocked, unacked) and publishes fifo queue + numeric counts as server-global options; unknown to idle transitions stay out of the queue (restart spam). new dingger focus subcommand jumps to the longest-waiting pane and acks it, bound to status-right clicks in tmux. menu bar redesigned per two-model design panel: monochrome sf-symbol bell + queue depth, queue-ordered rows with state glyphs and right-aligned targets, row clicks ack like focus does. tmux segment renders glyph+count with a single red accent for blocked; fixes unescaped comma in the conditional format that truncated the segment entirely.
Diffstat (limited to 'menubar')
-rw-r--r--menubar/main.swift198
1 files changed, 150 insertions, 48 deletions
diff --git a/menubar/main.swift b/menubar/main.swift
index e186f93..71bba46 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -1,57 +1,55 @@
// 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 emoji(for state: String) -> String {
- switch state {
- case "blocked": return "๐Ÿ”ด"
- case "working": return "๐ŸŸก"
- case "idle": return "๐ŸŸข"
- default: return "โšช๏ธ"
+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 {
+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?.title = "โšช๏ธ"
+ 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?.refresh()
+ self?.refreshTitle()
}
RunLoop.main.add(timer, forMode: .common)
- refresh()
+ refreshTitle()
}
- private func refresh() {
- panes = fetchPanes()
- redrawTitle()
- rebuildMenu()
- }
-
- private func fetchPanes() -> [PaneRow] {
- let fmt = "#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{@agent}\t#{@agent_state}"
- 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 >= 4 else { return nil }
- let agent = String(fields[2])
- let state = fields[3].isEmpty ? "unknown" : String(fields[3])
- guard !agent.isEmpty || !fields[3].isEmpty else { return nil } // untracked shell
- return PaneRow(target: String(fields[1]), agent: agent, state: state)
- }
- }
+ // MARK: data
private func runTmux(_ args: [String]) -> String? {
let process = Process()
@@ -71,45 +69,149 @@ final class Menubar: NSObject, NSApplicationDelegate {
return String(data: data, encoding: .utf8)
}
- private func redrawTitle() {
- let blocked = panes.filter { $0.state == "blocked" }.count
+ 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 idle = panes.filter { $0.state == "idle" }.count
- var parts: [String] = []
- if blocked > 0 { parts.append("๐Ÿ”ด\(blocked)") }
- if working > 0 { parts.append("๐ŸŸก\(working)") }
- if parts.isEmpty {
- item.button?.title = idle > 0 ? "๐ŸŸข\(idle)" : "โšช๏ธ"
+
+ 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 {
- item.button?.title = parts.joined(separator: " ")
+ (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()
- menu.addItem(NSMenuItem(title: "dingger", action: nil, keyEquivalent: ""))
+
+ // 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 running", action: nil, keyEquivalent: "")
+ let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "")
none.isEnabled = false
menu.addItem(none)
}
- for pane in panes.sorted(by: { $0.target < $1.target }) {
- let label = "\(emoji(for: pane.state)) \(pane.agent) \(pane.target) \(pane.state)"
- let item = NSMenuItem(title: label, action: #selector(paneClicked(_:)), keyEquivalent: "")
- item.target = self
- item.representedObject = pane.target
- menu.addItem(item)
+ 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())
- menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
+ 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 target = sender.representedObject as? String else { return }
- _ = runTmux(["switch-client", "-t", target])
+ 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()