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

crates/dinggerd/src/main.rs (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
//! 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<String>,
    },
}

#[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<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> {
    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(())
}