aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd')
-rw-r--r--crates/dinggerd/src/main.rs109
-rw-r--r--crates/dinggerd/src/terminals.rs275
-rw-r--r--crates/dinggerd/src/tmux.rs40
3 files changed, 366 insertions, 58 deletions
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index 2783899..e43f9a7 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -8,6 +8,7 @@ mod notify;
mod platform;
mod restore;
mod snapshot;
+mod terminals;
mod tmux;
mod watcher;
@@ -22,6 +23,20 @@ struct Cli {
}
#[derive(Subcommand)]
+enum TerminalAction {
+ /// The client gained focus: learn its terminal (tmux's client-focus-in).
+ Focused {
+ #[arg(long)]
+ client: String,
+ },
+ /// Raise the client's terminal.
+ Raise {
+ #[arg(long)]
+ client: String,
+ },
+}
+
+#[derive(Subcommand)]
enum Command {
/// Watch all tmux panes and notify on agent state transitions.
Watch {
@@ -85,6 +100,13 @@ enum Command {
/// `pi`, `claude`, or `zsh`.
agent: String,
},
+ /// The terminal window of a tmux client (tmux runs these; see
+ /// `terminals`).
+ #[command(hide = true)]
+ Terminal {
+ #[command(subcommand)]
+ action: TerminalAction,
+ },
/// An agent's hook, run by the agent (`dingger install` sets it up).
#[command(hide = true)]
Hook {
@@ -170,30 +192,43 @@ fn main() {
std::process::exit(1);
}
}
+ Command::Terminal { action } => {
+ let result = match &action {
+ TerminalAction::Focused { client } => terminals::learn(client),
+ TerminalAction::Raise { client } => terminals::raise(client),
+ };
+ if let Err(err) = result {
+ eprintln!("dingger: {err}");
+ std::process::exit(1);
+ }
+ }
// Never fails the agent: exits 0, prints nothing.
Command::Hook { agent } => integrations::run_hook(&agent),
}
}
/// Show a pane in a client and mark it attended (`@dingger_ack`, consumed
-/// by the watcher on its next tick). This is the single code path behind
-/// every click: the tmux status line and the menu bar rows.
+/// by the watcher on its next tick), then raise that client's terminal
+/// window. The single code path behind every click: the tmux status line,
+/// the menu bar rows, the panel, search, and the shortcuts.
+///
+/// The client: the named one (a status-line click names the clicking
+/// client); otherwise one already on a session holding the pane's window
+/// (several terminal windows can show one session), else any; the focused
+/// one first, then the most recently used (see `terminals::choose`).
fn focus(client: Option<String>, pane: Option<String>) -> Result<(), String> {
- let clients = tmux::clients()?;
- // The named client if it is attached; otherwise the most recently active
- // one (a click on the status line makes the clicking client the most
- // recent, so this is right even if the name did not expand).
- let chosen = client
- .and_then(|name| clients.iter().find(|(n, _)| *n == name).cloned())
- .or_else(|| clients.first().cloned());
- let Some((client, client_pid)) = chosen else {
+ let clients = terminals::clients()?;
+ if clients.is_empty() {
return Err("no tmux client attached".to_string());
- };
+ }
+ let named = client.and_then(|name| clients.iter().find(|c| c.name == name));
let pane_id = match pane {
Some(pane) => pane,
None => {
- let current_window = tmux::client_format(&client, "#{window_id}");
+ // The queue head, skipping the window the user looks at.
+ let looking = named.or_else(|| terminals::choose(&clients, &[]).map(|(c, _)| c));
+ let current_window = looking.and_then(|c| tmux::client_format(&c.name, "#{window_id}"));
let queue = tmux::show_global("@dingger_queue").unwrap_or_default();
let next = queue.split(',').filter(|id| !id.is_empty()).find(|id| {
tmux::pane_format(id, "#{window_id}").as_deref() != current_window.as_deref()
@@ -205,39 +240,37 @@ fn focus(client: Option<String>, pane: Option<String>) -> Result<(), String> {
}
};
+ let showing = tmux::sessions_with_pane(&pane_id);
+ let (client, keeps_session) = match named {
+ Some(client) => (client, showing.contains(&client.session_id)),
+ None => terminals::choose(&clients, &showing).ok_or("no tmux client attached")?,
+ };
tmux::set_pane_option(&pane_id, "@dingger_ack", "1")?;
- tmux::show_pane(&client, &pane_id)?;
+ tmux::show_pane(&client.name, &pane_id, keeps_session.then_some(client.session_id.as_str()))?;
let _ = hooks::poke();
- activate_terminal_app(client_pid);
+ raise(client);
Ok(())
}
-/// Bring the terminal app hosting a tmux client to the front: walk the
-/// client's process ancestry up to the first `*.app` bundle (Ghostty,
-/// Alacritty, ...) and `open -a` it.
-fn activate_terminal_app(client_pid: u32) {
- let mut pid = client_pid;
- for _ in 0..16 {
- let Ok(out) = std::process::Command::new("ps")
- .args(["-o", "ppid=,comm=", "-p", &pid.to_string()])
- .output()
- else {
- return;
- };
- let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
- let Some((ppid, comm)) = line.split_once(char::is_whitespace) else {
- return;
- };
- if let Some(end) = comm.find(".app/") {
- let bundle = &comm[..end + 4];
- let _ = std::process::Command::new("open").args(["-a", bundle]).status();
- return;
- }
- match ppid.trim().parse::<u32>() {
- Ok(parent) if parent > 1 => pid = parent,
- _ => return,
+/// Bring a client's terminal forward: its exact window, tab, and split if
+/// dingger learned which it is (raised by the tmux server, see
+/// `terminals`); otherwise the terminal app.
+fn raise(client: &terminals::Client) {
+ if terminals::pairing(&client.tty).is_some() {
+ if let Ok(exe) = std::env::current_exe() {
+ let command = format!(
+ "{} terminal raise --client {} >/dev/null 2>&1",
+ snapshot::shell_quote(&exe.to_string_lossy()),
+ snapshot::shell_quote(&client.name)
+ );
+ if tmux::run(&["run-shell", "-b", &command]).is_ok() {
+ return;
+ }
}
}
+ if let Some(app) = terminals::terminal_app(client.pid) {
+ terminals::open_app(&app);
+ }
}
/// Every agent pane in herdr's priority order (blocked, done, working,
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_<tty>` as
+//! `<unix ms> <terminal id> <app path>`, 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<String> {
+ 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::<u32>() {
+ 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<Pairing> {
+ 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<Pairing> {
+ 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<Vec<Client>, 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::<u64>().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<String> = 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"));
+ }
+}
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs
index 6610161..54da39d 100644
--- a/crates/dinggerd/src/tmux.rs
+++ b/crates/dinggerd/src/tmux.rs
@@ -280,31 +280,31 @@ pub fn client_format(client: &str, format: &str) -> Option<String> {
.filter(|value| !value.is_empty())
}
-/// Attached clients as (name, pid), most recently active first.
-pub fn clients() -> Result<Vec<(String, u32)>, String> {
- let out = run_tmux(&[
- "list-clients",
- "-F",
- "#{client_activity}\t#{client_name}\t#{client_pid}",
- ])?;
- let mut clients: Vec<(u64, String, u32)> = out
+/// The sessions holding a pane's window (several: a session group, a linked
+/// window).
+pub fn sessions_with_pane(pane_id: &str) -> Vec<String> {
+ run_tmux(&["list-panes", "-a", "-F", "#{session_id}\t#{pane_id}"])
+ .unwrap_or_default()
.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())
+ .filter_map(|line| line.split_once('\t').filter(|(_, pane)| *pane == pane_id).map(|(s, _)| s.to_string()))
+ .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])?;
+///
+/// `session`: the client's own session, when it holds the pane's window;
+/// the client stays on it (a pane target alone lets tmux pick a session,
+/// which in a session group may be another one).
+pub fn show_pane(client: &str, pane_id: &str, session: Option<&str>) -> Result<(), String> {
+ let target = match session {
+ Some(session) => {
+ let window = pane_format(pane_id, "#{window_id}").ok_or_else(|| format!("no pane {pane_id}"))?;
+ format!("{session}:{window}")
+ }
+ None => pane_id.to_string(),
+ };
+ run_tmux(&["switch-client", "-c", client, "-t", &target])?;
run_tmux(&["select-pane", "-t", pane_id]).map(|_| ())
}