all repos — dingger @ ea1dda2f056fdfd56bffe8b978c0b0786b82aa7d

Watches the AI coding agents in your tmux panes on macOS and tells you which need you, from the menu bar, a floating panel, the status line and notifications; jumps to them, and restores tmux sessions and agents after a crash

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.
Igor Soarez igor@soarez.org
Mon, 19 Oct 2026 16:55:30 +0300
commit

ea1dda2f056fdfd56bffe8b978c0b0786b82aa7d

parent

9c6b10426bd897afe6cf6f0c022f45310c5ab3d3

M crates/dinggerd/src/main.rs → crates/dinggerd/src/main.rs

@@ -33,6 +33,12 @@ Manifests {

#[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 @@ std::process::exit(1);

} } }, + 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> {
M crates/dinggerd/src/tmux.rs → crates/dinggerd/src/tmux.rs

@@ -26,6 +26,9 @@ /// read straight from the pane format.

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 @@ "#{session_attached}\t",

"#{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 @@ session_attached: fields[6] != "0",

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 @@ }

(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 @@

/// 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(|_| ()) }
M crates/dinggerd/src/watcher.rs → crates/dinggerd/src/watcher.rs

@@ -9,12 +9,28 @@

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`).

@@ -39,7 +55,8 @@

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 @@ fn tick(&mut self) -> Result<(), String> {

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 @@

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 @@ }

} } + 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)]