//! Which terminal window holds a tmux client, so a jump raises exactly that //! window (or tab, or split), not just the terminal app. //! //! The client's tty identifies it, but Ghostty (1.3+, scriptable: windows, //! tabs, terminals by id) does not expose ttys. So dingger learns the pairing //! from focus: when a client gains focus (tmux's `client-focus-in`, from the //! terminal's focus reports), the terminal Ghostty reports as focused is that //! client's. Nothing visible changes (no titles, no escape sequences). //! //! A pairing is kept in the global tmux option `@dingger_terminal_` as //! ` `, and only recorded if the client is //! still focused after Ghostty answered, so a fast switch cannot pair the //! wrong terminal. //! //! Apple Events go out from children of the tmux server (the focus hook, and //! `tmux run-shell` for raising), so macOS attributes them to the terminal //! that started tmux: Ghostty scripting itself needs no permission. use std::process::Command; use crate::tmux; const GHOSTTY_ID: &str = "com.mitchellh.ghostty"; fn now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0) } fn osascript() -> String { std::env::var("DINGGER_OSASCRIPT").unwrap_or_else(|_| "/usr/bin/osascript".to_string()) } /// Bring an app forward (`open -a`; `DINGGER_OPEN` replaces `open` in tests, /// which must never activate a real app). pub fn open_app(app: &str) { let open = std::env::var("DINGGER_OPEN").unwrap_or_else(|_| "open".to_string()); let _ = Command::new(open).args(["-a", app]).status(); } /// The `.app` bundle hosting a process: its first ancestor inside one /// (Ghostty, Alacritty, ...). `DINGGER_TERMINAL_APP` overrides it (tests). pub fn terminal_app(client_pid: u32) -> Option { if let Ok(app) = std::env::var("DINGGER_TERMINAL_APP") { return Some(app); } let mut pid = client_pid; for _ in 0..16 { let out = Command::new("ps").args(["-o", "ppid=,comm=", "-p", &pid.to_string()]).output().ok()?; let line = String::from_utf8_lossy(&out.stdout).trim().to_string(); let (ppid, comm) = line.split_once(char::is_whitespace)?; if let Some(end) = comm.find(".app/") { return Some(comm[..end + 4].to_string()); } match ppid.trim().parse::() { Ok(parent) if parent > 1 => pid = parent, _ => return None, } } None } fn is_ghostty(app: &str) -> bool { app.trim_end_matches('/').ends_with("/Ghostty.app") } /// A Ghostty terminal id: a UUID. Anything else is not used in a script. fn valid_id(id: &str) -> bool { !id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') } fn option_name(tty: &str) -> String { let name: String = tty.trim_start_matches("/dev/").chars().filter(|c| c.is_ascii_alphanumeric()).collect(); format!("@dingger_terminal_{name}") } /// A client's learned terminal. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Pairing { /// When the client last gained focus (unix ms). pub focused_ms: u64, pub terminal: String, pub app: String, } fn parse(value: &str) -> Option { let mut fields = value.trim().splitn(3, ' '); let focused_ms = fields.next()?.parse().ok()?; let terminal = fields.next()?.to_string(); let app = fields.next()?.to_string(); valid_id(&terminal).then_some(Pairing { focused_ms, terminal, app }) } /// The terminal a client was last seen focused in. pub fn pairing(tty: &str) -> Option { tmux::show_global(&option_name(tty)).and_then(|value| parse(&value)) } /// Every recorded pairing, as (option name, pairing). fn pairings() -> Vec<(String, Pairing)> { let Ok(out) = tmux::run(&["show-options", "-g"]) else { return Vec::new(); }; out.lines() .filter_map(|line| { let (name, value) = line.split_once(' ')?; name.starts_with("@dingger_terminal_") .then(|| parse(value.trim_matches('"')).map(|p| (name.to_string(), p)))? }) .collect() } fn client_focused(tty: &str) -> bool { tmux::run(&["list-clients", "-F", "#{client_tty}\t#{client_flags}"]) .unwrap_or_default() .lines() .any(|line| line.split_once('\t').is_some_and(|(t, flags)| t == tty && flags.split(',').any(|f| f == "focused"))) } /// `dingger terminal focused`: the client gained focus; pair it with the /// terminal Ghostty reports as focused. pub fn learn(client: &str) -> Result<(), String> { let started = now_ms(); let info = tmux::client_format(client, "#{client_tty}\t#{client_pid}").ok_or("no such client")?; let (tty, pid) = info.split_once('\t').ok_or("no client tty")?; let app = terminal_app(pid.parse().map_err(|_| "no client pid")?).ok_or("not in a terminal app")?; if !is_ghostty(&app) { return Ok(()); } let script = format!( "tell application id \"{GHOSTTY_ID}\" to if frontmost then return id of focused terminal of selected tab of front window" ); let out = Command::new(osascript()).args(["-e", &script]).output().map_err(|err| err.to_string())?; let terminal = String::from_utf8_lossy(&out.stdout).trim().to_string(); if !valid_id(&terminal) { return Err(format!("Ghostty reported no focused terminal ({terminal:?})")); } // Focus moved on while Ghostty answered: the answer may be another // terminal's. The next focus-in pairs it. if !client_focused(tty) { return Err("focus moved on; not paired".to_string()); } let ours = option_name(tty); let mut stale = Vec::new(); for (name, pairing) in pairings() { if name == ours && pairing.focused_ms > started { return Ok(()); // a later focus-in already paired it } // A terminal holds one client: an older pairing with it is stale. if name != ours && pairing.terminal == terminal && pairing.focused_ms <= started { stale.push(name); } } for name in stale { let _ = tmux::run(&["set-option", "-g", "-u", &name]); } tmux::run(&["set-option", "-g", &ours, &format!("{started} {terminal} {app}")]).map(|_| ()) } /// `dingger terminal raise` (run by the tmux server): focus the client's /// terminal, bringing its tab and window to the front; otherwise just the /// app. pub fn raise(client: &str) -> Result<(), String> { let tty = tmux::client_format(client, "#{client_tty}").ok_or("no such client")?; let pairing = pairing(&tty).ok_or("no terminal paired with this client")?; let script = format!( "tell application id \"{GHOSTTY_ID}\"\nfocus terminal id \"{}\"\nactivate\nend tell", pairing.terminal ); let focused = Command::new(osascript()).args(["-e", &script]).output().is_ok_and(|out| out.status.success()); if !focused { // The terminal is gone: forget it, and bring the app forward. let _ = tmux::run(&["set-option", "-g", "-u", &option_name(&tty)]); open_app(&pairing.app); return Err(format!("terminal {} is gone; activated {}", pairing.terminal, pairing.app)); } Ok(()) } /// A client, for choosing which one shows an agent. #[derive(Debug, Clone)] pub struct Client { pub name: String, pub pid: u32, pub tty: String, pub session_id: String, pub focused: bool, /// The later of its last input and its last focus (unix ms). pub used_ms: u64, } /// The attached clients. pub fn clients() -> Result, String> { let out = tmux::run(&[ "list-clients", "-F", "#{client_name}\t#{client_pid}\t#{client_tty}\t#{session_id}\t#{client_flags}\t#{client_activity}", ])?; Ok(out .lines() .filter_map(|line| { let f: Vec<&str> = line.split('\t').collect(); let [name, pid, tty, session_id, flags, activity] = f[..] else { return None; }; let input_ms = activity.parse::().unwrap_or(0) * 1000; let focus_ms = pairing(tty).map(|p| p.focused_ms).unwrap_or(0); Some(Client { name: name.to_string(), pid: pid.parse().ok()?, tty: tty.to_string(), session_id: session_id.to_string(), focused: flags.split(',').any(|flag| flag == "focused"), used_ms: input_ms.max(focus_ms), }) }) .collect()) } /// The client to show a pane in, and whether its session already holds the /// pane's window (`showing`: the sessions that do). Prefer a client that /// shows it without switching session; among those, or else among all, the /// focused one, then the most recently used. pub fn choose<'a>(clients: &'a [Client], showing: &[String]) -> Option<(&'a Client, bool)> { let best = |candidates: Vec<&'a Client>| { candidates.into_iter().max_by_key(|client| (client.focused, client.used_ms)) }; let shows = |client: &&Client| showing.contains(&client.session_id); best(clients.iter().filter(shows).collect()) .map(|client| (client, true)) .or_else(|| best(clients.iter().collect()).map(|client| (client, false))) } #[cfg(test)] mod tests { use super::*; fn client(name: &str, session: &str, focused: bool, used_ms: u64) -> Client { Client { name: name.into(), pid: 1, tty: format!("/dev/{name}"), session_id: session.into(), focused, used_ms, } } #[test] fn prefers_a_client_already_on_the_session_then_focus_then_recency() { let clients = [client("a", "$1", false, 300), client("b", "$2", false, 100), client("c", "$2", false, 200)]; let pick = |showing: &[&str]| { let showing: Vec = showing.iter().map(|s| s.to_string()).collect(); choose(&clients, &showing).map(|(c, keeps)| (c.name.clone(), keeps)) }; assert_eq!(pick(&["$2"]), Some(("c".into(), true)), "the most recent of those on the session"); assert_eq!(pick(&["$3"]), Some(("a".into(), false)), "none on it: the most recent, switched"); let focused = [client("a", "$1", false, 300), client("b", "$1", true, 100)]; assert_eq!(choose(&focused, &["$1".into()]).map(|(c, _)| c.name.clone()), Some("b".into()), "focused wins"); assert!(choose(&[], &[]).is_none()); } #[test] fn pairings_parse_and_ids_are_checked() { assert_eq!( parse("1700 FF00-AB12 /Applications/Ghostty.app"), Some(Pairing { focused_ms: 1700, terminal: "FF00-AB12".into(), app: "/Applications/Ghostty.app".into() }) ); assert_eq!(parse("1700 \"x\"; do shell script /x"), None, "only a UUID goes into a script"); assert_eq!(option_name("/dev/ttys021"), "@dingger_terminal_ttys021"); assert!(is_ghostty("/Applications/Ghostty.app") && !is_ghostty("/Applications/Alacritty.app")); } }