diff options
Diffstat (limited to 'crates/dinggerd/src/tmux.rs')
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 177 |
1 files changed, 177 insertions, 0 deletions
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs new file mode 100644 index 0000000..04a6c77 --- /dev/null +++ b/crates/dinggerd/src/tmux.rs @@ -0,0 +1,177 @@ +//! tmux IPC wrapper. Everything dingger knows comes from these commands. + +use std::process::Command; + +use detect_core::manifest::agent_state_label; +use detect_core::{identify_agent, Agent, AgentState}; + +#[derive(Debug, Clone)] +pub struct Pane { + /// Unique tmux pane id, e.g. `%3`. Stable for the pane's lifetime. + pub pane_id: String, + /// User-facing target, e.g. `main:1.0`. + pub target: String, + /// Foreground command name in the pane, from `#{pane_current_command}`. + pub current_command: String, + /// Last OSC title the pane's application set, from `#{pane_title}`. + pub pane_title: String, + pub dead: bool, + /// The pane's window is the active window of its session. + pub window_active: bool, + /// The pane's session has at least one attached client. + pub session_attached: bool, + pub agent: Option<Agent>, + /// The `@agent_state` user option previously written for this pane, + /// read straight from the pane format. + pub written_state: String, +} + +impl Pane { + /// Panes the user is actively looking at suppress notifications, mirroring + /// herdr's active-tab suppression. + pub fn suppresses_notifications(&self) -> bool { + self.window_active && self.session_attached + } +} + +fn run_tmux(args: &[&str]) -> Result<String, String> { + let mut command = Command::new("tmux"); + // -L selects a named server socket; lets tests and multi-server setups + // point dingger at a specific server. + if let Some(socket) = std::env::var_os("DINGGER_TMUX_SOCKET") { + command.arg("-L").arg(socket); + } + let output = command + .args(args) + .output() + .map_err(|err| format!("failed to run tmux: {err}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("tmux {} failed: {}", args.join(" "), stderr.trim())); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +pub fn list_panes() -> Result<Vec<Pane>, String> { + let format = concat!( + "#{pane_id}\t", + "#{session_name}:#{window_index}.#{pane_index}\t", + "#{pane_current_command}\t", + "#{pane_title}\t", + "#{pane_dead}\t", + "#{window_active}\t", + "#{session_attached}\t", + "#{pane_start_command}\t", + "#{pane_pid}\t", + "#{@agent_state}" + ); + let out = run_tmux(&["list-panes", "-a", "-F", format])?; + let mut panes = Vec::new(); + for line in out.lines() { + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() < 10 { + continue; + } + let current_command = fields[2].to_string(); + let start_command = fields[7].to_string(); + let pane_pid: Option<u32> = fields[8].trim().parse().ok(); + // An agent may be started via a wrapper or still be initializing + // behind the shell; `pane_start_command` covers launch-command cases. + let mut agent = + identify_agent(¤t_command).or_else(|| identify_agent(&start_command)); + // Some agents rename their process (claude sets its title to the + // version string), so `pane_current_command` alone is not enough. + let mut process_tree = None; + if agent.is_none() { + let tree = process_tree.get_or_insert_with(fetch_process_tree); + agent = resolve_agent_in_process_tree(pane_pid, tree); + } + panes.push(Pane { + pane_id: fields[0].to_string(), + target: fields[1].to_string(), + current_command, + pane_title: fields[3].to_string(), + dead: fields[4] == "1", + window_active: fields[5] == "1", + session_attached: fields[6] != "0", + agent, + written_state: fields[9].to_string(), + }); + } + Ok(panes) +} + +type ProcessTree = (std::collections::HashMap<u32, Vec<u32>>, std::collections::HashMap<u32, String>); + +/// Identify the pane's agent by walking its process tree. Some agents rename +/// their process (claude sets its title to the version string), so the kernel +/// name in `pane_current_command` is not enough; `ps` still shows the real +/// binary path. One `ps` call per `list_panes` covers all panes. +fn resolve_agent_in_process_tree(pane_pid: Option<u32>, tree: &ProcessTree) -> Option<Agent> { + let pane_pid = pane_pid?; + let (children, comm) = tree; + + // BFS from the pane's shell. + let mut queue = std::collections::VecDeque::from([pane_pid]); + while let Some(pid) = queue.pop_front() { + if let Some(name) = comm.get(&pid) { + if let Some(agent) = identify_agent(name) { + return Some(agent); + } + } + if let Some(child_ids) = children.get(&pid) { + for child in child_ids { + queue.push_back(*child); + } + } + } + None +} + +fn fetch_process_tree() -> ProcessTree { + let Ok(output) = Command::new("ps").args(["-axo", "pid=,ppid=,comm="]).output() else { + return Default::default(); + }; + if !output.status.success() { + return Default::default(); + } + let mut children: std::collections::HashMap<u32, Vec<u32>> = + std::collections::HashMap::new(); + let mut comm: std::collections::HashMap<u32, String> = std::collections::HashMap::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + // Columns are whitespace-aligned, so runs of spaces separate pid, + // ppid, and the command path; the command itself may contain spaces. + let Some((pid, rest)) = line.trim_start().split_once(char::is_whitespace) else { + continue; + }; + let Some((ppid, name)) = rest.trim_start().split_once(char::is_whitespace) else { + continue; + }; + let name = name.trim_start(); + let (Ok(pid), Ok(ppid)) = (pid.parse::<u32>(), ppid.parse::<u32>()) else { + continue; + }; + children.entry(ppid).or_default().push(pid); + let basename = name.rsplit('/').next().unwrap_or(name); + comm.insert(pid, basename.to_string()); + } + (children, comm) +} + +/// Capture the pane's visible screen as plain text — the exact input shape +/// the detection manifests expect. +pub fn capture_pane(pane_id: &str) -> Result<String, String> { + run_tmux(&["capture-pane", "-p", "-q", "-t", pane_id]) +} + +pub fn set_agent_state(pane_id: &str, state: AgentState) -> Result<(), String> { + run_tmux(&[ + "set-option", + "-p", + "-t", + pane_id, + "@agent_state", + agent_state_label(state), + ]) + .map(|_| ()) +} |
