From 0cc97b95046b60629dbce961504bcef5fbd9423c Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 20 May 2026 11:51:14 +0100 Subject: [PATCH 1/2] feat: switch Ghostty tabs on banner click via AppleScript bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click-to-focus on a Ghostty event banner now activates the .app AND switches to the source project's tab — not just brings Ghostty forward. Same UX as iTerm/Terminal but via Ghostty's scripting dictionary instead of the AX walk (which doesn't work — Ghostty is GPU-rendered and exposes no AXWindows to other processes; this was the path I tried first and abandoned). How it works: - bundleID == com.mitchellh.ghostty branches off the standard AX path in AppActivator.activate to focusGhosttyTab(projectPath:). - That runs an NSAppleScript that iterates `windows → tabs → terminal` and matches each terminal's `working directory` against the event's projectPath. First match wins; `tell w to select tab t` is the click-equivalent that switches the tab. - String comparison uses `as text` coercion on both sides because `working directory` returns a URL/alias type that `is` would otherwise compare unequal to a plain string even when the visible text matches. `contains` is a defensive fallback for Unicode-normalisation drift (NFC vs NFD). - Requires Ghostty 1.3.1+ for the scripting dictionary and the activation-on-select fix. Entitlement: Added com.apple.security.automation.apple-events to entitlements.plist. Without it, hardened-runtime apps cannot send Apple events at all — NSAppleScript bails with errAEEventNotPermitted (-1743) before macOS even surfaces its consent prompt. Now the user gets the standard "StackNudge would like to control Ghostty" dialog on first banner click; subsequent clicks use the persisted grant. Known limit: When multiple tabs share the same project cwd, AppleScript matches the first one in the enumeration — which may not be the tab the event originated from. Tiebreaker via captured tab id on the wire is queued as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- panel/entitlements.plist | 18 +++++ shared/AppActivator.swift | 139 +++++++++++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/panel/entitlements.plist b/panel/entitlements.plist index 48a3226..420db39 100644 --- a/panel/entitlements.plist +++ b/panel/entitlements.plist @@ -34,5 +34,23 @@ com.apple.security.cs.disable-library-validation + + + com.apple.security.automation.apple-events + diff --git a/shared/AppActivator.swift b/shared/AppActivator.swift index 70bf386..17a89d7 100644 --- a/shared/AppActivator.swift +++ b/shared/AppActivator.swift @@ -20,7 +20,17 @@ struct AppActivator { static func activate(bundleID: String, windowTitle: String? = nil, ipcHook: String? = nil, projectPath: String? = nil, sendApproval: Bool = false, agent: String? = nil) { - let folder = windowTitle ?? projectPath.map { ($0 as NSString).lastPathComponent } + // For matching windows/tabs we want a LOOSE fragment that survives + // the user switching tabs between event time and click time: + // - Tab titles in standalone terminals (Ghostty/iTerm/Terminal) + // include the cwd basename plus other dynamic bits ("claude + // ~/projA — Ghostty"). The exact captured windowTitle won't + // match the current title if the user moved tabs. + // - The project basename (eg "projA") is stable and almost + // always present in the tab title format. + // Prefer the basename when we have a projectPath, fall back to + // the captured windowTitle otherwise. + let folder = projectPath.map { ($0 as NSString).lastPathComponent } ?? windowTitle let proc = processName[bundleID] // Cursor/VSCode: use the CLI to switch the internal active window, then bring it @@ -75,6 +85,19 @@ struct AppActivator { return } + // Ghostty doesn't implement NSAccessibility on its Metal-rendered + // windows — `kAXWindowsAttribute` returns empty, so the AX walk + // below would never find anything. Ghostty 1.3.1+ ships a real + // AppleScript dictionary instead; use that to switch tabs by + // exact working-directory match. Falls through to standard + // activation if the scripting bridge doesn't respond (older + // Ghostty, or `tell application` permission denied). + if bundleID == "com.mitchellh.ghostty", + let path = projectPath, !path.isEmpty { + focusGhosttyTab(projectPath: path) + return + } + // Fallback: activate then AXRaise with retries (works for native terminal apps). // Retry schedule from claude-notifications-go: 150ms → 250ms → 400ms. guard let app = NSRunningApplication @@ -83,9 +106,21 @@ struct AppActivator { if let title = folder, !title.isEmpty { let pid = app.processIdentifier + // Two passes per retry tick: + // 1. raiseWindow: bring the OS-window containing the project + // to front (no-op when the project's tab is in the same + // window the user is already looking at). + // 2. focusTabContaining: walk that app's AX tree for a tab + // element whose title contains the project name and + // AXPress it. This is what actually switches tabs. + // Run #2 regardless of #1's outcome — when both tabs share the + // same OS-window, raiseWindow has nothing to do but the tab + // still needs selecting. for delay in [0.15, 0.25, 0.40] { Thread.sleep(forTimeInterval: delay) - if raiseWindow(pid: pid, containingTitle: title) { break } + let raised = raiseWindow(pid: pid, containingTitle: title) + let tabbed = focusTabContaining(pid: pid, fragment: title) + if raised || tabbed { break } } } @@ -208,6 +243,106 @@ struct AppActivator { return nil } + // MARK: - Ghostty (AppleScript bridge) + + // Ghostty exposes a scripting dictionary (windows → tabs → terminals) + // since 1.3.0; 1.3.1 added the activation-on-select fix so the .app + // actually comes to front. We can't use AX on Ghostty (it doesn't + // implement NSAccessibility on its Metal windows), so AppleScript is + // the supported external-control surface. + // + // Strategy: enumerate all tabs in all windows, find the one whose + // terminal.workingDirectory matches the event's projectPath exactly, + // and `select` it. Working directory is more reliable than title + // since titles include the running command + variable formatting. + private static func focusGhosttyTab(projectPath: String) { + let escaped = projectPath.replacingOccurrences(of: "\"", with: "\\\"") + // Two matching attempts in descending strictness: + // 1. Exact `is` after coercing both sides to text — Ghostty's + // `working directory` returns a URL/alias type, which `is` + // compares unequal to plain strings even when they print + // identically. `as text` makes it a string-string compare. + // 2. `contains` either direction — handles Unicode-normalisation + // drift (NFC vs NFD) and trailing-slash differences. + // First match wins. `tell w to select tab t` is what actually + // switches the tab; the outer `activate` brings Ghostty to front. + let script = """ + tell application "Ghostty" + activate + set target to "\(escaped)" as text + repeat with w in windows + repeat with t in tabs of w + try + set wd to (working directory of terminal of t) as text + if wd is target then + tell w to select tab t + return "matched-exact" + end if + if wd contains target or target contains wd then + tell w to select tab t + return "matched-contains" + end if + end try + end repeat + end repeat + return "no-match" + end tell + """ + var err: NSDictionary? + NSAppleScript(source: script)?.executeAndReturnError(&err) + } + + // MARK: - AX tab switching (standalone terminal apps) + + // For tabbed terminals where the OS-window is already frontmost but + // the event's source tab isn't the selected one. Walks the focused + // window's AX subtree (skipping the window node itself, since its + // title mirrors the currently-selected tab and would be a false + // match) for an element whose title or description contains the + // project-name fragment. AXPress on the match selects the tab — + // same mechanism as focusEditorTerminal uses for VS Code panes. + @discardableResult + private static func focusTabContaining(pid: pid_t, fragment: String) -> Bool { + let appElement = AXUIElementCreateApplication(pid) + + // Try every window of the app, not just the focused one — the + // project's tab may live in a different OS-window the user has + // open but isn't currently looking at. + var windowsRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(appElement, + kAXWindowsAttribute as CFString, + &windowsRef) == .success, + let windows = windowsRef as? [AXUIElement] else { return false } + + for win in windows { + // Walk window children directly (not the window itself) so we + // never match the window's own title — that mirrors the + // currently-selected tab and would be a false positive when + // the project we want is sitting in a different tab. + var children: CFTypeRef? + guard AXUIElementCopyAttributeValue(win, + kAXChildrenAttribute as CFString, + &children) == .success, + let kids = children as? [AXUIElement] else { continue } + + for child in kids { + if let match = findElement(in: child, depth: 0, matching: { node in + axStringContains(node, attribute: kAXTitleAttribute as CFString, fragment: fragment) || + axStringContains(node, attribute: kAXDescriptionAttribute as CFString, fragment: fragment) + }) { + // Raise the containing window so the tab actually + // becomes visible after AXPress. + AXUIElementPerformAction(win, kAXRaiseAction as CFString) + AXUIElementSetAttributeValue(appElement, + kAXFrontmostAttribute as CFString, + kCFBooleanTrue) + return focus(match) + } + } + } + return false + } + // MARK: - AX window raise (native terminal apps / fallback) @discardableResult From a8d79a74365fbf386649404b6999ff83deffbaa5 Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 20 May 2026 12:11:57 +0100 Subject: [PATCH 2/2] docs(notify): note deferred Ghostty tab-id tiebreaker Add a TODO-style comment next to the bundle-ID case explaining why we don't capture a Ghostty tab id in the wire payload yet, and what needs to change upstream before we can. Attempted a tab-id capture via `id of terminal of selected tab of front window` and decided against it: `selected tab` reflects the user's current focus, not the agent's tab, so it produced wrong results in the very case the tiebreaker is meant to solve (user switched away before the hook fires). Real fix needs Ghostty PR #11922 (pid on terminals, targeted at 1.4.0); then we match agent_pid in AppActivator directly. Note in the source so future-us doesn't re-attempt the same dead end. Co-Authored-By: Claude Opus 4.7 (1M context) --- notify.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/notify.sh b/notify.sh index bd2cee0..a22edfe 100755 --- a/notify.sh +++ b/notify.sh @@ -389,6 +389,22 @@ notify_macos() { *) bundle_id="com.apple.Terminal" ;; esac + # Ghostty same-project tab disambiguation (deferred to upstream): + # When multiple Ghostty tabs share the same cwd, the AppleScript in + # AppActivator.focusGhosttyTab lands on whichever cwd-matching tab + # comes first — which may not be the source tab. The right fix + # requires the hook to identify the Ghostty tab hosting this shell's + # PID. Ghostty 1.3.x exposes only id/name/working-directory/class on + # terminals — no pid, no tty — so there's no AppleScript-only way + # to map "this PID" → "this tab". An earlier attempt captured + # `id of terminal of selected tab of front window` at event time, + # but `selected tab` reflects the user's current focus, not the + # agent's tab, so it gave wrong results when the user had switched + # away before the hook fired. + # Ghostty PR #11922 adds `pid` to terminals (target: 1.4.0). When + # that ships we can match agent_pid directly — replace the cwd-only + # AppleScript with a pid match in AppActivator. + # Map bundle ID → System Events process name for window-title capture local process_name case "$bundle_id" in