Files
dotfiles/pi/files/agent/extensions/pi-done-notify.ts
T
2026-03-02 09:25:34 +00:00

180 lines
5.2 KiB
TypeScript

/**
* Pi Done Notify Extension
*
* Sends a native terminal notification when Pi finishes a prompt
* and is waiting for input. Optionally plays a sound.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
function windowsToastScript(title: string, body: string): string {
const type = "Windows.UI.Notifications";
const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
const template = `[${type}.ToastTemplateType]::ToastText01`;
const toast = `[${type}.ToastNotification]::new($xml)`;
return [
`${mgr} > $null`,
`$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
`$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`,
`[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`,
].join("; ");
}
function notifyOSC777(title: string, body: string): void {
process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
}
function notifyOSC99(title: string, body: string): void {
process.stdout.write(`\x1b]99;i=1:d=1;${title}\x1b\\`);
process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
}
const DEFAULT_DURATION_MS = 5000;
function notifyWindows(title: string, body: string): void {
const { execFile } = require("child_process");
execFile("powershell.exe", [
"-NoProfile",
"-Command",
windowsToastScript(title, body),
]);
}
function notifyDesktop(
title: string,
body: string,
iconPath: string,
durationMs = DEFAULT_DURATION_MS
): void {
const { execFile } = require("child_process");
execFile(
"notify-send",
["-t", `${durationMs}`, "-i", iconPath, title, body],
(error: Error | null) => {
if (error) {
notifyOSC777(title, body);
}
}
);
}
const ICON_PATH = "/home/thomasgl/.pi/agent/extensions/assets/pi-logo.svg";
function notify(title: string, body: string): void {
if (process.platform === "linux") {
notifyDesktop(title, body, ICON_PATH);
return;
}
if (process.env.WT_SESSION) {
notifyWindows(title, body);
} else if (process.env.KITTY_WINDOW_ID) {
notifyOSC99(title, body);
} else {
notifyOSC777(title, body);
}
}
// Sound playback
function playSound(soundPath?: string): void {
const { execFile } = require("child_process");
if (process.platform === "darwin") {
// macOS: use afplay with system sound or custom path
const sound = soundPath || "/System/Library/Sounds/Glass.aiff";
execFile("afplay", [sound], (error: Error | null) => {
if (error) console.error("Failed to play sound:", error);
});
} else if (process.platform === "linux") {
// Linux: try paplay (PulseAudio), then pw-play (PipeWire), then aplay (ALSA)
const sound =
soundPath || "/usr/share/sounds/freedesktop/stereo/complete.oga";
execFile("paplay", [sound], (error: Error | null) => {
if (error) {
execFile("pw-play", [sound], (error2: Error | null) => {
if (error2) {
execFile("aplay", [sound]);
}
});
}
});
} else if (process.platform === "win32") {
// Windows: use PowerShell to play system sound
const sound =
soundPath || "C:\\Windows\\Media\\Windows Notify System Generic.wav";
execFile("powershell.exe", [
"-NoProfile",
"-Command",
`(New-Object Media.SoundPlayer '${sound}').PlaySync()`,
]);
}
}
// Settings state
interface NotifySettings {
soundEnabled: boolean;
soundPath?: string;
}
const CUSTOM_TYPE = "pi-done-notify-settings";
export default function (pi: ExtensionAPI) {
let settings: NotifySettings = {
soundEnabled: true,
};
// Restore settings from session
pi.on("session_start", async (_event, ctx) => {
for (const entry of ctx.sessionManager.getEntries()) {
if (entry.type === "custom" && entry.customType === CUSTOM_TYPE) {
settings = entry.data as NotifySettings;
}
}
});
// Register /notify command
pi.registerCommand("notify", {
description: "Configure notification settings (sound on/off, or set sound path)",
handler: async (args, ctx) => {
const arg = args?.trim().toLowerCase();
if (!arg || arg === "status") {
ctx.ui.notify(
`Sound: ${settings.soundEnabled ? "on" : "off"}${settings.soundPath ? ` (${settings.soundPath})` : ""}`,
"info"
);
return;
}
if (arg === "on") {
settings.soundEnabled = true;
pi.appendEntry(CUSTOM_TYPE, settings);
ctx.ui.notify("Notification sound enabled", "success");
return;
}
if (arg === "off") {
settings.soundEnabled = false;
pi.appendEntry(CUSTOM_TYPE, settings);
ctx.ui.notify("Notification sound disabled", "info");
return;
}
// Treat as a sound path
settings.soundPath = args?.trim() || undefined;
pi.appendEntry(CUSTOM_TYPE, settings);
ctx.ui.notify(`Sound path set to: ${settings.soundPath}`, "success");
},
});
// Notify on agent end
pi.on("agent_end", async (_event, ctx) => {
if (!ctx.hasUI) return;
notify("Pi", "Done. Ready for input.");
if (settings.soundEnabled) {
playSound(settings.soundPath);
}
});
}