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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user