diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9f9a2831d..e74f51b84 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added native cmux notification delivery targeted to the current terminal surface. + ## [17.0.0] - 2026-07-15 ### Added diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index 4799ed6d1..93d45f912 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -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 diff --git a/packages/tui/test/notifications.test.ts b/packages/tui/test/notifications.test.ts index b6da7151b..1e6094af4 100644 --- a/packages/tui/test/notifications.test.ts +++ b/packages/tui/test/notifications.test.ts @@ -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;