aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/main.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src/main.rs')
-rw-r--r--crates/dinggerd/src/main.rs144
1 files changed, 98 insertions, 46 deletions
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index 7ef601f..f458e18 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -27,6 +27,9 @@ enum Command {
/// Disable notification sounds.
#[arg(long)]
no_sounds: bool,
+ /// Disable macOS notifications (sounds still play unless --no-sounds).
+ #[arg(long)]
+ no_notifications: bool,
},
/// Print the current agent state of every tmux pane.
Status,
@@ -35,12 +38,19 @@ 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.
+ /// Show an agent pane and mark it attended. Without --pane, jumps to the
+ /// agent that has waited longest (FIFO), skipping the window the client
+ /// already shows. Used by the tmux status-line click and the menu bar.
Focus {
- /// Name of the tmux client that clicked, passed as #{client_name}.
+ /// tmux client to switch (default: the most recently active one).
+ #[arg(long)]
client: Option<String>,
+ /// Pane to show (default: the head of the ready queue).
+ #[arg(long)]
+ pane: Option<String>,
},
+ /// Wake the watcher so focus changes show up immediately (tmux hooks).
+ Poke,
/// Install agent integrations (hook extensions that report state).
Install {
/// Agent to install the integration for.
@@ -65,12 +75,13 @@ fn main() {
Command::Watch {
interval_secs,
no_sounds,
+ no_notifications,
} => {
if let Err(err) = hooks::spawn_listener() {
eprintln!("dingger: {err}");
std::process::exit(1);
}
- let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds);
+ let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds, !no_notifications);
if let Err(err) = watcher.run() {
eprintln!("dingger: {err}");
std::process::exit(1);
@@ -90,12 +101,16 @@ fn main() {
}
}
},
- Command::Focus { client } => {
- if let Err(err) = focus(client) {
+ Command::Focus { client, pane } => {
+ if let Err(err) = focus(client, pane) {
eprintln!("dingger: {err}");
std::process::exit(1);
}
}
+ Command::Poke => {
+ // Best effort: a stopped watcher just means nothing to wake.
+ let _ = hooks::poke();
+ }
Command::Install { agent } => {
if let Err(err) = integrations::install(&agent) {
eprintln!("dingger: {err}");
@@ -105,55 +120,92 @@ fn main() {
}
}
-/// 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
+/// Show a pane in a client and mark it attended (`@dingger_ack`, consumed
+/// by the watcher on its next tick). This is the single code path behind
+/// every click: the tmux status line and the menu bar rows.
+fn focus(client: Option<String>, pane: Option<String>) -> Result<(), String> {
+ let clients = tmux::clients();
+ // The named client if it is attached; otherwise the most recently active
+ // one (a click on the status line makes the clicking client the most
+ // recent, so this is right even if the name did not expand).
+ let chosen = client
+ .and_then(|name| clients.iter().find(|(n, _)| *n == name).cloned())
+ .or_else(|| clients.first().cloned());
+ let Some((client, client_pid)) = chosen else {
+ return Err("no tmux client attached".to_string());
};
- 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);
+
+ let pane_id = match pane {
+ Some(pane) => pane,
+ None => {
+ let current_window = tmux::client_format(&client, "#{window_id}");
+ let queue = tmux::show_global("@dingger_queue").unwrap_or_default();
+ let next = queue.split(',').filter(|id| !id.is_empty()).find(|id| {
+ tmux::pane_format(id, "#{window_id}").as_deref() != current_window.as_deref()
+ });
+ match next {
+ Some(id) => id.to_string(),
+ None => return Ok(()), // nothing waiting elsewhere
+ }
+ }
+ };
+
+ tmux::set_pane_option(&pane_id, "@dingger_ack", "1")?;
+ tmux::show_pane(&client, &pane_id)?;
+ let _ = hooks::poke();
+ activate_terminal_app(client_pid);
+ Ok(())
+}
+
+/// Bring the terminal app hosting a tmux client to the front: walk the
+/// client's process ancestry up to the first `*.app` bundle (Ghostty,
+/// Alacritty, ...) and `open -a` it.
+fn activate_terminal_app(client_pid: u32) {
+ let mut pid = client_pid;
+ for _ in 0..16 {
+ let Ok(out) = std::process::Command::new("ps")
+ .args(["-o", "ppid=,comm=", "-p", &pid.to_string()])
+ .output()
+ else {
+ return;
+ };
+ let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
+ let Some((ppid, comm)) = line.split_once(char::is_whitespace) else {
+ return;
+ };
+ if let Some(end) = comm.find(".app/") {
+ let bundle = &comm[..end + 4];
+ let _ = std::process::Command::new("open").args(["-a", bundle]).status();
+ return;
+ }
+ match ppid.trim().parse::<u32>() {
+ Ok(parent) if parent > 1 => pid = parent,
+ _ => return,
+ }
}
- args.push("-t".to_string());
- args.push(target);
- tmux::run_tmux_client(
- &args.iter().map(String::as_str).collect::<Vec<_>>(),
- )
}
+/// Every agent pane in herdr's priority order (blocked, done, working,
+/// idle), oldest-waiting first within a status.
fn status() -> Result<(), String> {
- let panes = tmux::list_panes()?;
+ let mut panes: Vec<_> = tmux::list_panes()?
+ .into_iter()
+ .filter(|pane| pane.agent.is_some())
+ .collect();
if panes.is_empty() {
- println!("no tmux panes");
+ println!("no agent panes");
return Ok(());
}
- println!("{:<22} {:<10} {:<12} COMMAND", "TARGET", "AGENT", "STATE");
+ panes.sort_by_key(|pane| {
+ let status = watcher::Status::from_label(&pane.written_state);
+ let since = pane.written_since.parse::<u64>().unwrap_or(u64::MAX);
+ (std::cmp::Reverse(status.priority()), since, pane.display_target.clone())
+ });
+ println!("{:<8} {:<10} TARGET", "STATUS", "AGENT");
for pane in panes {
- println!(
- "{:<22} {:<10} {:<12} {}",
- pane.display_target,
- pane.agent.map(detect_core::agent_label).unwrap_or("-"),
- if pane.written_state.is_empty() {
- "-"
- } else {
- &pane.written_state
- },
- pane.current_command,
- );
+ let state = if pane.written_state.is_empty() { "-" } else { &pane.written_state };
+ let agent = pane.agent.map(detect_core::agent_label).unwrap_or("-");
+ println!("{state:<8} {agent:<10} {}", pane.display_target);
}
Ok(())
}