//! 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, /// The `@agent_state` user option previously written for this pane, /// read straight from the pane format. pub written_state: String, /// The `@agent` user option (agent label) previously written, if any. pub written_agent: String, /// The `@dingger_ack` user option: "1" when the user already jumped to /// this pane in its current ready episode. pub written_ack: 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 { let mut command = Command::new("tmux"); // -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}"))?; 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", "#{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}\t", "#{@agent}\t", "#{@dingger_ack}" ); 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() < 11 { continue; } let current_command = fields[2].to_string(); let start_command = fields[7].to_string(); let pane_pid: Option = 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(), written_agent: fields[10].to_string(), written_ack: fields.get(11).copied().unwrap_or("").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) } /// Run a tmux command whose effect is an action (e.g. switch-client), not /// output to parse. pub fn run_tmux_client(args: &[&str]) -> Result<(), String> { run_tmux(args).map(|_| ()) } /// Read a format expansion for one pane. pub fn show_pane_format(pane_id: &str, format: &str) -> Option { run_tmux(&["display-message", "-p", "-t", pane_id, format]) .ok() .map(|value| value.trim().to_string()) } /// 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_agent_state(pane_id: &str, state: AgentState) -> Result<(), String> { run_tmux(&[ "set-option", "-p", "-t", pane_id, "@agent_state", agent_state_label(state), ]) .map(|_| ()) } /// Label the pane with the agent running in it, for tmux status-line and /// menu-bar rendering; clears the label when the pane stops running one. pub fn set_agent(pane_id: &str, agent: Option) -> Result<(), String> { let label = agent.map(detect_core::agent_label).unwrap_or(""); run_tmux(&["set-option", "-p", "-t", pane_id, "@agent", label]).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()) } /// Mark a pane as seen by the user after `dingger focus`: taken out of the /// ready queue until its next state transition. pub fn ack(pane_id: &str) -> Result<(), String> { run_tmux(&["set-option", "-p", "-t", pane_id, "@dingger_ack", "1"]).map(|_| ()) } pub fn clear_ack(pane_id: &str) -> Result<(), String> { run_tmux(&["set-option", "-p", "-t", pane_id, "-u", "@dingger_ack"]).map(|_| ()) }