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 | |
| 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.
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | ROADMAP.md | 5 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 109 | ||||
| -rw-r--r-- | crates/dinggerd/src/terminals.rs | 275 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 40 | ||||
| -rw-r--r-- | packaging/tmux/dingger.tmux | 6 | ||||
| -rwxr-xr-x | tests/e2e.sh | 91 |
7 files changed, 473 insertions, 61 deletions
@@ -81,6 +81,14 @@ than a poll is still a completion. summarizes (full report: `~/.local/state/dingger/restore.log`). - All shortcuts live in `~/.config/dingger/config.toml` (menu bar, Settings…), reloaded on save. +- **Jumping to an agent** (any click, a shortcut, search) shows it in a + terminal window and raises that window. With several terminal windows, + one already on the agent's session wins (it shows the agent without + switching), then the focused one, then the one used last. In Ghostty + (1.3+), the exact window, tab, and split is raised: dingger learns which + terminal holds each tmux client when it gains focus (tmux's focus hook + asks Ghostty which terminal is focused; nothing visible changes). + Other terminals: the app is activated. - **tmux status line** (`packaging/tmux/dingger.tmux`): the same summary after the clock. Clicking it jumps to the top waiting agent (the menu's top row), skipping the window you already look at. @@ -5,8 +5,7 @@ In priority order. 1. ~~**Restore after a crash or shutdown.**~~ Done for pi and Claude Code (see the README). Next, when used: other agents' session references (codex, ...). -2. **Raise the right window.** Jumping to an agent activates the terminal - app; with several windows (several Ghostty windows, say) it should raise - the exact window that holds the tmux client, not whichever was last used. +2. ~~**Raise the right window.**~~ Done for Ghostty (see the README). + Next, when used: Alacritty with several windows in one process. 3. **Preview in agent search.** Show the last lines of the selected agent's pane (`tmux capture-pane`) beside the results. 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(|_| ()) } diff --git a/packaging/tmux/dingger.tmux b/packaging/tmux/dingger.tmux index f1529f1..b0b0951 100644 --- a/packaging/tmux/dingger.tmux +++ b/packaging/tmux/dingger.tmux @@ -18,3 +18,9 @@ bind -n MouseDown1StatusRight if -F '#{==:#{mouse_status_range},restore}' \ set-hook -g 'client-session-changed[77]' 'run-shell -b "$HOME/.local/bin/dingger poke"' set-hook -g 'session-window-changed[77]' 'run-shell -b "$HOME/.local/bin/dingger poke"' set-hook -g 'window-pane-changed[77]' 'run-shell -b "$HOME/.local/bin/dingger poke"' +# When a terminal window gains focus, dingger learns which window holds this +# client, so a jump raises exactly that window, tab, and split. Needs the +# terminal's focus reports. (#{hook_client}: in a hook, #{client_name} may be +# another client.) +set -g focus-events on +set-hook -g 'client-focus-in[77]' "run-shell -b \"$HOME/.local/bin/dingger terminal focused --client '#{hook_client}'\"" diff --git a/tests/e2e.sh b/tests/e2e.sh index 75dbc31..f874c15 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -226,6 +226,8 @@ t source-file "$ROOT/packaging/tmux/dingger.tmux" segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g') case "$segment" in *"×1"*"✓1"*) pass "segment shows ×1 ✓1 ($segment)";; *) fail "segment: [$segment]";; esac expect "mouse binding installed" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'dingger focus'" 1 +expect "focus hook installed: learns the terminal window" "t show-hooks -g client-focus-in | grep -c 'dingger terminal focused'" 1 +expect "with the terminal's focus reports" "t show -gv focus-events" on expect "clicking the restore offer runs restore" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'restore --report'" 1 t set -g @dingger_restore 3 segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g') @@ -673,6 +675,95 @@ expect "restore: the agent is resumed once" "grep -c -- '--session $W/sessions/g expect "with both windows" "r list-windows -t '=grp' -F '#{window_name}' | tr '\n' ' '" "agent other " expect "no real agent runs in a test pane (group)" "real_agents" "" +# --- 20. raise the right terminal window --------------------------------------- +# Two terminal windows on one session (Ghostty's `new-session -As 0` does +# that): nested clients, focused by typing a terminal's focus reports into +# their outer panes. A fake osascript plays Ghostty: it answers the focused +# terminal from a file and logs what it is told to focus. `open` is a fake +# too: tests never activate a real app. +echo "20. terminal windows" +cat >"$DIR/osascript" <<FAKE +#!/bin/sh +case "\$2" in + *"focused terminal"*) + echo query >>$DIR/ghostty-queries + # Focus moves on while Ghostty answers. + [ -f $DIR/ghostty-race ] && env -u TMUX tmux -u -L $R send-keys -t "\$(cat $DIR/ghostty-race):" -H 1b 5b 4f && sleep 0.3 + cat $DIR/ghostty-focused ;; + *"focus terminal id"*) + echo "\$2" | grep -o 'id "[^"]*"' | cut -d'"' -f2 >>$DIR/ghostty-raised + [ ! -f $DIR/ghostty-gone ] ;; +esac +FAKE +printf '#!/bin/sh\necho "$2" >> %s\n' "$DIR/opened" >"$DIR/open" +chmod +x "$DIR/osascript" "$DIR/open" +FAKE_APP="$DIR/Fake/Ghostty.app" +start_viewer +r set-environment -g DINGGER_OSASCRIPT "$DIR/osascript" +r set-environment -g DINGGER_OPEN "$DIR/open" +r set-environment -g DINGGER_TERMINAL_APP "$FAKE_APP" +r set-environment -g DINGGER_TMUX_SOCKET "$R" +r set -g focus-events on +# The snippet's own focus hook, pointed at the binary under test. +grep "client-focus-in" "$ROOT/packaging/tmux/dingger.tmux" | sed "s|\$HOME/.local/bin/dingger|$DINGGER|" >"$DIR/focus-hook.tmux" +r source-file "$DIR/focus-hook.tmux" +expect "the snippet's focus hook, on the binary under test" "r show-hooks -g client-focus-in | grep -c \"$DINGGER terminal focused\"" 1 +r new-session -d -s viewer2 -x 120 -y 40 +r send-keys -t viewer2 "exec env -u TMUX tmux -u -L $R attach -t 0" Enter +expect "two terminal windows on session 0" "r list-clients -F '#{session_name}' | sort | tr '\n' ' '" "0 0 " 50 +TTY1=$(r display -p -t viewer: '#{pane_tty}'); TTY2=$(r display -p -t viewer2: '#{pane_tty}') +r new-window -d -t '=0:' -n agent2 "$DIR/rbin/real/pi 100000" +AG=$(r display -p -t '=0:agent2' '#{pane_id}') +r new-window -d -t '=0:' -n other +r new-session -d -s elsewhere +focus_in() { echo "$2" >"$DIR/ghostty-focused"; r send-keys -t "$1:" -H 1b 5b 49; } +focus_out() { r send-keys -t "$1:" -H 1b 5b 4f; } +paired() { r show -gv "@dingger_terminal_$(basename "$1")" 2>/dev/null | cut -d' ' -f2; } +client_session() { r list-clients -F '#{client_tty} #{session_name}' | awk -v t="$1" '$1==t {print $2}'; } +focus_out viewer; focus_out viewer2 +focus_in viewer AAAA-1111 +expect "focusing a window pairs its client with Ghostty's focused terminal" "paired $TTY1" AAAA-1111 50 +focus_out viewer; focus_in viewer2 BBBB-2222 +expect "and the other window's" "paired $TTY2" BBBB-2222 50 +: >"$DIR/ghostty-raised" +renv "$DINGGER" focus --pane "$AG" +expect "a jump raises the focused window's terminal" "tail -1 $DIR/ghostty-raised" BBBB-2222 50 +expect "which shows the agent" "r display -p -c $TTY2 '#{window_name}'" agent2 +focus_out viewer2; focus_in viewer AAAA-1111; sleep 0.5; focus_out viewer +r select-window -t '=0:other' +renv "$DINGGER" focus --pane "$AG" +expect "nothing focused: the most recently focused window's" "tail -1 $DIR/ghostty-raised" AAAA-1111 50 +r switch-client -c "$TTY1" -t '=elsewhere' +renv "$DINGGER" focus --pane "$AG" +expect "a window already on the agent's session wins over a more recent one" "tail -1 $DIR/ghostty-raised" BBBB-2222 50 +expect "and the other window is not moved" "client_session $TTY1" elsewhere +echo viewer >"$DIR/ghostty-race"; : >"$DIR/ghostty-queries" +focus_in viewer CCCC-3333 +expect "Ghostty asked" "wc -l <$DIR/ghostty-queries | tr -d ' '" 1 50 +sleep 1 +expect "focus moved on while Ghostty answered: not paired" "paired $TTY1" AAAA-1111 +rm -f "$DIR/ghostty-race" +focus_in viewer2 AAAA-1111 +expect "a terminal holds one client: the newer pairing" "paired $TTY2" AAAA-1111 50 +expect "replaces the older one" "paired $TTY1" "" +focus_out viewer2 +touch "$DIR/ghostty-gone"; : >"$DIR/opened" +renv "$DINGGER" focus --pane "$AG" +expect "a terminal that is gone is forgotten" "paired $TTY2" "" 50 +expect "and the app is brought forward instead" "cat $DIR/opened" "$FAKE_APP" 50 +rm -f "$DIR/ghostty-gone"; : >"$DIR/opened"; : >"$DIR/ghostty-raised" +renv DINGGER_TERMINAL_APP="$FAKE_APP" DINGGER_OPEN="$DIR/open" "$DINGGER" focus --pane "$AG" +expect "no pairing: the app" "cat $DIR/opened" "$FAKE_APP" 50 +expect "and no terminal raised" "cat $DIR/ghostty-raised" "" +# A window on a session of a group stays on it: a pane target alone lets +# tmux pick a session of the group, the most recently used. +r new-session -d -t grp -s grp-b +r switch-client -c "$TTY1" -t '=grp-b' +r switch-client -c "$TTY2" -t '=grp'; sleep 1.1; r switch-client -c "$TTY2" -t '=0' +renv "$DINGGER" focus --client "$TTY1" --pane "$(r display -p -t '=grp:agent' '#{pane_id}')" +expect "a window on a session group stays on its own session" "client_session $TTY1" grp-b +expect "showing the agent" "r display -p -c $TTY1 '#{window_name}'" agent + # Every daemon this run started must stop with it. kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0 |
