diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-20 12:43:07 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-20 12:43:07 +0300 |
| commit | 4af6a4f625b1965b783c9efb857f8ac665adb8eb (patch) | |
| tree | dd2dac9627735fd02b3372a4f569125a702ba57a /crates/dinggerd/src/main.rs | |
| parent | 943fc940d9c387916708cb3fdc912323c92ef6dc (diff) | |
rebuild the status model as herdr's state machine, event-driven and tested
the seen logic was inverted: first observations were marked done and real
completions idle, so the menu showed teal for agents that did nothing and
green for agents that finished, and attending changed nothing visible.
- watcher::advance is herdr's apply_pane_state_change + mark_active_tab_seen
as a pure function, unit-tested against herdr's own cases; attendance is
window-level like herdr's active tab
- hook reports are events applied in order, so a turn shorter than a poll is
still a completion
- event-driven: hook reports, dingger focus, and tmux focus hooks wake the
watcher; notifications are delivered off-thread (the macos call blocked
ticks for 2+ seconds) after the pane options are written
- all per-pane state persists in tmux pane options, so restarts and deploys
lose nothing, including the authoritative hook state
- every click goes through dingger focus: the menu bar shells out to it and
the status-line binding runs it; it picks the right client, skips the
window already shown, and activates the terminal app hosting the client
- the ready queue is ordered like the menu (blocked first, then oldest done)
- menu bar and tmux segment render herdr's symbols in herdr's palette
- tests/e2e.sh: 60 assertions against an isolated tmux server with a real
client, a 30s poll interval, and a slow fake notifier
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(()) } |
