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
This commit is contained in:
roboomp
2026-06-27 23:26:06 +00:00
parent 61cf24bd68
commit 16fef78c97
6 changed files with 469 additions and 0 deletions
+4
View File
@@ -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
+190
View File
@@ -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<Record<TerminalId, true>> = {
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<ResolvedNotificationFields["urgency"], number> = {
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": <byte ${URGENCY_BYTE[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.
}
}
+2
View File
@@ -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
+10
View File
@@ -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);
}
}
}
+220
View File
@@ -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": <byte 0>}',
"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();
});
});
+43
View File
@@ -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[] = [];