aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/main.rs
blob: 285f83a112eadb32644ca0613f3c9bf91935f99b (plain)
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
//! dingger: agent state watching for tmux panes.

mod hooks;
mod integrations;
mod manifest_update;
mod names;
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,
        /// Disable macOS notifications (sounds still play unless --no-sounds).
        #[arg(long)]
        no_notifications: bool,
    },
    /// Print the current agent state of every tmux pane.
    Status,
    /// Refresh detection manifests from herdr's published catalog.
    Manifests {
        #[command(subcommand)]
        command: ManifestCommand,
    },
    /// 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 {
        /// 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.
        agent: 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,
            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, !no_notifications);
            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, 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}");
                std::process::exit(1);
            }
        }
    }
}

/// 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 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,
        }
    }
}

/// Every agent pane in herdr's priority order (blocked, done, working,
/// idle), oldest-waiting first within a status.
fn status() -> Result<(), String> {
    let mut panes: Vec<_> = tmux::list_panes()?
        .into_iter()
        .filter(|pane| pane.agent.is_some())
        .collect();
    if panes.is_empty() {
        println!("no agent panes");
        return Ok(());
    }
    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())
    });
    let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " · ", 1);
    let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5);
    println!("{:<8} {:<width$}  {:<7} NAME", "STATUS", "WHERE", "AGENT");
    for pane in &panes {
        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} {:<width$}  {agent:<7} {}", place(pane), pane.written_name);
    }
    Ok(())
}