From 16fef78c97f0794be15987861d344fe457746534 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 27 Jun 2026 23:26:06 +0000 Subject: [PATCH] feat(tui): delivered desktop toasts via d-bus when terminal escapes do not Introduces desktop-notify.ts which dispatches completion/ask notifications to org.freedesktop.Notifications through notify-send (libnotify) and falls back to gdbus when libnotify is absent. TerminalInfo.sendNotification fans out to D-Bus whenever the chosen notifyProtocol is BEL and DBUS_SESSION_BUS_ADDRESS is set on Linux, so VTE-family hosts (Ptyxis, GNOME Terminal, Tilix) plus Alacritty and bare xterm finally surface toasts they cannot deliver in-band. Delivery is fire-and-forget, skipped for terminals with their own notification UI (VS Code, Warp), and gated by PI_NO_DESKTOP_NOTIFY=1. The BEL emission is preserved for tmux monitor-bell, X11 urgency hints, and audible-bell handlers. Fixes #3685 --- packages/tui/CHANGELOG.md | 4 + packages/tui/src/desktop-notify.ts | 190 +++++++++++++++++++ packages/tui/src/index.ts | 2 + packages/tui/src/terminal-capabilities.ts | 10 + packages/tui/test/desktop-notify.test.ts | 220 ++++++++++++++++++++++ packages/tui/test/notifications.test.ts | 43 +++++ 6 files changed, 469 insertions(+) create mode 100644 packages/tui/src/desktop-notify.ts create mode 100644 packages/tui/test/desktop-notify.test.ts diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index f8db9aad8..8d1afffb6 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added a freedesktop D-Bus desktop-notification fallback for BEL-protocol terminals on Linux: `TERMINAL.sendNotification` now fans out completion/ask toasts through `notify-send` (libnotify) — or `gdbus call ... org.freedesktop.Notifications.Notify` when libnotify is absent — whenever `DBUS_SESSION_BUS_ADDRESS` is set, so VTE-family hosts (Ptyxis, GNOME Terminal, Tilix, …) plus Alacritty and bare xterm finally surface toasts they cannot deliver in-band ([#3685](https://github.com/can1357/oh-my-pi/issues/3685)). Skipped for terminals with their own notification UI (VS Code, Warp) to avoid duplicate toasts, gated by a `PI_NO_DESKTOP_NOTIFY=1` opt-out, and kept fire-and-forget so the BEL still fires for tmux `monitor-bell`, X11 urgency hints, and audible-bell handlers. + ## [16.2.0] - 2026-06-27 ### Added diff --git a/packages/tui/src/desktop-notify.ts b/packages/tui/src/desktop-notify.ts new file mode 100644 index 000000000..f6d42b942 --- /dev/null +++ b/packages/tui/src/desktop-notify.ts @@ -0,0 +1,190 @@ +// Linux desktop notification delivery via D-Bus. +// +// Several terminal families — most notably the VTE-based stack (Ptyxis, +// GNOME Terminal, Tilix, Terminator) but also Alacritty and bare xterm — have +// `notifyProtocol === Bell`, which means `formatNotification()` emits only a +// raw BEL. BEL alone never surfaces an arbitrary-text toast on those hosts +// (see #3685): Ptyxis hooks BEL to a CSS visual-bell flash, GNOME Terminal +// rings the audible bell. None of OSC 9 (ConEmu progress in VTE), OSC 99 +// (unimplemented), or OSC 777 (only `notify;Command completed` → unused +// shell-postexec termprop in current VTE) produce a desktop notification. +// +// The freedesktop `org.freedesktop.Notifications` D-Bus service is the only +// path that consistently delivers toasts on those terminals across Wayland +// and X11. We invoke it out-of-process via `notify-send` (the canonical +// libnotify CLI present on every modern Linux desktop) and fall back to +// `gdbus call` when libnotify is absent but GLib is installed. +// +// Delivery is fire-and-forget: a failed spawn or missing binary is treated as +// a silent no-op so terminals that already deliver toasts in-band (Kitty, +// iTerm2, WezTerm, …) keep working unchanged and the BEL emission still fires +// for tmux `monitor-bell`, X11 urgency hints, and audible-bell handlers. + +import { $which } from "@oh-my-pi/pi-utils"; +import type { TerminalId, TerminalNotification } from "./terminal-capabilities"; + +/** Application name surfaced as the notification source. */ +const APP_NAME = "Oh My Pi"; + +/** Resolved notifier binary used to fan a notification out to D-Bus. */ +export type DesktopNotifierKind = "notify-send" | "gdbus"; + +export interface DesktopNotifier { + kind: DesktopNotifierKind; + path: string; +} + +/** + * Terminal ids that ship their own in-app notification surface and would + * otherwise show a duplicate toast (the editor's own UI plus the D-Bus toast). + * Conservative whitelist — only terminals known to surface notifications in + * their host UI today; new entries land alongside the evidence. + */ +const NATIVE_NOTIFY_TERMINALS: Partial> = { + vscode: true, + warp: true, +}; + +/** + * Whether the current process can reach a freedesktop notification daemon: + * Linux platform + a session bus address in env. Caller is still responsible + * for resolving a delivery binary via {@link resolveDesktopNotifier}. + */ +export function hasLinuxDesktopSession( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = Bun.env, +): boolean { + if (platform !== "linux") return false; + return Boolean(env.DBUS_SESSION_BUS_ADDRESS); +} + +/** + * Whether `sendNotification` should also dispatch a D-Bus toast for this + * terminal. Returns true only when (1) the terminal lacks an in-band escape + * for arbitrary toasts (BEL is the chosen `notifyProtocol`), (2) the host + * exposes a Linux desktop session, (3) the terminal does not already render + * notifications in its own UI, and (4) the user has not opted out via + * `PI_NO_DESKTOP_NOTIFY=1`. Pure helper for tests and the singleton path. + */ +export function shouldDeliverDesktopNotification( + terminalId: TerminalId, + notifyProtocolIsBell: boolean, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = Bun.env, +): boolean { + if (!notifyProtocolIsBell) return false; + if (!hasLinuxDesktopSession(platform, env)) return false; + if (NATIVE_NOTIFY_TERMINALS[terminalId]) return false; + if (env.PI_NO_DESKTOP_NOTIFY === "1") return false; + return true; +} + +let cachedNotifier: DesktopNotifier | null | undefined; + +/** Reset the cached notifier resolution. Tests only. */ +export function resetDesktopNotifierCache(): void { + cachedNotifier = undefined; +} + +/** + * Locate a libnotify-compatible delivery binary on `PATH`, preferring + * `notify-send` (one-shot, no marshalling) and falling back to `gdbus call` + * for hosts where libnotify is not installed but GLib is. Result is cached so + * repeated notifications do not hit `$which` again. + */ +export function resolveDesktopNotifier(): DesktopNotifier | null { + if (cachedNotifier !== undefined) return cachedNotifier; + const notifySend = $which("notify-send"); + if (notifySend) { + cachedNotifier = { kind: "notify-send", path: notifySend }; + return cachedNotifier; + } + const gdbus = $which("gdbus"); + if (gdbus) { + cachedNotifier = { kind: "gdbus", path: gdbus }; + return cachedNotifier; + } + cachedNotifier = null; + return null; +} + +interface ResolvedNotificationFields { + title: string; + body: string; + urgency: "low" | "normal" | "critical"; +} + +function resolveFields(message: string | TerminalNotification): ResolvedNotificationFields { + if (typeof message === "string") { + return { title: APP_NAME, body: message, urgency: "normal" }; + } + const title = message.title?.trim() || APP_NAME; + const body = message.body ?? ""; + const urgency = message.urgency === "critical" || message.urgency === "low" ? message.urgency : "normal"; + return { title, body, urgency }; +} + +const URGENCY_BYTE: Record = { + low: 0, + normal: 1, + critical: 2, +}; + +/** + * Build the argv that delivers `message` through the resolved notifier. Pure + * helper so tests assert exact wire shape without spawning a child. Notes: + * - `notify-send` accepts title + body positionally and a numeric expire + * timeout (`-t`); urgency is a flag. + * - `gdbus call ... Notify` takes the freedesktop signature + * `s u s s s as a{sv} i`: app_name, replaces_id, app_icon, summary, body, + * actions, hints, expire_timeout. We feed hints with the urgency byte so + * the daemon classifies the toast identically to `notify-send`. + */ +export function buildDesktopNotifyCommand(notifier: DesktopNotifier, message: string | TerminalNotification): string[] { + const { title, body, urgency } = resolveFields(message); + if (notifier.kind === "notify-send") { + return [notifier.path, "--app-name", APP_NAME, `--urgency=${urgency}`, "--expire-time=5000", title, body]; + } + const hints = `{"urgency": }`; + return [ + notifier.path, + "call", + "--session", + "--dest", + "org.freedesktop.Notifications", + "--object-path", + "/org/freedesktop/Notifications", + "--method", + "org.freedesktop.Notifications.Notify", + APP_NAME, + "0", + "", + title, + body, + "[]", + hints, + "5000", + ]; +} + +/** + * Fire-and-forget D-Bus desktop notification. Resolves a notifier, spawns it + * with stdio fully detached, and never throws — terminal notifications are + * best-effort and must not block the renderer or interleave bytes onto + * stdout. Caller is responsible for the gating check + * ({@link shouldDeliverDesktopNotification}). + */ +export function sendDesktopNotification(message: string | TerminalNotification): void { + const notifier = resolveDesktopNotifier(); + if (!notifier) return; + try { + Bun.spawn({ + cmd: buildDesktopNotifyCommand(notifier, message), + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + } catch { + // Best-effort: a failed spawn is silent. + } +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index fb49b711f..40f594d62 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -19,6 +19,8 @@ export * from "./components/text"; export * from "./components/truncated-text"; // DECCARA rectangular-SGR background-fill optimizer export * from "./deccara"; +// Desktop notifications via D-Bus (Linux freedesktop notifications) +export * from "./desktop-notify"; // Editor component interface (for custom editors) export type * from "./editor-component"; // Fuzzy matching diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index 927a42759..71b639f9d 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -1,5 +1,6 @@ import { encodeSixel } from "@oh-my-pi/pi-natives"; import { $env, isBunTestRuntime, isTerminalHeadless } from "@oh-my-pi/pi-utils"; +import { sendDesktopNotification, shouldDeliverDesktopNotification } from "./desktop-notify"; import { detectKittyUnicodePlaceholdersSupport, getKittyGraphics, @@ -122,6 +123,15 @@ export class TerminalInfo { return; } process.stdout.write(formatted); + // VTE-family terminals (Ptyxis, GNOME Terminal, Tilix, …) plus Alacritty + // and bare xterm-on-Wayland have no in-band escape that surfaces an + // arbitrary desktop toast (#3685). When the chosen `notifyProtocol` is + // BEL on a Linux session bus, also fan the notification out via + // libnotify so users see the toast and the BEL still fires for tmux + // `monitor-bell` / X11 urgency hints / audible bell. + if (this.notifyProtocol === NotifyProtocol.Bell && shouldDeliverDesktopNotification(this.id, true)) { + sendDesktopNotification(message); + } } } diff --git a/packages/tui/test/desktop-notify.test.ts b/packages/tui/test/desktop-notify.test.ts new file mode 100644 index 000000000..690fbc361 --- /dev/null +++ b/packages/tui/test/desktop-notify.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { + buildDesktopNotifyCommand, + type DesktopNotifier, + hasLinuxDesktopSession, + resetDesktopNotifierCache, + resolveDesktopNotifier, + sendDesktopNotification, + shouldDeliverDesktopNotification, +} from "@oh-my-pi/pi-tui/desktop-notify"; +import * as utils from "@oh-my-pi/pi-utils"; + +const LINUX_ENV: NodeJS.ProcessEnv = { DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus" }; + +describe("hasLinuxDesktopSession", () => { + it("requires linux + a session bus address", () => { + expect(hasLinuxDesktopSession("linux", LINUX_ENV)).toBe(true); + expect(hasLinuxDesktopSession("linux", {})).toBe(false); + expect(hasLinuxDesktopSession("darwin", LINUX_ENV)).toBe(false); + expect(hasLinuxDesktopSession("win32", LINUX_ENV)).toBe(false); + }); +}); + +describe("shouldDeliverDesktopNotification", () => { + it("fires for VTE-family fallbacks (base/trueColor/alacritty) on a Linux session", () => { + for (const id of ["base", "trueColor", "alacritty"] as const) { + expect(shouldDeliverDesktopNotification(id, true, "linux", LINUX_ENV)).toBe(true); + } + }); + + it("never fires when the terminal already speaks an in-band notify protocol", () => { + // notifyProtocolIsBell=false means OSC 9 / OSC 99 already delivered the toast. + expect(shouldDeliverDesktopNotification("kitty", false, "linux", LINUX_ENV)).toBe(false); + expect(shouldDeliverDesktopNotification("ghostty", false, "linux", LINUX_ENV)).toBe(false); + expect(shouldDeliverDesktopNotification("wezterm", false, "linux", LINUX_ENV)).toBe(false); + expect(shouldDeliverDesktopNotification("iterm2", false, "linux", LINUX_ENV)).toBe(false); + }); + + it("skips terminals that surface their own notification UI to avoid duplicate toasts", () => { + expect(shouldDeliverDesktopNotification("vscode", true, "linux", LINUX_ENV)).toBe(false); + expect(shouldDeliverDesktopNotification("warp", true, "linux", LINUX_ENV)).toBe(false); + }); + + it("respects the PI_NO_DESKTOP_NOTIFY=1 opt-out", () => { + expect( + shouldDeliverDesktopNotification("trueColor", true, "linux", { + ...LINUX_ENV, + PI_NO_DESKTOP_NOTIFY: "1", + }), + ).toBe(false); + }); + + it("requires a Linux desktop session — silent on macOS / Windows / headless Linux", () => { + expect(shouldDeliverDesktopNotification("trueColor", true, "darwin", LINUX_ENV)).toBe(false); + expect(shouldDeliverDesktopNotification("trueColor", true, "win32", LINUX_ENV)).toBe(false); + expect(shouldDeliverDesktopNotification("trueColor", true, "linux", {})).toBe(false); + }); +}); + +describe("resolveDesktopNotifier", () => { + beforeEach(() => { + resetDesktopNotifierCache(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetDesktopNotifierCache(); + }); + + it("prefers notify-send when libnotify is on PATH", () => { + vi.spyOn(utils, "$which").mockImplementation(name => + name === "notify-send" ? "/usr/bin/notify-send" : "/usr/bin/gdbus", + ); + expect(resolveDesktopNotifier()).toEqual({ kind: "notify-send", path: "/usr/bin/notify-send" }); + }); + + it("falls back to gdbus when notify-send is missing", () => { + vi.spyOn(utils, "$which").mockImplementation(name => (name === "gdbus" ? "/usr/bin/gdbus" : null)); + expect(resolveDesktopNotifier()).toEqual({ kind: "gdbus", path: "/usr/bin/gdbus" }); + }); + + it("returns null when neither binary is installed", () => { + vi.spyOn(utils, "$which").mockReturnValue(null); + expect(resolveDesktopNotifier()).toBeNull(); + }); + + it("caches the resolution so repeat calls do not re-probe PATH", () => { + const spy = vi.spyOn(utils, "$which").mockReturnValue("/usr/bin/notify-send"); + resolveDesktopNotifier(); + resolveDesktopNotifier(); + resolveDesktopNotifier(); + // One call per probed binary on the first invocation, zero on cache hits. + expect(spy).toHaveBeenCalledTimes(1); + }); +}); + +describe("buildDesktopNotifyCommand", () => { + const notifySend: DesktopNotifier = { kind: "notify-send", path: "/usr/bin/notify-send" }; + const gdbus: DesktopNotifier = { kind: "gdbus", path: "/usr/bin/gdbus" }; + + it("encodes string messages as title=app + body=message for notify-send", () => { + expect(buildDesktopNotifyCommand(notifySend, "ping")).toEqual([ + "/usr/bin/notify-send", + "--app-name", + "Oh My Pi", + "--urgency=normal", + "--expire-time=5000", + "Oh My Pi", + "ping", + ]); + }); + + it("threads structured fields (title, body, urgency) through notify-send positional + flag args", () => { + expect( + buildDesktopNotifyCommand(notifySend, { + title: "Session 12", + body: "Complete", + urgency: "critical", + }), + ).toEqual([ + "/usr/bin/notify-send", + "--app-name", + "Oh My Pi", + "--urgency=critical", + "--expire-time=5000", + "Session 12", + "Complete", + ]); + }); + + it("falls back to the app name when the structured title is blank", () => { + expect(buildDesktopNotifyCommand(notifySend, { title: " ", body: "Waiting for input" })).toEqual([ + "/usr/bin/notify-send", + "--app-name", + "Oh My Pi", + "--urgency=normal", + "--expire-time=5000", + "Oh My Pi", + "Waiting for input", + ]); + }); + + it("produces a freedesktop Notify call for gdbus including the urgency hint byte", () => { + expect(buildDesktopNotifyCommand(gdbus, { title: "Oh My Pi", body: "ping", urgency: "low" })).toEqual([ + "/usr/bin/gdbus", + "call", + "--session", + "--dest", + "org.freedesktop.Notifications", + "--object-path", + "/org/freedesktop/Notifications", + "--method", + "org.freedesktop.Notifications.Notify", + "Oh My Pi", + "0", + "", + "Oh My Pi", + "ping", + "[]", + '{"urgency": }', + "5000", + ]); + }); +}); + +describe("sendDesktopNotification", () => { + beforeEach(() => { + resetDesktopNotifierCache(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetDesktopNotifierCache(); + }); + + it("fires Bun.spawn with the resolved notify-send argv and detached stdio", () => { + vi.spyOn(utils, "$which").mockImplementation(name => (name === "notify-send" ? "/usr/bin/notify-send" : null)); + const spawn = vi.spyOn(Bun, "spawn").mockImplementation((..._args: unknown[]) => ({}) as never); + + sendDesktopNotification({ title: "Session", body: "Complete" }); + + expect(spawn).toHaveBeenCalledTimes(1); + const opts = spawn.mock.calls[0]?.[0] as unknown as { + cmd: string[]; + stdin: string; + stdout: string; + stderr: string; + }; + expect(opts.cmd).toEqual([ + "/usr/bin/notify-send", + "--app-name", + "Oh My Pi", + "--urgency=normal", + "--expire-time=5000", + "Session", + "Complete", + ]); + expect(opts.stdin).toBe("ignore"); + expect(opts.stdout).toBe("ignore"); + expect(opts.stderr).toBe("ignore"); + }); + + it("is a silent no-op when no notifier binary is installed", () => { + vi.spyOn(utils, "$which").mockReturnValue(null); + const spawn = vi.spyOn(Bun, "spawn").mockImplementation((..._args: unknown[]) => ({}) as never); + + sendDesktopNotification("ping"); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it("swallows spawn failures so a missing daemon never throws into the renderer", () => { + vi.spyOn(utils, "$which").mockReturnValue("/usr/bin/notify-send"); + vi.spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("ENOENT"); + }); + + expect(() => sendDesktopNotification("ping")).not.toThrow(); + }); +}); diff --git a/packages/tui/test/notifications.test.ts b/packages/tui/test/notifications.test.ts index ae12ae49a..aef05bf44 100644 --- a/packages/tui/test/notifications.test.ts +++ b/packages/tui/test/notifications.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as desktopNotify from "@oh-my-pi/pi-tui/desktop-notify"; import { ProcessTerminal } from "@oh-my-pi/pi-tui/terminal"; import { getTerminalInfo, @@ -207,6 +208,48 @@ describe("terminal notifications", () => { expect(writes).toEqual(["\x07"]); }); + it("Bell-protocol sendNotification also fans out to D-Bus when the gate is open", () => { + mutableTerminal.notifyProtocol = NotifyProtocol.Bell; + const writes: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation(chunk => { + writes.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + vi.spyOn(desktopNotify, "shouldDeliverDesktopNotification").mockReturnValue(true); + const dbus = vi.spyOn(desktopNotify, "sendDesktopNotification").mockImplementation(() => {}); + + TERMINAL.sendNotification({ title: "Session", body: "Complete" }); + + // BEL still hits stdout for tmux monitor-bell / X11 urgency / audible bell. + expect(writes).toEqual(["\x07"]); + // And the desktop toast is dispatched with the same structured payload. + expect(dbus).toHaveBeenCalledTimes(1); + expect(dbus).toHaveBeenCalledWith({ title: "Session", body: "Complete" }); + }); + + it("skips the D-Bus dispatch when the gate forbids it (kept side-effect free)", () => { + mutableTerminal.notifyProtocol = NotifyProtocol.Bell; + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(desktopNotify, "shouldDeliverDesktopNotification").mockReturnValue(false); + const dbus = vi.spyOn(desktopNotify, "sendDesktopNotification").mockImplementation(() => {}); + + TERMINAL.sendNotification("ping"); + + expect(dbus).not.toHaveBeenCalled(); + }); + + it("never reaches D-Bus when the terminal already speaks an in-band notify protocol", () => { + mutableTerminal.notifyProtocol = NotifyProtocol.Osc99; + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + // Even if the gate would say yes, the BEL branch is skipped so dispatch never fires. + vi.spyOn(desktopNotify, "shouldDeliverDesktopNotification").mockReturnValue(true); + const dbus = vi.spyOn(desktopNotify, "sendDesktopNotification").mockImplementation(() => {}); + + TERMINAL.sendNotification("ping"); + + expect(dbus).not.toHaveBeenCalled(); + }); + it("outside tmux, OSC-protocol sendNotification writes the raw OSC unchanged", () => { mutableTerminal.notifyProtocol = NotifyProtocol.Osc99; const writes: string[] = [];