From e4d3bc242bd3d18aa83a76f25dcbe5cb7a2ced78 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Fri, 23 Oct 2026 19:11:23 +0300 Subject: raise the terminal window that holds the tmux client a jump (any click, a shortcut, search) activated the terminal app, which raised whichever window was used last. now: - the client: the named one (a status-line click), else one already on a session holding the agent's window (several terminal windows can show one session; it shows the agent without switching, and keeps its own session in a session group), else any; the focused one first, then the one used last (its last input or focus). - Ghostty (1.3+, scriptable by terminal id, not by tty): dingger learns which terminal holds each client when it gains focus. tmux's client-focus-in hook runs `dingger terminal focused`, which asks Ghostty for its focused terminal and records it in @dingger_terminal_, but only if the client is still focused once Ghostty answered (a fast switch must not pair the wrong terminal); a terminal holds one client, so an older pairing with it is dropped. `focus` raises the exact terminal (window, tab, split) through `tmux run-shell`: Apple Events from the tmux server count as Ghostty's own, so no permission prompt. a terminal that is gone is forgotten and the app activated, as without a pairing. - in a hook, #{client_name} can be another client (the previous event's); the snippet's hook uses #{hook_client}. e2e step 20: two nested clients on one session, focused by typing focus reports into their outer panes, a fake osascript as Ghostty, a fake open (tests never activate a real app); it loads the snippet's own hook line. each rule has a negative control that fails. --- crates/dinggerd/src/terminals.rs | 275 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 crates/dinggerd/src/terminals.rs (limited to 'crates/dinggerd/src/terminals.rs') diff --git a/crates/dinggerd/src/terminals.rs b/crates/dinggerd/src/terminals.rs new file mode 100644 index 0000000..413ba0d --- /dev/null +++ b/crates/dinggerd/src/terminals.rs @@ -0,0 +1,275 @@ +//! 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")); + } +} -- cgit v1.2.3