diff options
Diffstat (limited to 'crates/dinggerd/src/main.rs')
| -rw-r--r-- | crates/dinggerd/src/main.rs | 144 |
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(()) } |
