aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-19 16:35:40 +0300
committerIgor Soarez <igor@soarez.org>2026-10-19 16:35:40 +0300
commit9c6b10426bd897afe6cf6f0c022f45310c5ab3d3 (patch)
tree1002c7fe76bfd2980773b1f0826b6452e5dc99ad
parent3bace4ac392fb914263377369383f5ac23111c61 (diff)
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.
-rw-r--r--crates/dinggerd/src/tmux.rs16
-rw-r--r--crates/dinggerd/src/watcher.rs20
-rw-r--r--menubar/main.swift126
-rw-r--r--packaging/launchd/dev.dingger.menubar.plist18
4 files changed, 172 insertions, 8 deletions
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<Vec<Pane>, 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<Vec<Pane>, 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<Agent>) -> 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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>dev.dingger.menubar</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/Users/sz/Applications/dingger.app/Contents/MacOS/dingger-menu</string>
+ </array>
+ <key>RunAtLoad</key>
+ <true/>
+ <key>KeepAlive</key>
+ <true/>
+ <key>ThrottleInterval</key>
+ <integer>10</integer>
+</dict>
+</plist>