/** * Pi Nudge Extension * * Sends native terminal notifications when pi needs attention and the * terminal is not focused. Suppresses notifications when you're already * looking at the terminal. * * Other extensions can trigger notifications via pi.events: * pi.events.emit("nudge", { title: "Pi", body: "Needs approval" }); * * Notification protocols: * - OSC 9: Ghostty, iTerm2 * - OSC 99: Kitty * - OSC 777: WezTerm, rxvt-unicode (fallback) * - tmux passthrough wrapper */ import type { ExtensionAPI, ExtensionUIContext } from "@mariozechner/pi-coding-agent"; // --------------------------------------------------------------------------- // Focus tracking compatibility // --------------------------------------------------------------------------- type FocusAwareUI = ExtensionUIContext & { isTerminalFocused(): boolean }; function hasFocusTracking(ui: ExtensionUIContext): ui is FocusAwareUI { return typeof (ui as Partial).isTerminalFocused === "function"; } // --------------------------------------------------------------------------- // Notification transport // --------------------------------------------------------------------------- type EncodeNotification = (title: string, body: string) => string[]; const encoders: Record = { ghostty: osc9, iterm2: osc9, kitty: osc99, default: osc777, }; // OSC 9 has no title field; the terminal provides its own header (at least in Ghostty). function osc9(_title: string, body: string): string[] { return [`\x1b]9;${body}\x07`]; } function osc99(title: string, body: string): string[] { return [ `\x1b]99;i=1:d=0;${title}\x1b\\`, `\x1b]99;i=1:p=body;${body}\x1b\\`, ]; } function osc777(title: string, body: string): string[] { return [`\x1b]777;notify;${title};${body}\x07`]; } function detectTerminal(): string { if (process.env.KITTY_WINDOW_ID) return "kitty"; if (process.env.TERM_PROGRAM === "ghostty") return "ghostty"; if (process.env.TERM_PROGRAM === "iTerm.app" || process.env.ITERM_SESSION_ID) return "iterm2"; return "default"; } function tmuxPassthrough(sequence: string): string { if (!process.env.TMUX) return sequence; const escaped = sequence.split("\x1b").join("\x1b\x1b"); return `\x1bPtmux;${escaped}\x1b\\`; } // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- const TITLE = "π"; interface NudgeEvent { title?: string; body?: string; } export default function (pi: ExtensionAPI) { let isTerminalFocused: (() => boolean) | undefined; const terminal = detectTerminal(); const encode = encoders[terminal] ?? encoders.default!; function nudge(title: string, body: string): void { if (isTerminalFocused?.()) return; for (const sequence of encode(title, body)) { process.stdout.write(tmuxPassthrough(sequence)); } } // Detect focus tracking support on session start pi.on("session_start", async (_event, ctx) => { if (!ctx.hasUI) return; if (hasFocusTracking(ctx.ui)) { const ui = ctx.ui; isTerminalFocused = () => ui.isTerminalFocused(); } else { ctx.ui.notify( "[pi-nudge] Terminal focus detection is unavailable in this pi build. Notifications will always be sent, but your terminal may suppress them if focused.", "info" ); } }); // Notify when agent finishes a turn pi.on("agent_end", async (_event, ctx) => { if (!ctx.hasUI) return; nudge(TITLE, "Ready for input"); }); // Allow other extensions to trigger notifications via pi.events pi.events.on("nudge", (data: unknown) => { const event = (data ?? {}) as NudgeEvent; nudge(event.title ?? TITLE, event.body ?? "Needs attention"); }); }