From 83dab1ec8a6815a737423e8e25ac8907d142a146 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Tue, 22 Sep 2026 00:56:58 +0200 Subject: [PATCH] fix(agent-isolation): close the macOS touch overlay when it loses the keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit take_focus takes the keyboard once, on the way up, and the window is -topmost. Anything that takes it afterwards — a notification, another application activating, the terminal being clicked — left the overlay painted over the screen with Esc going somewhere else and nothing but a kill to get rid of it. Bind so the window closes instead. Not : Tk's focus events track the keyboard moving between widgets inside one application, and nothing moves between widgets here, so it never fires when another application takes over. The binding is deferred past a short grace period because activation itself churns focus — bound immediately, the window closes on its own way up. Re-grabbing the keyboard would make Esc always work too, but this window deliberately does not trap the screen: the key still has to be touched for the command to go through. Generated-by: Claude Opus 5 --- .../gpg-touch-overlay-window-macos.py | 31 +++++++++++++++- .../tests/test_gpg_touch_overlay.py | 37 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/tools/agent-isolation/gpg-touch-overlay-window-macos.py b/tools/agent-isolation/gpg-touch-overlay-window-macos.py index d8bb6f40..53b2f971 100644 --- a/tools/agent-isolation/gpg-touch-overlay-window-macos.py +++ b/tools/agent-isolation/gpg-touch-overlay-window-macos.py @@ -42,8 +42,11 @@ showing that should be the overlay, which ignores them — see ``take_focus``. -Closes on Esc or a click — the key must still be touched for the command -to go through, so trapping the screen would buy nothing. +Closes on Esc, on a click, or as soon as it loses focus — the key must +still be touched for the command to go through, so trapping the screen +would buy nothing. Closing on focus loss is what makes that last part +safe: the keyboard is taken once, on the way up, and never grabbed back, +so a window that is no longer listening is never left on screen. """ import ctypes @@ -77,6 +80,9 @@ PULSE_MS = 33 PULSE_PERIOD = 1.9 # seconds, one ripple's whole travel STOP_POLL_MS = 60 +# Long enough to cover the focus churn of activating the application, short +# enough that a window losing focus to something else is still caught. +FOCUS_GRACE_MS = 750 FONT = "Helvetica Neue" MONO = "Menlo" @@ -302,6 +308,27 @@ def build_window(): # around it. root.update() take_focus(root) + + # ``take_focus`` runs once, and the window is ``-topmost``: anything + # that takes the keyboard afterwards — a notification, another app + # activating, the terminal being clicked — leaves it painted over the + # screen with Esc going somewhere else, and nothing but a kill to get + # rid of it. Closing on focus loss is what keeps that state + # unreachable. Re-grabbing the keyboard instead would trap the screen, + # which this window deliberately does not do. + # + # ````, not ````: Tk's focus events track the + # keyboard moving between widgets inside one application, and nothing + # moves between widgets here. Losing the keyboard to a *different* + # application is what has to be caught, and on Aqua that arrives as + # ```` on the toplevel. + # + # The binding is deferred because activation itself churns focus: bound + # immediately, the window closes on its own way up. + root.after( + FOCUS_GRACE_MS, + lambda: root.bind("", lambda _event: root.destroy()), + ) return root diff --git a/tools/agent-isolation/tests/test_gpg_touch_overlay.py b/tools/agent-isolation/tests/test_gpg_touch_overlay.py index b55ca45f..a8b0d19e 100644 --- a/tools/agent-isolation/tests/test_gpg_touch_overlay.py +++ b/tools/agent-isolation/tests/test_gpg_touch_overlay.py @@ -1180,3 +1180,40 @@ def test_the_two_windows_share_one_copy_of_the_context_helpers() -> None: assert set(found) == set(wanted), f"{window.name} is missing {wanted}" helpers.append([found[name] for name in wanted]) assert helpers[0] == helpers[1], "the two windows' context helpers have drifted" + + +def test_the_aqua_window_closes_when_it_loses_the_keyboard() -> None: + """An overlay that is no longer listening must not stay on screen. + + ``take_focus`` takes the keyboard once, on the way up, and the window + is ``-topmost``. Anything that takes the keyboard afterwards leaves it + painted over the screen with Esc going elsewhere and nothing but a + kill to get rid of it — which is what this binding exists to prevent. + + It has to be ````: Tk's focus events track the keyboard + moving between widgets inside one application, and nothing moves + between widgets here. ```` does not fire when a different + application takes over, so binding it instead reads correct and does + nothing. + """ + window = SCRIPT.parent / "gpg-touch-overlay-window-macos.py" + tree = ast.parse(window.read_text()) + build = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "build_window" + ) + bound = { + node.args[0].value + for node in ast.walk(build) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "bind" + and node.args + and isinstance(node.args[0], ast.Constant) + } + assert "" in bound, ( + "the Aqua overlay does not close when another application takes the " + f"keyboard; it binds only {sorted(bound)}" + ) + assert "" in bound, "the Aqua overlay no longer closes on Esc"