diff options
Diffstat (limited to 'menubar/main.swift')
| -rw-r--r-- | menubar/main.swift | 126 |
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() |
