diff options
| -rw-r--r-- | crates/dinggerd/src/terminals.rs | 53 | ||||
| -rwxr-xr-x | tests/e2e.sh | 20 |
2 files changed, 58 insertions, 15 deletions
diff --git a/crates/dinggerd/src/terminals.rs b/crates/dinggerd/src/terminals.rs index 413ba0d..85ae398 100644 --- a/crates/dinggerd/src/terminals.rs +++ b/crates/dinggerd/src/terminals.rs @@ -12,6 +12,11 @@ //! still focused after Ghostty answered, so a fast switch cannot pair the //! wrong terminal. //! +//! Ghostty's answer lags tmux's focus: across a Space switch (a full screen +//! window) tmux sees the new client focused about 0.3 s before Ghostty's +//! front window changes, and meanwhile Ghostty names the terminal that had +//! focus. So Ghostty is asked only once focus has settled (`SETTLE`). +//! //! 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. @@ -112,11 +117,33 @@ fn pairings() -> Vec<(String, Pairing)> { .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"))) +/// The pairing options of the attached clients, and whether `tty` is focused. +fn attached(tty: &str) -> (Vec<String>, bool) { + let mut names = Vec::new(); + let mut focused = false; + for line in tmux::run(&["list-clients", "-F", "#{client_tty}\t#{client_flags}"]).unwrap_or_default().lines() { + if let Some((t, flags)) = line.split_once('\t') { + names.push(option_name(t)); + focused |= t == tty && flags.split(',').any(|f| f == "focused"); + } + } + (names, focused) +} + +/// How long after a focus-in Ghostty is asked: its front window lags +/// tmux's focus by about 0.3 s across a Space switch. +const SETTLE: std::time::Duration = std::time::Duration::from_millis(1000); + +fn ghostty_focused_terminal() -> Result<String, String> { + 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:?})")); + } + Ok(terminal) } /// `dingger terminal focused`: the client gained focus; pair it with the @@ -129,17 +156,12 @@ pub fn learn(client: &str) -> Result<(), String> { 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:?})")); - } + std::thread::sleep(SETTLE); + let terminal = ghostty_focused_terminal()?; // Focus moved on while Ghostty answered: the answer may be another // terminal's. The next focus-in pairs it. - if !client_focused(tty) { + let (live, focused) = attached(tty); + if !focused { return Err("focus moved on; not paired".to_string()); } let ours = option_name(tty); @@ -149,7 +171,8 @@ pub fn learn(client: &str) -> Result<(), String> { 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 { + // So is a client's that is no longer attached. + if name != ours && ((pairing.terminal == terminal && pairing.focused_ms <= started) || !live.contains(&name)) { stale.push(name); } } diff --git a/tests/e2e.sh b/tests/e2e.sh index f874c15..13a698f 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -737,6 +737,26 @@ 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 +# Across a Space switch Ghostty still names the terminal that had focus +# for a moment: the other window's. +focus_out viewer; focus_in viewer2 AAAA-1111 +sleep 0.5; echo BBBB-2222 >"$DIR/ghostty-focused" +sleep 1 +expect "Ghostty lagging behind tmux's focus: the right terminal" "paired $TTY2" BBBB-2222 50 +expect "and the other window keeps its own" "paired $TTY1" AAAA-1111 +# Pairings crossed (as the lag used to leave them): Ghostty's lagging +# answer is then the client's own wrong terminal. +r set -g "@dingger_terminal_$(basename "$TTY1")" "1 BBBB-2222 $FAKE_APP" +r set -g "@dingger_terminal_$(basename "$TTY2")" "1 AAAA-1111 $FAKE_APP" +focus_out viewer2; focus_in viewer2 AAAA-1111 +sleep 0.5; echo BBBB-2222 >"$DIR/ghostty-focused" +expect "crossed pairings heal: the focused client's" "paired $TTY2" BBBB-2222 50 +expect "and the other's wrong one is dropped" "paired $TTY1" "" +focus_out viewer2; focus_in viewer AAAA-1111 +expect "until its next focus" "paired $TTY1" AAAA-1111 50 +r set -g @dingger_terminal_ttysgone "1 DDDD-4444 $FAKE_APP" +focus_out viewer; focus_in viewer2 BBBB-2222 +expect "a client no longer attached loses its pairing" "r show -gv @dingger_terminal_ttysgone 2>/dev/null" "" 50 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 |
