Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed

- macOS focus suppression recognizes terminals when `lsappinfo` returns a multiline application report and falls back to System Events when its output cannot be parsed.

## [0.8.2] - 2026-10-14

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,8 @@ tmux source-file ~/.tmux.conf

When `suppressWhenFocused` is `true` (the default), popups, sounds, bells, and V1 event commands are skipped when the terminal running OpenCode is focused. Supported terminal and multiplexer pane checks also apply. V2 server event commands run independently of local focus.

On macOS, focus detection reads the app name from either the `lsappinfo` name=value output or its multiline application report. If no valid name can be extracted, it falls back to AppleScript (`System Events`). Failed queries keep notifications enabled.

To disable this and always get notified:

```json
Expand Down Expand Up @@ -507,7 +509,7 @@ Work from third-party delegation plugins without native child-session events, or

| Platform | Method | Requirements | Status |
| ---------------------------------------- | ---------------------------------------- | --------------------- | ------------------------------ |
| macOS | AppleScript (`System Events`) | None | Untested |
| macOS | `lsappinfo`, then AppleScript (`System Events`) | None | Untested |
| Linux X11 | `xdotool` | `xdotool` installed | Untested |
| Linux Wayland (Hyprland) | `hyprctl activewindow`; legacy or Lua (0.55+) `dispatch` for click-to-focus | None | Tested (0.56.2, Lua config) |
| Linux Wayland (Niri) | `niri msg --json focused-window` | None | Tested |
Expand Down
42 changes: 41 additions & 1 deletion src/focus.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,47 @@
import { describe, test, expect } from "bun:test"
import { isZellijPaneFocused } from "./focus"
import { isZellijPaneFocused, parseMacOSFrontmostAppName } from "./focus"
import { isLinuxTerminalFocused, isMacTerminalAppFocused, isTmuxPaneFocused, parseWezTermFocusedPaneId, isKDEJumpBackSupported, captureStartupWindowId, focusTerminal, getCachedWindowTitle, buildOsascriptActivateAppArgs, findQdbusBinary, resolveQdbusBinary, isGnomeLikeSession, getLinuxFocusBackendName, parseAtspiString, parseAtspiObjectRefs, parseAtspiStateActive, isAtspiTerminalWindow, isAtspiWindowRoleAccepted } from "./focus"

describe("parseMacOSFrontmostAppName", () => {
test("parses the standard name=value output", () => {
expect(parseMacOSFrontmostAppName('"LSDisplayName"="Ghostty"')).toBe("Ghostty")
expect(parseMacOSFrontmostAppName('"name"="iTerm2"')).toBe("iTerm2")
})

test("parses the multiline report observed on macOS", () => {
const output = [
'"Ghostty" ASN:0x0-0x8d08d: (in front) ',
' bundleID=[ NULL ] ',
' bundle path=[ NULL ] ',
' executable path=[ NULL ] ',
' !cgsConnection !signalled type=[ NULL ] flavor=[ NULL ] Version=[ NULL ] Arch=!!none ',
].join("\n")

expect(parseMacOSFrontmostAppName(output)).toBe("Ghostty")
})

test("preserves spaces and decodes escaped quotes", () => {
expect(parseMacOSFrontmostAppName('"LSDisplayName"="Visual Studio Code"')).toBe("Visual Studio Code")
expect(parseMacOSFrontmostAppName(String.raw`"LSDisplayName"="Terminal \"Work\""`)).toBe('Terminal "Work"')
})

test("rejects unavailable, empty, malformed, or unrecognized output", () => {
for (const output of [
null,
"",
" ",
"unrecognized report",
'"LSDisplayName"=""',
'"LSDisplayName"=" "',
String.raw`"LSDisplayName"="Invalid \q"`,
'"LSDisplayName"="Ghostty" unexpected text',
'"CFBundleIdentifier"="com.example.app"',
]) {
expect(parseMacOSFrontmostAppName(output)).toBe(null)
}
})
})

describe("isMacTerminalAppFocused", () => {
test("matches Terminal when TERM_PROGRAM is Apple_Terminal", () => {
const env = { TERM_PROGRAM: "Apple_Terminal" }
Expand Down
26 changes: 21 additions & 5 deletions src/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,15 +387,31 @@ function getMacOSActiveWindowId(): string | null {
)
}

export function parseMacOSFrontmostAppName(output: string | null): string | null {
if (!output) return null

// lsappinfo may return a full application report instead of a name=value line.
const match =
output.match(/^\s*"(?:name|LSDisplayName)"\s*=\s*("(?:\\.|[^"\\])*")\s*$/m) ??
output.match(/^\s*("(?:\\.|[^"\\])*")\s+ASN:/m)
if (!match) return null

try {
const name: unknown = JSON.parse(match[1])
return typeof name === "string" && name.trim().length > 0 ? name : null
} catch {
return null
}
}

function getMacOSFrontmostAppName(): string | null {
// Detect frontmost app regardless of activation policy
// (apps excluded from Dock and App Switcher)
const lsappinfo = execWithTimeout(
`lsappinfo info -only name \`lsappinfo front\` | sed 's/.*="\\([^"]*\\)".*/\\1/'`
)
if (lsappinfo) return lsappinfo
const output = execWithTimeout("lsappinfo info -only name $(lsappinfo front)")
const name = parseMacOSFrontmostAppName(output)
if (name !== null) return name

// Fallback to System Events if lsappinfo is unavailable
// Fall back when lsappinfo is unavailable or its output cannot be parsed.
return execWithTimeout(
`osascript -e 'tell application "System Events" to return name of first application process whose frontmost is true'`
)
Expand Down
Loading