aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/dinggerd/src/terminals.rs53
-rwxr-xr-xtests/e2e.sh20
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