all repos — dingger @ b77427190408cbe3ebea5677cd67bce53e73f313

Watches the AI coding agents in your tmux panes on macOS and tells you which need you, from the menu bar, a floating panel, the status line and notifications; jumps to them, and restores tmux sessions and agents after a crash

terminals: ask Ghostty only once focus has settled

across a Space switch (a full screen window) tmux sees the new client
focused about 0.3 s before Ghostty's front window changes, so learn paired
the client with the terminal that had focus. live, raising a full screen
window and back crossed the two pairings, and from then on every jump
raised the wrong window (old binary: 0 of 10 jumps right).

learn now waits a second before asking Ghostty, then still requires the
client to be focused. it also drops pairings of clients no longer attached,
which piled up with every closed window.

e2e step 20: Ghostty lagging behind tmux's focus, crossed pairings healing,
pruning; the old learn fails all five. live, two Ghostty windows, one full
screen: 16 of 16 jumps right, starting from crossed pairings.
Igor Soarez igor@soarez.org
Sun, 25 Oct 2026 06:41:34 +0200
commit

b77427190408cbe3ebea5677cd67bce53e73f313

parent

dd3462c3cd0a7ff8dbb31c5b4e6d133a04506419

2 files changed, 58 insertions(+), 15 deletions(-)

jump to
M crates/dinggerd/src/terminals.rs → crates/dinggerd/src/terminals.rs

@@ -12,6 +12,11 @@ //! `<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. //! +//! 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 @@ })

.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 @@ 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:?})")); - } + 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 @@ 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 { + // 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); } }
M tests/e2e.sh → 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