diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-19 16:55:30 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-19 16:55:30 +0300 |
| commit | ea1dda2f056fdfd56bffe8b978c0b0786b82aa7d (patch) | |
| tree | 2a6451f2a956cdd2345d6a77a2db00e0fff1d376 /menubar/main.swift | |
| parent | 9c6b10426bd897afe6cf6f0c022f45310c5ab3d3 (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/main.swift')
| -rw-r--r-- | menubar/main.swift | 198 |
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() |
