diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-23 19:11:23 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-23 19:11:23 +0300 |
| commit | e4d3bc242bd3d18aa83a76f25dcbe5cb7a2ced78 (patch) | |
| tree | 70b7631a0558829fb53814f3fb5a1ad32977149f /crates/dinggerd/src/main.rs | |
| parent | b4f45632debe6b8dc2f44ca6cc1b003d9093ad33 (diff) | |
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_<tty>, 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.
Diffstat (limited to 'crates/dinggerd/src/main.rs')
| -rw-r--r-- | crates/dinggerd/src/main.rs | 109 |
1 files changed, 71 insertions, 38 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, |
