aboutsummaryrefslogtreecommitdiff
path: root/menubar/main.swift
diff options
context:
space:
mode:
Diffstat (limited to 'menubar/main.swift')
-rw-r--r--menubar/main.swift126
1 files changed, 126 insertions, 0 deletions
diff --git a/menubar/main.swift b/menubar/main.swift
new file mode 100644
index 0000000..e186f93
--- /dev/null
+++ b/menubar/main.swift
@@ -0,0 +1,126 @@
+// dingger menu bar: live agent states from tmux pane user options
+// (@agent / @agent_state), written by the dingger daemon.
+
+import AppKit
+
+struct PaneRow {
+ 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 "⚪️"
+ }
+}
+
+final class Menubar: NSObject, NSApplicationDelegate {
+ private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ private let menu = NSMenu()
+ private var panes: [PaneRow] = []
+
+ func applicationDidFinishLaunching(_: Notification) {
+ NSApp.setActivationPolicy(.accessory) // no Dock icon
+ item.button?.title = "⚪️"
+ item.menu = menu
+ let timer = Timer(timeInterval: 2.0, repeats: true) { [weak self] _ in
+ self?.refresh()
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ refresh()
+ }
+
+ 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)
+ }
+ }
+
+ 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)
+ }
+
+ private func redrawTitle() {
+ let blocked = panes.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)" : "⚪️"
+ } else {
+ item.button?.title = parts.joined(separator: " ")
+ }
+ }
+
+ private func rebuildMenu() {
+ menu.removeAllItems()
+ menu.addItem(NSMenuItem(title: "dingger", action: nil, keyEquivalent: ""))
+
+ if panes.isEmpty {
+ let none = NSMenuItem(title: "no agents running", 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)
+ }
+
+ menu.addItem(.separator())
+ menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
+ }
+
+ @objc private func paneClicked(_ sender: NSMenuItem) {
+ guard let target = sender.representedObject as? String else { return }
+ _ = runTmux(["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 app = NSApplication.shared
+let delegate = Menubar()
+app.delegate = delegate
+app.setActivationPolicy(.accessory)
+app.run()