From 9c6b10426bd897afe6cf6f0c022f45310c5ab3d3 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Mon, 19 Oct 2026 16:35:40 +0300 Subject: menu bar app: live agent states from tmux pane options single-file appkit status item (menubar/main.swift, LSUIElement) polling @agent/@agent_state pane options every 2s: counts in the bar, per-pane rows with click-to-focus via switch-client. daemon now also writes an @agent label per pane so the menu bar and tmux status-right can render agent names without duplicating identification logic. --- crates/dinggerd/src/tmux.rs | 16 +++- crates/dinggerd/src/watcher.rs | 20 +++-- menubar/main.swift | 126 ++++++++++++++++++++++++++++ packaging/launchd/dev.dingger.menubar.plist | 18 ++++ 4 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 menubar/main.swift create mode 100644 packaging/launchd/dev.dingger.menubar.plist diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index e833e3c..edd44aa 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -4,7 +4,6 @@ use std::process::Command; use detect_core::manifest::agent_state_label; use detect_core::{identify_agent, Agent, AgentState}; -use tracing::debug; #[derive(Debug, Clone)] pub struct Pane { @@ -25,6 +24,8 @@ pub struct Pane { /// The `@agent_state` user option previously written for this pane, /// read straight from the pane format. pub written_state: String, + /// The `@agent` user option (agent label) previously written, if any. + pub written_agent: String, } impl Pane { @@ -68,13 +69,14 @@ pub fn list_panes() -> Result, String> { "#{session_attached}\t", "#{pane_start_command}\t", "#{pane_pid}\t", - "#{@agent_state}" + "#{@agent_state}\t", + "#{@agent}" ); let out = run_tmux(&["list-panes", "-a", "-F", format])?; let mut panes = Vec::new(); for line in out.lines() { let fields: Vec<&str> = line.split('\t').collect(); - if fields.len() < 10 { + if fields.len() < 11 { continue; } let current_command = fields[2].to_string(); @@ -101,6 +103,7 @@ pub fn list_panes() -> Result, String> { session_attached: fields[6] != "0", agent, written_state: fields[9].to_string(), + written_agent: fields[10].to_string(), }); } Ok(panes) @@ -180,3 +183,10 @@ pub fn set_agent_state(pane_id: &str, state: AgentState) -> Result<(), String> { ]) .map(|_| ()) } + +/// Label the pane with the agent running in it, for tmux status-line and +/// menu-bar rendering; clears the label when the pane stops running one. +pub fn set_agent(pane_id: &str, agent: Option) -> Result<(), String> { + let label = agent.map(detect_core::agent_label).unwrap_or(""); + run_tmux(&["set-option", "-p", "-t", pane_id, "@agent", label]).map(|_| ()) +} diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 56107d6..f039456 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -113,6 +113,20 @@ impl Watcher { debug!(target = %pane.target, "observation skipped (viewer screen)"); continue; }; + + // Keep the pane's agent label current even when the state is + // unchanged (e.g. an agent swap between two idle panes). + let agent_label = pane + .agent + .map(detect_core::agent_label) + .unwrap_or("") + .to_string(); + if pane.written_agent != agent_label { + if let Err(err) = tmux::set_agent(&pane.pane_id, pane.agent) { + warn!(target = %pane.target, %err, "failed to write @agent"); + } + } + if new_state == prev_state { continue; } @@ -123,11 +137,7 @@ impl Watcher { warn!(target = %pane.target, %err, "failed to write @agent_state"); } - let agent_label = pane - .agent - .map(detect_core::agent_label) - .unwrap_or("agent") - .to_string(); + if let Some(kind) = notification_kind_for_transition(prev_state, new_state) { if pane.suppresses_notifications() { debug!(target = %pane.target, "suppressed (active pane)"); 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() diff --git a/packaging/launchd/dev.dingger.menubar.plist b/packaging/launchd/dev.dingger.menubar.plist new file mode 100644 index 0000000..c0b5b50 --- /dev/null +++ b/packaging/launchd/dev.dingger.menubar.plist @@ -0,0 +1,18 @@ + + + + + Label + dev.dingger.menubar + ProgramArguments + + /Users/sz/Applications/dingger.app/Contents/MacOS/dingger-menu + + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + + -- cgit v1.2.3