fix(notify): restrict OSC to native-rendering terminals; hint osascript perms

Research finding: terminfo.dev "support" for OSC 9/777 only means the parser
consumes the sequence — VS Code/Cursor and Apple Terminal silently drop it
without rendering anything (microsoft/vscode#294247, anthropics/claude-code#28338).
Emitting OSC there made notifications no-op AND skipped the OS fallback.

- _detect_terminal_osc now returns a flavor only for terminals that actually
  render: iTerm2, Ghostty, kitty, WezTerm. Everything else (VS Code/Cursor,
  Apple Terminal, unknown) falls through to the osascript path. VS Code/Cursor
  users wanting click-to-focus can install the "Terminal Notification"
  extension, which parses the OSC we already emit — documented, not assumed.
- Add a one-time WARNING when the osascript fallback runs: on macOS Sequoia+,
  osascript notifications are attributed to "Script Editor" and silently
  dropped (exit 0, nothing shown) until the user grants Script Editor
  notification permission once. The hint spells out the fix so users aren't
  stuck staring at a no-op.
This commit is contained in:
Brooklyn Nicholson 2026-06-18 15:33:00 -05:00
parent b59b1cfb12
commit 82b9c44cbd
2 changed files with 52 additions and 5 deletions

View File

@ -81,9 +81,11 @@ def test_consume_is_scoped_to_its_session(home, monkeypatch):
[ [
({"TERM_PROGRAM": "iTerm.app"}, "osc9"), ({"TERM_PROGRAM": "iTerm.app"}, "osc9"),
({"TERM_PROGRAM": "WarpTerminal"}, "osc9"), ({"TERM_PROGRAM": "WarpTerminal"}, "osc9"),
({"TERM_PROGRAM": "vscode"}, "osc777"),
({"KITTY_WINDOW_ID": "1"}, "osc9"), ({"KITTY_WINDOW_ID": "1"}, "osc9"),
({"WEZTERM_PANE": "0"}, "osc777"), ({"WEZTERM_PANE": "0"}, "osc777"),
# VS Code / Cursor and Apple Terminal parse but DON'T render OSC
# notifications — must fall through to the OS-level path.
({"TERM_PROGRAM": "vscode"}, None),
({"TERM_PROGRAM": "Apple_Terminal"}, None), ({"TERM_PROGRAM": "Apple_Terminal"}, None),
({}, None), ({}, None),
], ],
@ -152,6 +154,16 @@ def test_desktop_notification_prefers_terminal_over_os(monkeypatch):
assert macos_called == [] assert macos_called == []
def test_osascript_permission_hint_fires_once(monkeypatch, caplog):
import logging
monkeypatch.setattr(notify_utils, "_OSASCRIPT_HINT_SHOWN", False)
with caplog.at_level(logging.WARNING, logger=notify_utils.logger.name):
notify_utils._osascript_permission_hint_once()
notify_utils._osascript_permission_hint_once()
hits = [r for r in caplog.records if "Script Editor" in r.getMessage()]
assert len(hits) == 1
def test_macos_prefers_terminal_notifier_when_present(monkeypatch): def test_macos_prefers_terminal_notifier_when_present(monkeypatch):
runs = [] runs = []
monkeypatch.setattr(notify_utils.shutil, "which", monkeypatch.setattr(notify_utils.shutil, "which",

View File

@ -225,10 +225,38 @@ def _show_notification_macos(title: str, message: str) -> None:
timeout=5, capture_output=True, timeout=5, capture_output=True,
) )
logger.debug("notify: macOS notification sent via osascript") logger.debug("notify: macOS notification sent via osascript")
_osascript_permission_hint_once()
except Exception as e: except Exception as e:
logger.debug("notify: osascript notification failed: %s", e) logger.debug("notify: osascript notification failed: %s", e)
_OSASCRIPT_HINT_SHOWN = False
def _osascript_permission_hint_once() -> None:
"""Warn once that osascript notifications need Script Editor permission.
On macOS Sequoia+, ``osascript display notification`` is attributed to
``com.apple.ScriptEditor2`` and is silently dropped until the user grants
Script Editor notification permission the command still exits 0, so a
user sees "nothing happened" with no error. Surface the one-time fix so
they aren't stuck. (Native terminals using the OSC path never reach here.)
"""
global _OSASCRIPT_HINT_SHOWN
if _OSASCRIPT_HINT_SHOWN:
return
_OSASCRIPT_HINT_SHOWN = True
logger.warning(
"Desktop notifications use osascript on this terminal, which macOS "
"delivers as 'Script Editor' — banners are suppressed until you grant "
"permission once: run `open -a 'Script Editor'`, execute "
"`display notification \"test\" with title \"test\"` inside it, click "
"Allow, then check System Settings > Notifications > Script Editor. "
"For native banners, run Hermes in iTerm2/Ghostty/kitty/WezTerm, or "
"install the VS Code 'Terminal Notification' extension for Cursor."
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Terminal-native notifications (OSC escape sequences) # Terminal-native notifications (OSC escape sequences)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -253,8 +281,16 @@ def _show_notification_macos(title: str, message: str) -> None:
def _detect_terminal_osc() -> Optional[str]: def _detect_terminal_osc() -> Optional[str]:
"""Return the OSC notification flavor for the current terminal, or None. """Return the OSC notification flavor for the current terminal, or None.
None means "unknown / unsupported (e.g. Apple Terminal)" the caller Only terminals that actually *render* an OS notification from the escape
should fall back to an OS-level notifier. sequence are listed. terminfo.dev marks many terminals as "supporting"
OSC 9/777, but that only means their parser consumes the sequence VS
Code/Cursor and Apple Terminal silently drop it without showing anything
(confirmed: microsoft/vscode#294247, anthropics/claude-code#28338). Those
return None so the caller falls back to an OS-level notifier (osascript).
VS Code / Cursor users who want click-to-focus can install the "Terminal
Notification" extension (it parses OSC 9/777 from the terminal stream);
that's a user-side opt-in, not something we can assume here.
""" """
if os.environ.get("KITTY_WINDOW_ID"): if os.environ.get("KITTY_WINDOW_ID"):
return "osc9" # kitty also speaks the legacy OSC 9 return "osc9" # kitty also speaks the legacy OSC 9
@ -264,9 +300,8 @@ def _detect_terminal_osc() -> Optional[str]:
return { return {
"iTerm.app": "osc9", "iTerm.app": "osc9",
"WarpTerminal": "osc9", "WarpTerminal": "osc9",
"Hyper": "osc9",
"ghostty": "osc777", "ghostty": "osc777",
"vscode": "osc777", # VS Code AND Cursor integrated terminals "WezTerm": "osc777",
}.get(tp) }.get(tp)