fix: non-macOS notification focus detection#71
Conversation
…r platforms terminalIsFocused() now uses xdotool on Linux to detect terminal focus. On Windows and other unsupported platforms, defaults to "focused" to prevent spurious notifications. Closes #68 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
This PR doesn't fully meet our contributing guidelines and PR template. What needs to be fixed:
Please edit this PR description to address the above within 2 hours, or it will be automatically closed. If you believe this was flagged incorrectly, please let a maintainer know. |
There was a problem hiding this comment.
Pull request overview
Fixes non-macOS notification behavior by changing how “terminal focus” is detected so notifications don’t fire while the user is actively in a terminal on supported platforms.
Changes:
- Extend
Notification.terminalIsFocused()to handle Linux/Windows/unknown platforms differently. - Add Linux
xdotool-based active-window PID probing for focus detection. - Add unit tests covering win32/unsupported and several Linux
xdotooloutput cases.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/opencode/src/notification/index.ts | Updates cross-platform terminal focus detection logic used to suppress notifications while focused. |
| packages/opencode/test/notification.test.ts | Adds tests for platform-specific terminalIsFocused() behavior and Linux xdotool parsing paths. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (result.code !== 0) return true | ||
| const pid = parseInt(result.text.trim(), 10) | ||
| if (isNaN(pid)) return true | ||
| return pid === process.pid || pid === process.ppid |
There was a problem hiding this comment.
Linux focus detection compares the active window PID from xdotool getactivewindow getwindowpid to process.pid/process.ppid. That PID is the GUI window owner (e.g. gnome-terminal, alacritty, code), not the Node/Bun process running inside the terminal, so this will almost always return false even when the terminal is focused (and notifications will keep firing).
Consider mapping the returned PID to its executable/comm (e.g. read /proc/<pid>/comm or /proc/<pid>/exe, or ps -p <pid> -o comm=) and running it through the existing terminal() normalization, or alternatively use xdotool to query the active window class/name and compare via terminal().
| return pid === process.pid || pid === process.ppid | |
| const processResult = await Process.text( | |
| ["ps", "-p", String(pid), "-o", "comm="], | |
| { nothrow: true }, | |
| ) | |
| if (processResult.code !== 0) return true | |
| const activeProcess = processResult.text.trim() | |
| if (!activeProcess) return true | |
| return terminal(activeProcess) |
| test("linux: returns true when xdotool returns matching PID", async () => { | ||
| ;(Process as Record<string, unknown>).text = mock(async () => ({ | ||
| code: 0, | ||
| stdout: Buffer.from(String(process.pid) + "\n"), | ||
| stderr: Buffer.alloc(0), | ||
| text: String(process.pid) + "\n", | ||
| })) | ||
| const result = await Notification.terminalIsFocused("linux") | ||
| expect(result).toBe(true) | ||
| }) | ||
|
|
||
| test("linux: returns false when xdotool returns non-matching PID", async () => { | ||
| const fakePid = 99999 | ||
| ;(Process as Record<string, unknown>).text = mock(async () => ({ | ||
| code: 0, | ||
| stdout: Buffer.from(String(fakePid) + "\n"), | ||
| stderr: Buffer.alloc(0), | ||
| text: String(fakePid) + "\n", | ||
| })) |
There was a problem hiding this comment.
The Linux tests assume xdotool ... getwindowpid returns process.pid, but in practice that command returns the PID of the active window owner (terminal emulator / editor), not the PID of the CLI process running inside the terminal. This makes the test pass while masking the likely real-world failure mode.
After updating Linux focus detection (e.g., resolve PID -> executable name and call terminal()), adjust the tests to stub realistic xdotool output and assert against that behavior.
| test("linux: returns true when xdotool returns matching PID", async () => { | |
| ;(Process as Record<string, unknown>).text = mock(async () => ({ | |
| code: 0, | |
| stdout: Buffer.from(String(process.pid) + "\n"), | |
| stderr: Buffer.alloc(0), | |
| text: String(process.pid) + "\n", | |
| })) | |
| const result = await Notification.terminalIsFocused("linux") | |
| expect(result).toBe(true) | |
| }) | |
| test("linux: returns false when xdotool returns non-matching PID", async () => { | |
| const fakePid = 99999 | |
| ;(Process as Record<string, unknown>).text = mock(async () => ({ | |
| code: 0, | |
| stdout: Buffer.from(String(fakePid) + "\n"), | |
| stderr: Buffer.alloc(0), | |
| text: String(fakePid) + "\n", | |
| })) | |
| test("linux: returns true when xdotool returns a terminal window PID and it resolves to a known terminal app", async () => { | |
| const activeWindowPid = 4242 | |
| ;(Process as Record<string, unknown>).text = mock(async (...args: unknown[]) => { | |
| const command = args.map(String).join(" ") | |
| if (command.includes("xdotool") && command.includes("getwindowpid")) { | |
| return { | |
| code: 0, | |
| stdout: Buffer.from(String(activeWindowPid) + "\n"), | |
| stderr: Buffer.alloc(0), | |
| text: String(activeWindowPid) + "\n", | |
| } | |
| } | |
| return { | |
| code: 0, | |
| stdout: Buffer.from("Visual Studio Code\n"), | |
| stderr: Buffer.alloc(0), | |
| text: "Visual Studio Code\n", | |
| } | |
| }) | |
| const result = await Notification.terminalIsFocused("linux") | |
| expect(result).toBe(true) | |
| }) | |
| test("linux: returns false when xdotool returns a window-owner PID that resolves to a non-terminal app", async () => { | |
| const activeWindowPid = 99999 | |
| ;(Process as Record<string, unknown>).text = mock(async (...args: unknown[]) => { | |
| const command = args.map(String).join(" ") | |
| if (command.includes("xdotool") && command.includes("getwindowpid")) { | |
| return { | |
| code: 0, | |
| stdout: Buffer.from(String(activeWindowPid) + "\n"), | |
| stderr: Buffer.alloc(0), | |
| text: String(activeWindowPid) + "\n", | |
| } | |
| } | |
| return { | |
| code: 0, | |
| stdout: Buffer.from("Xcode\n"), | |
| stderr: Buffer.alloc(0), | |
| text: "Xcode\n", | |
| } | |
| }) |
What
Linux でのターミナルフォーカス検出を追加、Windows は通知許可、未知の OS は通知抑制。
Why
terminalIsFocused() が macOS 以外で常に false を返し通知が常に発火していた。
Closes #68
Changes
Review
Test plan