Skip to content
Open
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
21 changes: 21 additions & 0 deletions examples/terminal-notifications/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Contributors to this example

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
110 changes: 110 additions & 0 deletions examples/terminal-notifications/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Proposal: terminal-owned macOS notifications

This is an original, standalone reference implementation for discussion. It does
not change the shipped Copilot CLI or modify its installed files. Integration
into the actual notification implementation requires a maintainer change.

## Problem

On macOS in Ghostty, clicking a Copilot CLI desktop notification can open Script
Editor instead of returning to the terminal.

This was observed with the unmodified
[1.0.84-1 prerelease](https://github.com/github/copilot-cli/releases/tag/v1.0.84-1).
Comparison with the official release ruled out a stale local notification patch.
The macOS notification path uses AppleScript, which attributes the notification
to the scripting host rather than the originating terminal.

To reproduce:

1. Enable desktop notifications and start an interactive Copilot CLI session in Ghostty.
2. Submit a request and switch to another application.
3. Wait for a completion or attention notification.
4. Click it.

Expected: return to the terminal, without opening Script Editor.

## Proposed behavior

For supported local macOS terminals, prefer a notification protocol implemented
by the terminal itself:

| Terminal | Protocol |
| --- | --- |
| Ghostty | OSC 777 |
| iTerm2 | OSC 9 |

The terminal owns these notifications and handles their click actions.

[`terminal-notifications.mjs`](./terminal-notifications.mjs) provides:

- `selectTerminalTarget(context)`: select a supported terminal and writable TTY.
- `encodeTerminalNotification(payload, protocol)`: encode sanitized notification text.
- `showTerminalNotification(payload, context)`: emit a notification and return
`"sent"`, `"unsupported"`, or `"disabled"`; reject on invalid text or write errors.

The optional context defaults to `process` and exposes `platform`, `env`,
`stdout`, and `stderr`. It also makes the implementation independently testable.

## Integration requirements

Keep the existing notification setting, event formatting, focus gate, rate
limiting, and in-flight deduplication. Keep an existing protocol-aware host or
multiplexer notification backend ahead of this terminal path.

Before native delivery, try the terminal backend. On `"unsupported"`, use the
existing native backend. On an exception, log the failure and use the existing
native backend. On `"disabled"`, do not deliver through another backend.

Only mark a notification as sent after the selected backend reports success.
Do not require the native backend to be available before trying an independently
supported terminal backend.

The example removes C0/C1 control characters, DEL, and field separators from
notification text. It writes one complete sequence only to a writable TTY,
preferring stdout and then stderr. It awaits the write callback: a `false`
return value from `write()` means backpressure, not delivery failure.

## Scope and tradeoffs

Linux, Windows, unknown terminals, SSH, tmux, screen, and unsupported multiplexer
contexts return `"unsupported"`. Passthrough support should be implemented and
tested explicitly, not guessed from inherited environment variables.

Terminal notification permissions, sounds, grouping, urgency, and foreground
suppression can differ from native notifications. Existing notification IDs,
timeouts, and urgency fields cannot all be represented by OSC 777/9. Preserve
existing payload length limits in the caller.

`"sent"` confirms the sequence was written, not that the OS displayed it.
Disabled terminal notification permissions cannot be detected from a successful
TTY write. Users must enable notifications for the terminal.

For terminals without a suitable protocol, a supported native notification
helper with explicit terminal activation is a separate possible solution.

## Validation

The example has no external dependencies. Run its tests with Node.js:

```sh
node --test examples/terminal-notifications/terminal-notifications.test.mjs
```

The tests cover protocol selection, unsupported environments, disabled
notifications, redirected output, control-character sanitization, backpressure,
and synchronous/asynchronous write errors. They do not launch Copilot, load its
runtime, read user configuration, or send real desktop notifications.

In a separate macOS desktop acceptance run using this backend, the notification's
default click action brought Ghostty to the foreground from another application
without opening Script Editor. That run covered one Ghostty window, not iTerm2
or multiple-window behavior. Those cases still need maintainer validation before
integrating a product change.

## License

Only the original files in this example directory are offered under the
[MIT license](./LICENSE). No Copilot runtime source, binaries, local installation
wrappers, or user configuration are included. This example's license does not
change the license of Copilot CLI.
97 changes: 97 additions & 0 deletions examples/terminal-notifications/terminal-notifications.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: MIT

export function selectTerminalTarget(context = process) {
const { platform, env, stdout, stderr } = context;
if (
platform !== "darwin" ||
env.TERM === "dumb" ||
env.TMUX ||
env.STY ||
/^(?:screen|tmux)(?:[.-]|$)/i.test(env.TERM ?? "") ||
env.SSH_CONNECTION ||
env.SSH_CLIENT ||
env.SSH_TTY ||
env.HERDR_ENV ||
env.HERDR_PANE_ID ||
env.HERDR_SOCKET_PATH ||
["tmux", "herdr"].includes(env.COPILOT_MULTIPLEXER)
) {
return undefined;
}

const terminal = (env.TERM_PROGRAM ?? "").toLowerCase();
let protocol;
if (terminal === "ghostty" || (!terminal && env.TERM === "xterm-ghostty")) {
protocol = "osc777";
} else if (terminal === "iterm.app") {
protocol = "osc9";
} else {
return undefined;
}

const stream = [stdout, stderr].find(
(candidate) =>
candidate?.isTTY === true &&
candidate.writable !== false &&
!candidate.writableEnded &&
!candidate.destroyed,
);
return stream ? { protocol, stream } : undefined;
}

export function encodeTerminalNotification(payload, protocol) {
const sanitize = (value) => {
if (typeof value !== "string") {
throw new TypeError("Notification text must be a string");
}
return value
.replace(/[\x00-\x1f\x7f-\x9f;]/g, " ")
.replace(/\s+/gu, " ")
.trim();
};

const title = sanitize(payload.summary);
if (!title) {
throw new TypeError("Notification summary must not be empty");
}
const details = [payload.subtitle, payload.body]
.filter((value) => value !== undefined)
.map(sanitize)
.filter(Boolean)
.join(" - ");

if (protocol === "osc777") {
return `\x1b]777;notify;${title};${details}\x07`;
}
if (protocol === "osc9") {
return `\x1b]9;${[title, details].filter(Boolean).join(": ")}\x07`;
}
throw new RangeError(`Unsupported notification protocol: ${protocol}`);
}

export async function showTerminalNotification(payload, context = process) {
if (context.env.COPILOT_DISABLE_DESKTOP_NOTIFICATIONS === "1") {
return "disabled";
}
const target = selectTerminalTarget(context);
if (!target) {
return "unsupported";
}

const sequence = encodeTerminalNotification(payload, target.protocol);
let onError;
try {
await new Promise((resolve, reject) => {
onError = reject;
target.stream.once("error", onError);
// A false write result is backpressure, not a failed notification.
target.stream.write(sequence, (error) => {
if (error) reject(error);
else resolve();
});
});
} finally {
if (onError) target.stream.removeListener("error", onError);
}
return "sent";
}
Loading