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
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