fix(tui): route notifications through cmux

This commit is contained in:
usr_bin_roygbiv
2026-07-15 17:09:25 -05:00
parent d5cd24f39a
commit 3be56229aa
3 changed files with 136 additions and 0 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added native cmux notification delivery targeted to the current terminal surface.
## [17.0.0] - 2026-07-15
### Added
+33
View File
@@ -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
+99
View File
@@ -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;