diff --git a/examples/terminal-notifications/LICENSE b/examples/terminal-notifications/LICENSE new file mode 100644 index 00000000..29c28fbe --- /dev/null +++ b/examples/terminal-notifications/LICENSE @@ -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. diff --git a/examples/terminal-notifications/README.md b/examples/terminal-notifications/README.md new file mode 100644 index 00000000..e4ac2585 --- /dev/null +++ b/examples/terminal-notifications/README.md @@ -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. diff --git a/examples/terminal-notifications/terminal-notifications.mjs b/examples/terminal-notifications/terminal-notifications.mjs new file mode 100644 index 00000000..04a843fe --- /dev/null +++ b/examples/terminal-notifications/terminal-notifications.mjs @@ -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"; +} diff --git a/examples/terminal-notifications/terminal-notifications.test.mjs b/examples/terminal-notifications/terminal-notifications.test.mjs new file mode 100644 index 00000000..299aa854 --- /dev/null +++ b/examples/terminal-notifications/terminal-notifications.test.mjs @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: MIT + +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { Writable } from "node:stream"; +import test from "node:test"; +import { + encodeTerminalNotification, + selectTerminalTarget, + showTerminalNotification, +} from "./terminal-notifications.mjs"; + +function tty(options = {}) { + const chunks = []; + const stream = new Writable({ + write(chunk, encoding, done) { + chunks.push(chunk.toString()); + done(); + }, + ...options, + }); + stream.isTTY = true; + return { stream, chunks }; +} + +function context(overrides = {}) { + return { + platform: "darwin", + env: { TERM_PROGRAM: "ghostty", TERM: "xterm-ghostty" }, + stdout: tty().stream, + stderr: tty().stream, + ...overrides, + }; +} + +const payload = { summary: "Copilot", subtitle: "project", body: "Agent finished" }; + +test("Ghostty uses OSC 777 and keeps the title, subtitle, and body", async () => { + const { stream, chunks } = tty(); + assert.equal( + await showTerminalNotification(payload, context({ stdout: stream })), + "sent", + ); + assert.deepEqual(chunks, ["\x1b]777;notify;Copilot;project - Agent finished\x07"]); + assert.equal(stream.listenerCount("error"), 0); +}); + +test("iTerm2 uses OSC 9, not OSC 777", async () => { + const { stream, chunks } = tty(); + await showTerminalNotification(payload, context({ + env: { TERM_PROGRAM: "iTerm.app", TERM: "xterm-256color" }, + stdout: stream, + })); + assert.deepEqual(chunks, ["\x1b]9;Copilot: project - Agent finished\x07"]); +}); + +test("Ghostty TERM fallback requires an absent TERM_PROGRAM", () => { + assert.equal( + selectTerminalTarget(context({ env: { TERM: "xterm-ghostty" } })).protocol, + "osc777", + ); + assert.equal( + selectTerminalTarget(context({ + env: { TERM: "xterm-ghostty", TERM_PROGRAM: "Apple_Terminal" }, + })), + undefined, + ); +}); + +for (const platform of ["linux", "win32"]) { + test(`${platform} returns unsupported`, async () => { + assert.equal( + await showTerminalNotification(payload, context({ platform })), + "unsupported", + ); + }); +} + +for (const env of [ + { TERM_PROGRAM: "Apple_Terminal" }, + { TERM_PROGRAM: "not-ghostty" }, + { TERM_PROGRAM: "vscode" }, + { TERM_PROGRAM: "ghostty", TERM: "dumb" }, + { TERM_PROGRAM: "ghostty", TERM: "screen-256color" }, + { TERM_PROGRAM: "ghostty", TERM: "tmux-256color" }, + { TERM_PROGRAM: "ghostty", TMUX: "test-socket" }, + { TERM_PROGRAM: "ghostty", STY: "test-session" }, + { TERM_PROGRAM: "ghostty", SSH_CONNECTION: "present" }, + { TERM_PROGRAM: "ghostty", SSH_CLIENT: "present" }, + { TERM_PROGRAM: "ghostty", SSH_TTY: "test-tty" }, + { TERM_PROGRAM: "ghostty", HERDR_ENV: "present" }, + { TERM_PROGRAM: "ghostty", HERDR_PANE_ID: "test-pane" }, + { TERM_PROGRAM: "ghostty", HERDR_SOCKET_PATH: "test-socket" }, + { TERM_PROGRAM: "ghostty", COPILOT_MULTIPLEXER: "herdr" }, + { TERM_PROGRAM: "ghostty", COPILOT_MULTIPLEXER: "tmux" }, + {}, +]) { + test(`unsupported context emits no OSC: ${JSON.stringify(env)}`, async () => { + const out = tty(); + const err = tty(); + assert.equal(await showTerminalNotification(payload, context({ + env, + stdout: out.stream, + stderr: err.stream, + })), "unsupported"); + assert.deepEqual(out.chunks, []); + assert.deepEqual(err.chunks, []); + }); +} + +test("the disable switch prevents terminal output", async () => { + const out = tty(); + const err = tty(); + assert.equal(await showTerminalNotification(payload, context({ + env: { + TERM_PROGRAM: "ghostty", + COPILOT_DISABLE_DESKTOP_NOTIFICATIONS: "1", + }, + stdout: out.stream, + stderr: err.stream, + })), "disabled"); + assert.deepEqual(out.chunks, []); + assert.deepEqual(err.chunks, []); +}); + +test("redirected output receives no escape sequences", async () => { + const out = tty(); + const err = tty(); + out.stream.isTTY = false; + err.stream.isTTY = false; + assert.equal(await showTerminalNotification(payload, context({ + stdout: out.stream, + stderr: err.stream, + })), "unsupported"); + assert.deepEqual(out.chunks, []); + assert.deepEqual(err.chunks, []); +}); + +test("stderr TTY is used when stdout is redirected", async () => { + const out = tty(); + const err = tty(); + out.stream.isTTY = false; + await showTerminalNotification(payload, context({ + stdout: out.stream, + stderr: err.stream, + })); + assert.deepEqual(out.chunks, []); + assert.equal(err.chunks.length, 1); +}); + +test("destroyed and ended streams are not selected", () => { + const out = tty(); + const err = tty(); + out.stream.destroy(); + err.stream.end(); + assert.equal(selectTerminalTarget(context({ + stdout: out.stream, + stderr: err.stream, + })), undefined); +}); + +for (const protocol of ["osc777", "osc9"]) { + test(`${protocol} rejects control-character and field-separator injection`, () => { + const controls = Array.from( + { length: 65 }, + (_, index) => String.fromCharCode(index < 32 ? index : index + 95), + ).join(""); + const sequence = encodeTerminalNotification({ + summary: `Copilot${controls};title`, + subtitle: "line\nbreak", + body: "\x1b]52;c;clipboard\x07\x9c", + }, protocol); + assert.equal(sequence[0], "\x1b"); + assert.equal(sequence.at(-1), "\x07"); + assert.doesNotMatch(sequence.slice(1, -1), /[\x00-\x1f\x7f-\x9f]/); + assert.equal(sequence.split(";").length, protocol === "osc777" ? 4 : 2); + }); +} + +test("Unicode text survives sanitization", () => { + assert.equal( + encodeTerminalNotification({ summary: "\u4f60\u597d", body: "\ud83d\udc4d" }, "osc9"), + "\x1b]9;\u4f60\u597d: \ud83d\udc4d\x07", + ); +}); + +test("empty bodies are valid; invalid input is not silently accepted", () => { + assert.equal( + encodeTerminalNotification({ summary: "Copilot" }, "osc777"), + "\x1b]777;notify;Copilot;\x07", + ); + assert.throws(() => encodeTerminalNotification({ summary: "\n;" }, "osc777"), /empty/); + assert.throws(() => encodeTerminalNotification({ summary: 42 }, "osc9"), /string/); + assert.throws(() => encodeTerminalNotification(payload, "osc999"), /Unsupported/); +}); + +test("backpressure waits for completion instead of reporting failure", async () => { + let complete; + const stream = new EventEmitter(); + stream.isTTY = true; + stream.write = (sequence, callback) => { + complete = callback; + return false; + }; + let finished = false; + const result = showTerminalNotification(payload, context({ stdout: stream })) + .then((value) => { finished = true; return value; }); + await Promise.resolve(); + assert.equal(finished, false); + complete(); + assert.equal(await result, "sent"); + assert.equal(stream.listenerCount("error"), 0); +}); + +test("asynchronous stream errors propagate without an unhandled event", async () => { + const failure = new Error("EIO"); + const { stream } = tty({ write(chunk, encoding, done) { done(failure); } }); + await assert.rejects( + showTerminalNotification(payload, context({ stdout: stream })), + (error) => error === failure, + ); + assert.equal(stream.listenerCount("error"), 0); +}); + +test("synchronous stream errors propagate and remove the listener", async () => { + const stream = new EventEmitter(); + stream.isTTY = true; + stream.write = () => { throw new Error("write failed"); }; + await assert.rejects( + showTerminalNotification(payload, context({ stdout: stream })), + /write failed/, + ); + assert.equal(stream.listenerCount("error"), 0); +});