fix(tui): route notifications through cmux
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added native cmux notification delivery targeted to the current terminal surface.
|
||||
|
||||
## [17.0.0] - 2026-07-15
|
||||
|
||||
### Added
|
||||
|
||||
@@ -36,6 +36,38 @@ export type TerminalId =
|
||||
| "base"
|
||||
| "trueColor";
|
||||
|
||||
const CMUX_NOTIFICATION_TITLE = "Oh My Pi";
|
||||
const CMUX_SURFACE_ID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu;
|
||||
|
||||
/**
|
||||
* Route a notification through cmux when the process belongs to a concrete
|
||||
* surface. Workspace/socket state alone is not enough: only the injected
|
||||
* surface UUID identifies the pane that should receive the notification.
|
||||
* Returns whether cmux owns delivery so the caller can preserve every existing
|
||||
* terminal fallback unchanged when no valid surface is present.
|
||||
*/
|
||||
function sendCmuxNotification(message: string | TerminalNotification, env: NodeJS.ProcessEnv = Bun.env): boolean {
|
||||
const surfaceId = env.CMUX_SURFACE_ID?.trim();
|
||||
if (!surfaceId || !CMUX_SURFACE_ID_PATTERN.test(surfaceId)) return false;
|
||||
|
||||
const title =
|
||||
typeof message === "string" ? CMUX_NOTIFICATION_TITLE : message.title?.trim() || CMUX_NOTIFICATION_TITLE;
|
||||
const body = typeof message === "string" ? message : (message.body ?? "");
|
||||
try {
|
||||
const child = Bun.spawn({
|
||||
cmd: ["cmux", "notify", "--surface", surfaceId, "--title", title, "--body", body],
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
} catch {
|
||||
// A missing cmux binary leaves delivery to the existing terminal fallback.
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasNeedleBefore(line: string, needle: string, limit: number): boolean {
|
||||
const index = line.indexOf(needle);
|
||||
return index !== -1 && index + needle.length <= limit;
|
||||
@@ -111,6 +143,7 @@ export class TerminalInfo {
|
||||
|
||||
sendNotification(message: string | TerminalNotification): void {
|
||||
if (isNotificationSuppressed() || isTerminalHeadless()) return;
|
||||
if (sendCmuxNotification(message)) return;
|
||||
const formatted = this.formatNotification(message);
|
||||
// Under tmux, terminals whose notify protocol is OSC 9 / OSC 99 would
|
||||
// otherwise lose the notification entirely: tmux does not forward bare
|
||||
|
||||
@@ -20,6 +20,9 @@ const originalOsc99Probe = Bun.env.PI_TUI_OSC99_PROBE;
|
||||
const originalTmux = Bun.env.TMUX;
|
||||
const originalZellij = Bun.env.ZELLIJ;
|
||||
const originalPiNotifications = Bun.env.PI_NOTIFICATIONS;
|
||||
const originalCmuxSurfaceId = Bun.env.CMUX_SURFACE_ID;
|
||||
const originalCmuxWorkspaceId = Bun.env.CMUX_WORKSPACE_ID;
|
||||
const originalCmuxSocketPath = Bun.env.CMUX_SOCKET_PATH;
|
||||
const mutableTerminal = TERMINAL as unknown as { notifyProtocol: NotifyProtocol };
|
||||
const originalNotifyProtocol = mutableTerminal.notifyProtocol;
|
||||
|
||||
@@ -74,6 +77,9 @@ describe("terminal notifications", () => {
|
||||
// assertions never see a stray inherited TMUX leaking the DCS wrap in.
|
||||
delete Bun.env.TMUX;
|
||||
delete Bun.env.ZELLIJ;
|
||||
delete Bun.env.CMUX_SURFACE_ID;
|
||||
delete Bun.env.CMUX_WORKSPACE_ID;
|
||||
delete Bun.env.CMUX_SOCKET_PATH;
|
||||
// `PI_NOTIFICATIONS=off` is set in this workspace's CI env, which would
|
||||
// short-circuit `sendNotification` before it writes anything. Clear it
|
||||
// so the delivery-path assertions actually observe stdout writes.
|
||||
@@ -89,6 +95,9 @@ describe("terminal notifications", () => {
|
||||
restoreEnv("TMUX", originalTmux);
|
||||
restoreEnv("ZELLIJ", originalZellij);
|
||||
restoreEnv("PI_NOTIFICATIONS", originalPiNotifications);
|
||||
restoreEnv("CMUX_SURFACE_ID", originalCmuxSurfaceId);
|
||||
restoreEnv("CMUX_WORKSPACE_ID", originalCmuxWorkspaceId);
|
||||
restoreEnv("CMUX_SOCKET_PATH", originalCmuxSocketPath);
|
||||
restoreProperty(process.stdin, "isTTY", stdinIsTtyDescriptor);
|
||||
restoreProperty(process.stdout, "isTTY", stdoutIsTtyDescriptor);
|
||||
restoreProperty(process.stdin, "setRawMode", stdinSetRawModeDescriptor);
|
||||
@@ -182,6 +191,96 @@ describe("terminal notifications", () => {
|
||||
expect(wrapTmuxPassthrough(payload)).toBe("\x1bPtmux;\x1b\x1b]99;;Hello\x1b\x1b\\\x1b\\");
|
||||
});
|
||||
|
||||
it("routes a real cmux surface notification exactly once with explicit argv fields", () => {
|
||||
Bun.env.CMUX_SURFACE_ID = "123e4567-e89b-12d3-a456-426614174000";
|
||||
mutableTerminal.notifyProtocol = NotifyProtocol.Osc99;
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
const unref = vi.fn();
|
||||
const spawn = vi.spyOn(Bun, "spawn").mockImplementation((..._args: unknown[]) => ({ unref }) as never);
|
||||
|
||||
TERMINAL.sendNotification({ title: "--title=spoof", body: "--surface other" });
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith({
|
||||
cmd: [
|
||||
"cmux",
|
||||
"notify",
|
||||
"--surface",
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
"--title",
|
||||
"--title=spoof",
|
||||
"--body",
|
||||
"--surface other",
|
||||
],
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
});
|
||||
expect(unref).toHaveBeenCalledTimes(1);
|
||||
expect(stdout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the existing OSC fallback for cmux workspace or socket state without a surface", () => {
|
||||
mutableTerminal.notifyProtocol = NotifyProtocol.Osc99;
|
||||
const writes: string[] = [];
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(chunk => {
|
||||
writes.push(typeof chunk === "string" ? chunk : chunk.toString());
|
||||
return true;
|
||||
});
|
||||
const spawn = vi.spyOn(Bun, "spawn").mockImplementation((..._args: unknown[]) => ({ unref: vi.fn() }) as never);
|
||||
|
||||
Bun.env.CMUX_WORKSPACE_ID = "workspace:1";
|
||||
TERMINAL.sendNotification("workspace");
|
||||
delete Bun.env.CMUX_WORKSPACE_ID;
|
||||
Bun.env.CMUX_SOCKET_PATH = "/tmp/cmux.sock";
|
||||
TERMINAL.sendNotification("socket");
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual(["\x1b]99;;workspace\x1b\\", "\x1b]99;;socket\x1b\\"]);
|
||||
});
|
||||
|
||||
it("rejects option-like cmux surface values and retains the existing fallback", () => {
|
||||
Bun.env.CMUX_SURFACE_ID = "--help";
|
||||
mutableTerminal.notifyProtocol = NotifyProtocol.Osc99;
|
||||
const writes: string[] = [];
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(chunk => {
|
||||
writes.push(typeof chunk === "string" ? chunk : chunk.toString());
|
||||
return true;
|
||||
});
|
||||
const spawn = vi.spyOn(Bun, "spawn").mockImplementation((..._args: unknown[]) => ({ unref: vi.fn() }) as never);
|
||||
|
||||
TERMINAL.sendNotification("ping");
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual(["\x1b]99;;ping\x1b\\"]);
|
||||
});
|
||||
|
||||
it("falls back to the terminal protocol when cmux cannot be spawned", () => {
|
||||
Bun.env.CMUX_SURFACE_ID = "123e4567-e89b-12d3-a456-426614174000";
|
||||
mutableTerminal.notifyProtocol = NotifyProtocol.Osc99;
|
||||
const writes: string[] = [];
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(chunk => {
|
||||
writes.push(typeof chunk === "string" ? chunk : chunk.toString());
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(Bun, "spawn").mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
|
||||
expect(() => TERMINAL.sendNotification("ping")).not.toThrow();
|
||||
expect(writes).toEqual(["\x1b]99;;ping\x1b\\"]);
|
||||
});
|
||||
|
||||
it("unrefs a lingering cmux child so notification delivery cannot pin process exit", () => {
|
||||
Bun.env.CMUX_SURFACE_ID = "123e4567-e89b-12d3-a456-426614174000";
|
||||
const unref = vi.fn();
|
||||
vi.spyOn(Bun, "spawn").mockImplementation((..._args: unknown[]) => ({ unref }) as never);
|
||||
|
||||
TERMINAL.sendNotification("ping");
|
||||
|
||||
expect(unref).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("under tmux, OSC-protocol sendNotification wraps for passthrough and appends BEL", () => {
|
||||
Bun.env.TMUX = "/tmp/tmux-1000/default,1234,0";
|
||||
mutableTerminal.notifyProtocol = NotifyProtocol.Osc99;
|
||||
|
||||
Reference in New Issue
Block a user