//! tmux IPC wrapper. Everything dingger knows comes from these commands. use std::process::Command; use detect_core::{identify_agent, Agent}; #[derive(Debug, Clone)] pub struct Pane { /// Unique tmux pane id, e.g. `%3`. Stable for the pane's lifetime. pub pane_id: String, /// Human-readable target, e.g. `main:work` (`.pane` suffix when split). pub display_target: String, /// Last OSC title the pane's application set, from `#{pane_title}`. pub pane_title: String, /// Working directory of the pane's foreground process. pub cwd: String, pub dead: bool, /// The pane's window is the current 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, /// Pane options written by dingger (see the watcher module docs). pub written_state: String, pub written_agent: String, pub written_ack: String, pub written_since: String, pub written_hook: String, pub written_name: String, } impl Pane { /// The user is looking at this pane's window. herdr marks every pane of /// the active tab seen (`mark_active_tab_seen`); a tmux window is a tab. pub fn attended(&self) -> bool { self.window_active && self.session_attached } } /// The tmux binary. launchd starts dingger, and the menu bar app that runs /// `dingger focus`, with PATH=/usr/bin:/bin:/usr/sbin:/sbin, where Homebrew's /// tmux is not found: fall back to the usual install locations. fn tmux_binary() -> &'static std::path::Path { static TMUX: std::sync::OnceLock = std::sync::OnceLock::new(); TMUX.get_or_init(|| { if let Some(path) = std::env::var_os("DINGGER_TMUX") { return path.into(); } let on_path: Vec = std::env::var_os("PATH") .map(|path| std::env::split_paths(&path).map(|dir| dir.join("tmux")).collect()) .unwrap_or_default(); let known = ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"] .map(std::path::PathBuf::from); on_path .into_iter() .chain(known) .find(|candidate| candidate.is_file()) .unwrap_or_else(|| "tmux".into()) }) } fn run_tmux(args: &[&str]) -> Result { let mut command = Command::new(tmux_binary()); // -u forces UTF-8 mode: without it, tmux mangles format output in // non-UTF-8 locales (launchd/cron default to the C locale, turning the // tab separators in our -F formats into underscores). command.arg("-u"); // -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}", tmux_binary().display()))?; 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, String> { let format = concat!( "#{pane_id}\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}\t", "#{@agent}\t", "#{@dingger_ack}\t", "#{@dingger_since}\t", "#{@dingger_hook}\t", "#{session_name}:#{window_name}\t", "#{window_panes}\t", "#{pane_index}\t", "#{pane_current_path}\t", "#{@agent_name}", ); let out = run_tmux(&["list-panes", "-a", "-F", format])?; let mut panes = Vec::new(); let mut process_tree = None; for line in out.lines() { let fields: Vec<&str> = line.split('\t').collect(); if fields.len() < 18 { continue; } let current_command = fields[1]; let start_command = fields[6]; let pane_pid: Option = fields[7].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(current_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. if agent.is_none() { let tree = process_tree.get_or_insert_with(fetch_process_tree); agent = resolve_agent_in_process_tree(pane_pid, tree); } // Display target: session:window-name, pane index only when split. let mut display_target = fields[13].to_string(); if fields[14].parse::().map(|n| n > 1).unwrap_or(false) { display_target.push('.'); display_target.push_str(fields[15]); } panes.push(Pane { pane_id: fields[0].to_string(), display_target, pane_title: fields[2].to_string(), cwd: fields[16].to_string(), dead: fields[3] == "1", window_active: fields[4] == "1", session_attached: fields[5] != "0", agent, written_state: fields[8].to_string(), written_agent: fields[9].to_string(), written_ack: fields[10].to_string(), written_since: fields[11].to_string(), written_hook: fields[12].to_string(), written_name: fields[17].to_string(), }); } Ok(panes) } type ProcessTree = (std::collections::HashMap>, std::collections::HashMap); /// 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, tree: &ProcessTree) -> Option { 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> = std::collections::HashMap::new(); let mut comm: std::collections::HashMap = 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::(), ppid.parse::()) 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 { run_tmux(&["capture-pane", "-p", "-q", "-t", pane_id]) } pub fn set_pane_option(pane_id: &str, name: &str, value: &str) -> Result<(), String> { run_tmux(&["set-option", "-p", "-t", pane_id, name, value]).map(|_| ()) } pub fn unset_pane_option(pane_id: &str, name: &str) -> Result<(), String> { run_tmux(&["set-option", "-p", "-u", "-t", pane_id, name]).map(|_| ()) } /// Write a server-global option (readable from any client and from formats). pub fn set_global(name: &str, value: &str) -> Result<(), String> { run_tmux(&["set-option", "-g", name, value]).map(|_| ()) } pub fn show_global(name: &str) -> Option { run_tmux(&["show-option", "-gv", name]) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } /// Expand a format in the context of a pane. pub fn pane_format(pane_id: &str, format: &str) -> Option { run_tmux(&["display-message", "-p", "-t", pane_id, format]) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } /// Expand a format in the context of a client (its current pane/window). pub fn client_format(client: &str, format: &str) -> Option { run_tmux(&["display-message", "-p", "-c", client, format]) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } /// Attached clients as (name, pid), most recently active first. pub fn clients() -> Result, String> { let out = run_tmux(&[ "list-clients", "-F", "#{client_activity}\t#{client_name}\t#{client_pid}", ])?; let mut clients: Vec<(u64, String, u32)> = out .lines() .filter_map(|line| { let mut fields = line.split('\t'); let activity = fields.next()?.parse().ok()?; let name = fields.next()?.to_string(); let pid = fields.next()?.parse().ok()?; Some((activity, name, pid)) }) .collect(); clients.sort_by_key(|client| std::cmp::Reverse(client.0)); Ok(clients.into_iter().map(|(_, name, pid)| (name, pid)).collect()) } /// Show a pane in a client: switch the client to the pane's window and make /// the pane the active one. pub fn show_pane(client: &str, pane_id: &str) -> Result<(), String> { run_tmux(&["switch-client", "-c", client, "-t", pane_id])?; run_tmux(&["select-pane", "-t", pane_id]).map(|_| ()) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_ps_tree() { let (children, comm) = fetch_process_tree(); assert!(comm.contains_key(&1u32), "launchd should be present"); assert!(!children.is_empty()); } }