//! dingger: agent state watching for tmux panes. mod manifest_update; mod notify; mod tmux; mod watcher; use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; #[derive(Parser)] #[command(name = "dingger", about = "Agent state watching for tmux panes")] struct Cli { #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { /// Watch all tmux panes and notify on agent state transitions. Watch { /// Poll interval in seconds. #[arg(long, default_value_t = 1)] interval_secs: u64, /// Disable notification sounds. #[arg(long)] no_sounds: bool, }, /// Print the current agent state of every tmux pane. Status, /// Refresh detection manifests from herdr's published catalog. 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, }, } #[derive(Subcommand)] enum ManifestCommand { /// Download newer manifests from herdr's published catalog into the /// local remote store; bundled manifests are the offline fallback. /// Restart `dingger watch` to pick them up. Update, } fn main() { tracing_subscriber::fmt() .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .init(); match Cli::parse().command { Command::Watch { interval_secs, no_sounds, } => { let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds); if let Err(err) = watcher.run() { eprintln!("dingger: {err}"); std::process::exit(1); } } Command::Status => { if let Err(err) = status() { eprintln!("dingger: {err}"); std::process::exit(1); } } Command::Manifests { command } => match command { ManifestCommand::Update => { if let Err(err) = manifest_update::update() { eprintln!("dingger: {err}"); 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) -> 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::>(); 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::>(), ) } fn status() -> Result<(), String> { let panes = tmux::list_panes()?; if panes.is_empty() { println!("no tmux panes"); return Ok(()); } println!("{:<22} {:<10} {:<12} COMMAND", "TARGET", "AGENT", "STATE"); for pane in panes { println!( "{:<22} {:<10} {:<12} {}", pane.target, pane.agent.map(detect_core::agent_label).unwrap_or("-"), if pane.written_state.is_empty() { "-" } else { &pane.written_state }, pane.current_command, ); } Ok(()) }