From 1c6d384f144cf860497f5d4bdd05d430d7d4c4c7 Mon Sep 17 00:00:00 2001 From: 1noilimrev Date: Fri, 27 Feb 2026 14:39:07 +0900 Subject: [PATCH 1/3] fix(hooks): use terminal-notifier for macOS notification click-to-focus --- src/hooks/session-notification-sender.ts | 14 ++++++++++++++ src/hooks/session-notification-utils.ts | 2 ++ 2 files changed, 16 insertions(+) diff --git a/src/hooks/session-notification-sender.ts b/src/hooks/session-notification-sender.ts index 8c5cf1df7..4ac0c4d8c 100644 --- a/src/hooks/session-notification-sender.ts +++ b/src/hooks/session-notification-sender.ts @@ -7,6 +7,7 @@ import { getAfplayPath, getPaplayPath, getAplayPath, + getTerminalNotifierPath, } from "./session-notification-utils" import { buildWindowsToastScript, escapeAppleScriptText, escapePowerShellSingleQuotedText } from "./session-notification-formatting" @@ -39,6 +40,19 @@ export async function sendSessionNotification( ): Promise { switch (platform) { case "darwin": { + // Try terminal-notifier first — deterministic click-to-focus + const terminalNotifierPath = await getTerminalNotifierPath() + if (terminalNotifierPath) { + const bundleId = process.env.__CFBundleIdentifier + const args = [terminalNotifierPath, "-title", title, "-message", message] + if (bundleId) { + args.push("-activate", bundleId) + } + await ctx.$`${args}`.catch(() => {}) + break + } + + // Fallback: osascript (click may open Finder instead of terminal) const osascriptPath = await getOsascriptPath() if (!osascriptPath) return diff --git a/src/hooks/session-notification-utils.ts b/src/hooks/session-notification-utils.ts index 0c09fd8f8..5f9d572fb 100644 --- a/src/hooks/session-notification-utils.ts +++ b/src/hooks/session-notification-utils.ts @@ -32,11 +32,13 @@ export const getPowershellPath = createCommandFinder("powershell") export const getAfplayPath = createCommandFinder("afplay") export const getPaplayPath = createCommandFinder("paplay") export const getAplayPath = createCommandFinder("aplay") +export const getTerminalNotifierPath = createCommandFinder("terminal-notifier") export function startBackgroundCheck(platform: Platform): void { if (platform === "darwin") { getOsascriptPath().catch(() => {}) getAfplayPath().catch(() => {}) + getTerminalNotifierPath().catch(() => {}) } else if (platform === "linux") { getNotifySendPath().catch(() => {}) getPaplayPath().catch(() => {}) From 88bf8268f573e286e1b1d10ccaf0a7d3bfc18a04 Mon Sep 17 00:00:00 2001 From: 1noilimrev Date: Fri, 27 Feb 2026 14:48:34 +0900 Subject: [PATCH 2/3] test(hooks): add darwin notification backend selection tests --- src/hooks/session-notification.test.ts | 95 ++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index cf895ba98..3bee39f0b 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -365,4 +365,99 @@ describe("session-notification", () => { // then - only one notification should be sent expect(notificationCalls).toHaveLength(1) }) + + test("should use terminal-notifier with -activate when available on darwin", async () => { + // given - terminal-notifier is available and __CFBundleIdentifier is set + spyOn(sender, "sendSessionNotification").mockRestore() + const notifyCalls: string[] = [] + const mockCtx = { + $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { + const cmdStr = typeof cmd === "string" + ? cmd + : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + notifyCalls.push(cmdStr) + return { stdout: "", stderr: "", exitCode: 0 } + }, + } as any + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") + const originalEnv = process.env.__CFBundleIdentifier + process.env.__CFBundleIdentifier = "com.mitchellh.ghostty" + + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + + // then - notification uses terminal-notifier with -activate flag + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeDefined() + expect(tnCall).toContain("-activate") + expect(tnCall).toContain("com.mitchellh.ghostty") + + // cleanup + if (originalEnv !== undefined) { + process.env.__CFBundleIdentifier = originalEnv + } else { + delete process.env.__CFBundleIdentifier + } + }) + + test("should fall back to osascript when terminal-notifier is not available", async () => { + // given - terminal-notifier is NOT available + spyOn(sender, "sendSessionNotification").mockRestore() + const notifyCalls: string[] = [] + const mockCtx = { + $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { + const cmdStr = typeof cmd === "string" + ? cmd + : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + notifyCalls.push(cmdStr) + return { stdout: "", stderr: "", exitCode: 0 } + }, + } as any + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null) + spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") + + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + + // then - notification uses osascript (fallback) + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const osascriptCall = notifyCalls.find(c => c.includes("osascript")) + expect(osascriptCall).toBeDefined() + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeUndefined() + }) + + test("should use terminal-notifier without -activate when __CFBundleIdentifier is not set", async () => { + // given - terminal-notifier available but no bundle ID + spyOn(sender, "sendSessionNotification").mockRestore() + const notifyCalls: string[] = [] + const mockCtx = { + $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { + const cmdStr = typeof cmd === "string" + ? cmd + : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + notifyCalls.push(cmdStr) + return { stdout: "", stderr: "", exitCode: 0 } + }, + } as any + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") + const originalEnv = process.env.__CFBundleIdentifier + delete process.env.__CFBundleIdentifier + + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + + // then - terminal-notifier used but without -activate flag + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeDefined() + expect(tnCall).not.toContain("-activate") + + // cleanup + if (originalEnv !== undefined) { + process.env.__CFBundleIdentifier = originalEnv + } + }) + }) From fbe3b5423db3336fcfe0dd3d4ee6dece5e2f75c9 Mon Sep 17 00:00:00 2001 From: 1noilimrev Date: Fri, 27 Feb 2026 15:30:13 +0900 Subject: [PATCH 3/3] refactor(test): extract shared mock helper and add try-finally for env cleanup --- src/hooks/session-notification.test.ts | 86 +++++++++++--------------- 1 file changed, 37 insertions(+), 49 deletions(-) diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index 3bee39f0b..9d9c4706b 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -366,9 +366,7 @@ describe("session-notification", () => { expect(notificationCalls).toHaveLength(1) }) - test("should use terminal-notifier with -activate when available on darwin", async () => { - // given - terminal-notifier is available and __CFBundleIdentifier is set - spyOn(sender, "sendSessionNotification").mockRestore() + function createSenderMockCtx() { const notifyCalls: string[] = [] const mockCtx = { $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { @@ -379,41 +377,40 @@ describe("session-notification", () => { return { stdout: "", stderr: "", exitCode: 0 } }, } as any + return { mockCtx, notifyCalls } + } + + test("should use terminal-notifier with -activate when available on darwin", async () => { + // given - terminal-notifier is available and __CFBundleIdentifier is set + spyOn(sender, "sendSessionNotification").mockRestore() + const { mockCtx, notifyCalls } = createSenderMockCtx() spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") const originalEnv = process.env.__CFBundleIdentifier process.env.__CFBundleIdentifier = "com.mitchellh.ghostty" - // when - sendSessionNotification is called directly on darwin - await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + try { + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") - // then - notification uses terminal-notifier with -activate flag - expect(notifyCalls.length).toBeGreaterThanOrEqual(1) - const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) - expect(tnCall).toBeDefined() - expect(tnCall).toContain("-activate") - expect(tnCall).toContain("com.mitchellh.ghostty") - - // cleanup - if (originalEnv !== undefined) { - process.env.__CFBundleIdentifier = originalEnv - } else { - delete process.env.__CFBundleIdentifier + // then - notification uses terminal-notifier with -activate flag + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeDefined() + expect(tnCall).toContain("-activate") + expect(tnCall).toContain("com.mitchellh.ghostty") + } finally { + if (originalEnv !== undefined) { + process.env.__CFBundleIdentifier = originalEnv + } else { + delete process.env.__CFBundleIdentifier + } } }) test("should fall back to osascript when terminal-notifier is not available", async () => { // given - terminal-notifier is NOT available spyOn(sender, "sendSessionNotification").mockRestore() - const notifyCalls: string[] = [] - const mockCtx = { - $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { - const cmdStr = typeof cmd === "string" - ? cmd - : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - notifyCalls.push(cmdStr) - return { stdout: "", stderr: "", exitCode: 0 } - }, - } as any + const { mockCtx, notifyCalls } = createSenderMockCtx() spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null) spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") @@ -431,33 +428,24 @@ describe("session-notification", () => { test("should use terminal-notifier without -activate when __CFBundleIdentifier is not set", async () => { // given - terminal-notifier available but no bundle ID spyOn(sender, "sendSessionNotification").mockRestore() - const notifyCalls: string[] = [] - const mockCtx = { - $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { - const cmdStr = typeof cmd === "string" - ? cmd - : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - notifyCalls.push(cmdStr) - return { stdout: "", stderr: "", exitCode: 0 } - }, - } as any + const { mockCtx, notifyCalls } = createSenderMockCtx() spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") const originalEnv = process.env.__CFBundleIdentifier delete process.env.__CFBundleIdentifier - // when - sendSessionNotification is called directly on darwin - await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + try { + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") - // then - terminal-notifier used but without -activate flag - expect(notifyCalls.length).toBeGreaterThanOrEqual(1) - const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) - expect(tnCall).toBeDefined() - expect(tnCall).not.toContain("-activate") - - // cleanup - if (originalEnv !== undefined) { - process.env.__CFBundleIdentifier = originalEnv + // then - terminal-notifier used but without -activate flag + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeDefined() + expect(tnCall).not.toContain("-activate") + } finally { + if (originalEnv !== undefined) { + process.env.__CFBundleIdentifier = originalEnv + } } }) - })