aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-19 16:55:30 +0300
committerIgor Soarez <igor@soarez.org>2026-10-19 16:55:30 +0300
commitea1dda2f056fdfd56bffe8b978c0b0786b82aa7d (patch)
tree2a6451f2a956cdd2345d6a77a2db00e0fff1d376
parent9c6b10426bd897afe6cf6f0c022f45310c5ab3d3 (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.
-rw-r--r--crates/dinggerd/src/main.rs42
-rw-r--r--crates/dinggerd/src/tmux.rs46
-rw-r--r--crates/dinggerd/src/watcher.rs107
-rw-r--r--menubar/main.swift198
4 files changed, 333 insertions, 60 deletions
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index f046732..4c8521c 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -33,6 +33,12 @@ enum Command {
#[command(subcommand)]
command: ManifestCommand,
},
+ /// Switch to the agent that has been waiting the longest (FIFO). Bound to
+ /// a mouse click on the tmux status line by the packaged config.
+ Focus {
+ /// Name of the tmux client that clicked, passed as #{client_name}.
+ client: Option<String>,
+ },
}
#[derive(Subcommand)]
@@ -73,7 +79,43 @@ fn main() {
}
}
},
+ Command::Focus { client } => {
+ if let Err(err) = focus(client) {
+ eprintln!("dingger: {err}");
+ std::process::exit(1);
+ }
+ }
+ }
+}
+
+/// Jump to the head of the ready queue (oldest waiting agent) and ack it, so
+/// the next focus moves on to the following one.
+fn focus(client: Option<String>) -> Result<(), String> {
+ let queue = tmux::show_global("@dingger_queue")
+ .unwrap_or_default()
+ .split(',')
+ .filter(|entry| !entry.is_empty())
+ .map(|entry| entry.split(':').next().unwrap_or("").to_string())
+ .collect::<Vec<_>>();
+ let Some(pane_id) = queue.first() else {
+ return Ok(()); // nothing waiting
+ };
+ let target = tmux::show_pane_format(pane_id, "#{session_name}:#{window_index}.#{pane_index}")
+ .unwrap_or_default();
+ if target.is_empty() {
+ return Err("ready pane no longer exists".to_string());
+ }
+ tmux::ack(pane_id)?;
+ let mut args = vec!["switch-client".to_string()];
+ if let Some(client) = client {
+ args.push("-c".to_string());
+ args.push(client);
}
+ args.push("-t".to_string());
+ args.push(target);
+ tmux::run_tmux_client(
+ &args.iter().map(String::as_str).collect::<Vec<_>>(),
+ )
}
fn status() -> Result<(), String> {
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs
index edd44aa..cba1ca6 100644
--- a/crates/dinggerd/src/tmux.rs
+++ b/crates/dinggerd/src/tmux.rs
@@ -26,6 +26,9 @@ pub struct Pane {
pub written_state: String,
/// The `@agent` user option (agent label) previously written, if any.
pub written_agent: String,
+ /// The `@dingger_ack` user option: "1" when the user already jumped to
+ /// this pane in its current ready episode.
+ pub written_ack: String,
}
impl Pane {
@@ -70,7 +73,8 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
"#{pane_start_command}\t",
"#{pane_pid}\t",
"#{@agent_state}\t",
- "#{@agent}"
+ "#{@agent}\t",
+ "#{@dingger_ack}"
);
let out = run_tmux(&["list-panes", "-a", "-F", format])?;
let mut panes = Vec::new();
@@ -104,6 +108,7 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
agent,
written_state: fields[9].to_string(),
written_agent: fields[10].to_string(),
+ written_ack: fields.get(11).copied().unwrap_or("").to_string(),
});
}
Ok(panes)
@@ -166,6 +171,19 @@ fn fetch_process_tree() -> ProcessTree {
(children, comm)
}
+/// Run a tmux command whose effect is an action (e.g. switch-client), not
+/// output to parse.
+pub fn run_tmux_client(args: &[&str]) -> Result<(), String> {
+ run_tmux(args).map(|_| ())
+}
+
+/// Read a format expansion for one pane.
+pub fn show_pane_format(pane_id: &str, format: &str) -> Option<String> {
+ run_tmux(&["display-message", "-p", "-t", pane_id, format])
+ .ok()
+ .map(|value| value.trim().to_string())
+}
+
/// Capture the pane's visible screen as plain text โ€” the exact input shape
/// the detection manifests expect.
pub fn capture_pane(pane_id: &str) -> Result<String, String> {
@@ -187,6 +205,28 @@ pub fn set_agent_state(pane_id: &str, state: AgentState) -> Result<(), String> {
/// 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(|_| ())
+ let label = agent.map(detect_core::agent_label).unwrap_or("");
+ run_tmux(&["set-option", "-p", "-t", pane_id, "@agent", label]).map(|_| ())
+}
+
+/// Write a server-global option (readable from any client and from formats).
+pub fn set_global(name: &str, value: &str) -> Result<(), String> {
+ run_tmux(&["set-option", "-g", name, value]).map(|_| ())
+}
+
+pub fn show_global(name: &str) -> Option<String> {
+ run_tmux(&["show-option", "-gv", name])
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+}
+
+/// Mark a pane as seen by the user after `dingger focus`: taken out of the
+/// ready queue until its next state transition.
+pub fn ack(pane_id: &str) -> Result<(), String> {
+ run_tmux(&["set-option", "-p", "-t", pane_id, "@dingger_ack", "1"]).map(|_| ())
+}
+
+pub fn clear_ack(pane_id: &str) -> Result<(), String> {
+ run_tmux(&["set-option", "-p", "-t", pane_id, "-u", "@dingger_ack"]).map(|_| ())
}
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index f039456..9fd999d 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -9,14 +9,30 @@ use tracing::{debug, info, warn};
use crate::notify::{play_sound, send_notification, NotificationKind};
use crate::tmux;
+/// When a pane became ready for the user, as unix seconds. Ready means the
+/// agent is waiting on the user: idle at its prompt or blocked on input.
+fn is_ready(state: AgentState) -> bool {
+ matches!(state, AgentState::Idle | AgentState::Blocked)
+}
+
pub struct Watcher {
- /// Last published state per pane id. `status` reads the same values back
- /// from tmux's `@agent_state`, so no other state is needed.
- states: HashMap<String, AgentState>,
+ /// Published state plus the unix second it became ready, per pane id.
+ /// Published state plus the unix second it became ready, per pane id.
+ /// `status` reads the same values back from tmux's `@agent_state`, so no
+ /// other durable state is needed.
+ panes: HashMap<String, PaneTrack>,
+ /// Last (queue, counts) written to the global options; avoids rewriting
+ /// them every tick.
+ last_published: Option<(String, (String, String, String))>,
interval_secs: u64,
sounds: bool,
}
+struct PaneTrack {
+ state: AgentState,
+ ready_since: Option<u64>,
+}
+
/// herdr's notification policy (`notification_toast_for_state_change`).
pub fn notification_kind_for_transition(
prev_state: AgentState,
@@ -39,7 +55,8 @@ pub fn notification_kind_for_transition(
impl Watcher {
pub fn new(interval_secs: u64, sounds: bool) -> Self {
Self {
- states: HashMap::new(),
+ panes: HashMap::new(),
+ last_published: None,
interval_secs: interval_secs.max(1),
sounds,
}
@@ -74,14 +91,14 @@ impl Watcher {
let panes = tmux::list_panes()?;
// Forget panes that went away.
- self.states
+ self.panes
.retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id));
- for pane in panes {
+ for pane in &panes {
let prev_state = self
- .states
+ .panes
.get(&pane.pane_id)
- .copied()
+ .map(|track| track.state)
.unwrap_or(AgentState::Unknown);
// The detection result is an observation of live agent state.
@@ -130,12 +147,22 @@ impl Watcher {
if new_state == prev_state {
continue;
}
- self.states.insert(pane.pane_id.clone(), new_state);
+ let ready_since = is_ready(new_state).then(unix_now);
+ self.panes.insert(
+ pane.pane_id.clone(),
+ PaneTrack {
+ state: new_state,
+ ready_since,
+ },
+ );
debug!(target = %pane.target, ?prev_state, ?new_state, "state change");
if let Err(err) = tmux::set_agent_state(&pane.pane_id, new_state) {
warn!(target = %pane.target, %err, "failed to write @agent_state");
}
+ // A fresh transition re-enters the ready queue even if the user
+ // already jumped to this pane in a previous ready episode.
+ let _ = tmux::clear_ack(&pane.pane_id);
if let Some(kind) = notification_kind_for_transition(prev_state, new_state) {
@@ -151,8 +178,70 @@ impl Watcher {
}
}
+ self.publish_ready_queue(&panes);
Ok(())
}
+
+ /// Publish the FIFO ready queue (oldest first) as a server-global option;
+ /// `dingger focus` pops its head, and the tmux status line renders counts.
+ fn publish_ready_queue(&mut self, panes: &[tmux::Pane]) {
+ let mut ready: Vec<(&str, String, u64)> = Vec::new(); // (pane_id, target, ready_since)
+ let mut blocked = 0usize;
+ let mut working = 0usize;
+ for pane in panes {
+ let Some(track) = self.panes.get(&pane.pane_id) else {
+ continue;
+ };
+ match track.state {
+ AgentState::Working => working += 1,
+ AgentState::Idle | AgentState::Blocked => {
+ let acked = pane.written_ack == "1";
+ if !acked {
+ if let Some(since) = track.ready_since {
+ if track.state == AgentState::Blocked {
+ blocked += 1;
+ }
+ ready.push((pane.pane_id.as_str(), pane.target.clone(), since));
+ }
+ }
+ }
+ AgentState::Unknown => {}
+ }
+ }
+ ready.sort_by_key(|(_, _, since)| *since);
+
+ let queue = ready
+ .iter()
+ .map(|(pane_id, _, since)| format!("{pane_id}:{since}"))
+ .collect::<Vec<_>>()
+ .join(",");
+ let counts = (
+ ready.len().to_string(),
+ blocked.to_string(),
+ working.to_string(),
+ );
+ let next = (queue.clone(), counts.clone());
+ if self.last_published.as_ref() != Some(&next) {
+ self.last_published = Some(next);
+ for (name, value) in [
+ ("@dingger_queue", queue.as_str()),
+ ("@dingger_ready", counts.0.as_str()),
+ ("@dingger_blocked", counts.1.as_str()),
+ ("@dingger_working", counts.2.as_str()),
+ ] {
+ if let Err(err) = tmux::set_global(name, value) {
+ warn!(%err, "failed to write {name}");
+ }
+ }
+ }
+ }
+}
+
+fn unix_now() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
}
#[cfg(test)]
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()