mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: add Kitty-aware completion notifications
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { extractMessageText, truncateText } from "./messages.ts";
|
||||
import {
|
||||
parseEnabled,
|
||||
parseKittyVisibility,
|
||||
parseMessageMax,
|
||||
parseMessageSource,
|
||||
parseMinDurationMs,
|
||||
parseNotificationAction,
|
||||
} from "./parsers.ts";
|
||||
import { renderTemplate } from "./template.ts";
|
||||
import type { NotificationRuntimeValues, NotifyConfig } from "./types.ts";
|
||||
|
||||
const DEFAULT_TITLE = "π - {project}";
|
||||
const DEFAULT_BODY = "{message}";
|
||||
|
||||
export function getConfig(
|
||||
ctx: ExtensionContext | undefined,
|
||||
startedAt: number | undefined,
|
||||
messages: unknown[] | undefined,
|
||||
runtime: NotificationRuntimeValues,
|
||||
now = Date.now(),
|
||||
): NotifyConfig {
|
||||
const durationMs = startedAt === undefined ? 0 : Math.max(0, now - startedAt);
|
||||
const messageMax = parseMessageMax(process.env.PI_NOTIFY_MESSAGE_MAX);
|
||||
const userMessage = truncateText(extractMessageText(messages, "user"), messageMax);
|
||||
const assistantMessage = truncateText(extractMessageText(messages, "assistant"), messageMax);
|
||||
const messageSource = parseMessageSource(process.env.PI_NOTIFY_MESSAGE_SOURCE);
|
||||
const selectedMessage = messageSource === "user" ? userMessage : messageSource === "assistant" ? assistantMessage : "";
|
||||
const message = selectedMessage || "Ready for input";
|
||||
const toolErrorCount = Math.max(0, runtime.toolErrorCount ?? 0);
|
||||
const values = {
|
||||
user_message: userMessage,
|
||||
assistant_message: assistantMessage,
|
||||
message,
|
||||
status: toolErrorCount > 0 ? `ready · ${toolErrorCount} tool error${toolErrorCount === 1 ? "" : "s"}` : "ready",
|
||||
status_icon: toolErrorCount > 0 ? "⚠" : "✓",
|
||||
tool_error_count: String(toolErrorCount),
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: parseEnabled(process.env.PI_NOTIFY_ENABLED),
|
||||
title: renderTemplate(process.env.PI_NOTIFY_TITLE || DEFAULT_TITLE, ctx, durationMs, values),
|
||||
body: renderTemplate(process.env.PI_NOTIFY_BODY || DEFAULT_BODY, ctx, durationMs, values),
|
||||
durationMs,
|
||||
minDurationMs: parseMinDurationMs(process.env.PI_NOTIFY_MIN_SECONDS),
|
||||
notificationId: runtime.notificationId,
|
||||
visibility: parseKittyVisibility(process.env.PI_NOTIFY_VISIBILITY),
|
||||
action: parseNotificationAction(process.env.PI_NOTIFY_ACTION),
|
||||
soundCommand: process.env.PI_NOTIFY_SOUND_CMD?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldNotify(config: NotifyConfig, enabledOverride?: boolean): boolean {
|
||||
return (enabledOverride ?? config.enabled) && config.durationMs >= config.minDurationMs;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { getConfig, shouldNotify } from "./config.ts";
|
||||
import { getNotificationBackend, sendNotification } from "./terminal.ts";
|
||||
import type { NotifyConfig } from "./types.ts";
|
||||
|
||||
type ExtensionDependencies = {
|
||||
now: () => number;
|
||||
createNotificationId: () => string;
|
||||
notify: (config: NotifyConfig) => void;
|
||||
getBackend: typeof getNotificationBackend;
|
||||
};
|
||||
|
||||
export function createPiNotifyExtension(dependencies: Partial<ExtensionDependencies> = {}) {
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const createNotificationId = dependencies.createNotificationId ?? (() => `pi-${process.pid}-${randomUUID()}`);
|
||||
const notify = dependencies.notify ?? sendNotification;
|
||||
const getBackend = dependencies.getBackend ?? getNotificationBackend;
|
||||
|
||||
return function piNotify(pi: ExtensionAPI): void {
|
||||
const notificationId = createNotificationId();
|
||||
let startedAt: number | undefined;
|
||||
let latestMessages: unknown[] | undefined;
|
||||
let toolErrorCount = 0;
|
||||
let enabledOverride: boolean | undefined;
|
||||
|
||||
const resetRun = (): void => {
|
||||
startedAt = undefined;
|
||||
latestMessages = undefined;
|
||||
toolErrorCount = 0;
|
||||
};
|
||||
|
||||
pi.on("agent_start", () => {
|
||||
startedAt ??= now();
|
||||
});
|
||||
|
||||
pi.on("tool_execution_end", (event) => {
|
||||
if (event.isError) toolErrorCount++;
|
||||
});
|
||||
|
||||
pi.on("agent_end", (event) => {
|
||||
latestMessages = event.messages;
|
||||
});
|
||||
|
||||
pi.on("agent_settled", (_event, ctx) => {
|
||||
try {
|
||||
const config = getConfig(ctx, startedAt, latestMessages, { notificationId, toolErrorCount }, now());
|
||||
if (ctx.mode === "tui" && shouldNotify(config, enabledOverride)) notify(config);
|
||||
} finally {
|
||||
resetRun();
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("notify", {
|
||||
description: "Control Kitty/macOS completion notifications: on, off, test, status",
|
||||
handler: async (args, ctx) => {
|
||||
const action = args.trim().toLowerCase() || "status";
|
||||
|
||||
if (action === "on" || action === "off") {
|
||||
enabledOverride = action === "on";
|
||||
ctx.ui.notify(`Completion notifications ${action}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig(ctx, now() - 1000, undefined, { notificationId, toolErrorCount: 0 }, now());
|
||||
if (action === "test") {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("System notifications are only emitted in interactive TUI mode", "warning");
|
||||
return;
|
||||
}
|
||||
notify({ ...config, enabled: true, minDurationMs: 0, body: "Kitty notification test — click to focus this window" });
|
||||
ctx.ui.notify(`Test sent through ${getBackend()}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "status") {
|
||||
const enabled = enabledOverride ?? config.enabled;
|
||||
ctx.ui.notify(
|
||||
`Notifications ${enabled ? "on" : "off"} · ${getBackend()} · ${config.visibility} · min ${config.minDurationMs / 1000}s`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify("Usage: /notify on|off|test|status", "warning");
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default createPiNotifyExtension();
|
||||
@@ -0,0 +1,42 @@
|
||||
export function extractMessageText(messages: unknown[] | undefined, role: "user" | "assistant"): string {
|
||||
if (!messages) return "";
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (!isRecord(message) || message.role !== role) continue;
|
||||
|
||||
const text = normalizeMessageText(extractContentText(message.content));
|
||||
if (text) return text;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
const chars = Array.from(text);
|
||||
if (chars.length <= maxLength) return text;
|
||||
if (maxLength <= 0) return "";
|
||||
if (maxLength === 1) return "…";
|
||||
return `${chars.slice(0, maxLength - 1).join("")}…`;
|
||||
}
|
||||
|
||||
function extractContentText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
return content
|
||||
.map((block) => {
|
||||
if (!isRecord(block)) return "";
|
||||
return block.type === "text" && typeof block.text === "string" ? block.text : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function normalizeMessageText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { KittyVisibility, MessageSource, NotificationAction } from "./types.ts";
|
||||
|
||||
export function parseMessageSource(value: string | undefined): MessageSource {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "user" || normalized === "none") return normalized;
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
export function parseMessageMax(value: string | undefined): number {
|
||||
return parseBoundedInteger(value, 80, 0, 500);
|
||||
}
|
||||
|
||||
export function parseMinDurationMs(value: string | undefined): number {
|
||||
const seconds = parseBoundedNumber(value, 3, 0, 3600);
|
||||
return Math.round(seconds * 1000);
|
||||
}
|
||||
|
||||
export function parseEnabled(value: string | undefined): boolean {
|
||||
if (value === undefined) return true;
|
||||
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function parseKittyVisibility(value: string | undefined): KittyVisibility {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "always" || normalized === "invisible") return normalized;
|
||||
return "unfocused";
|
||||
}
|
||||
|
||||
export function parseNotificationAction(value: string | undefined): NotificationAction {
|
||||
return value?.trim().toLowerCase() === "none" ? "none" : "focus";
|
||||
}
|
||||
|
||||
function parseBoundedInteger(value: string | undefined, fallback: number, min: number, max: number): number {
|
||||
const parsed = Number.parseInt(value || String(fallback), 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.max(min, Math.min(parsed, max));
|
||||
}
|
||||
|
||||
function parseBoundedNumber(value: string | undefined, fallback: number, min: number, max: number): number {
|
||||
const normalized = value?.trim();
|
||||
const parsed = Number(normalized ? normalized : fallback);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.max(min, Math.min(parsed, max));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { basename } from "node:path";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { TemplateValues } from "./types.ts";
|
||||
|
||||
export function renderTemplate(
|
||||
template: string,
|
||||
ctx: ExtensionContext | undefined,
|
||||
durationMs: number,
|
||||
extraValues: TemplateValues = {},
|
||||
): string {
|
||||
const cwd = ctx?.cwd || process.cwd();
|
||||
const values: TemplateValues = {
|
||||
cwd,
|
||||
project: basename(cwd),
|
||||
model: formatModel(ctx),
|
||||
model_short: formatModelShort(ctx),
|
||||
duration: formatDuration(durationMs),
|
||||
duration_ms: String(durationMs),
|
||||
...extraValues,
|
||||
};
|
||||
|
||||
return template.replace(/\{([a-z_]+)\}/g, (match, key) => values[key] ?? match);
|
||||
}
|
||||
|
||||
function formatModel(ctx: ExtensionContext | undefined): string {
|
||||
if (!ctx?.model) return "unknown";
|
||||
return `${ctx.model.provider}/${ctx.model.id}`;
|
||||
}
|
||||
|
||||
function formatModelShort(ctx: ExtensionContext | undefined): string {
|
||||
return ctx?.model?.id || "unknown";
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const seconds = Math.round((ms % 60_000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import type { NotificationBackend, NotifyConfig } from "./types.ts";
|
||||
|
||||
const ESC = "\x1b";
|
||||
const BEL = "\x07";
|
||||
const ST = `${ESC}\\`;
|
||||
|
||||
type NotifierDependencies = {
|
||||
spawn: typeof spawn;
|
||||
readTmuxClientInfo: () => string | undefined;
|
||||
platform: NodeJS.Platform;
|
||||
};
|
||||
|
||||
export function createNotifier(dependencies: Partial<NotifierDependencies> = {}) {
|
||||
const spawnCommand = dependencies.spawn ?? spawn;
|
||||
const tmuxClientInfo = dependencies.readTmuxClientInfo ?? readTmuxClientInfo;
|
||||
const platform = dependencies.platform ?? process.platform;
|
||||
|
||||
return {
|
||||
getBackend(): NotificationBackend {
|
||||
return detectNotificationBackend(tmuxClientInfo, platform);
|
||||
},
|
||||
sendNotification(config: NotifyConfig): void {
|
||||
try {
|
||||
const backend = detectNotificationBackend(tmuxClientInfo, platform);
|
||||
if (backend === "kitty") notifyKitty(config);
|
||||
else if (backend === "apple-script") notifyAppleScript(config.title, config.body, spawnCommand);
|
||||
else notifyOsc777(config.title, config.body);
|
||||
|
||||
runSoundHook(config.soundCommand, spawnCommand);
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const defaultNotifier = createNotifier();
|
||||
|
||||
export function sendNotification(config: NotifyConfig): void {
|
||||
defaultNotifier.sendNotification(config);
|
||||
}
|
||||
|
||||
export function getNotificationBackend(): NotificationBackend {
|
||||
return defaultNotifier.getBackend();
|
||||
}
|
||||
|
||||
export function detectNotificationBackend(
|
||||
readClientInfo: () => string | undefined = readTmuxClientInfo,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): NotificationBackend {
|
||||
if (isKitty(readClientInfo)) return "kitty";
|
||||
return platform === "darwin" ? "apple-script" : "osc-777";
|
||||
}
|
||||
|
||||
function isKitty(readClientInfo: () => string | undefined): boolean {
|
||||
if (!process.env.TMUX) return Boolean(process.env.KITTY_WINDOW_ID);
|
||||
|
||||
const info = readClientInfo()?.toLowerCase();
|
||||
if (info) return info.includes("kitty");
|
||||
return Boolean(process.env.KITTY_WINDOW_ID);
|
||||
}
|
||||
|
||||
function readTmuxClientInfo(): string | undefined {
|
||||
try {
|
||||
return execFileSync("tmux", ["display-message", "-p", "#{client_termname} #{client_termtype}"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyKitty(config: NotifyConfig): void {
|
||||
const safeTitle = Buffer.from(config.title, "utf8").toString("base64");
|
||||
const safeBody = Buffer.from(config.body, "utf8").toString("base64");
|
||||
const id = sanitizeMetadataValue(config.notificationId);
|
||||
writeSequence(
|
||||
`${ESC}]99;i=${id}:d=0:e=1:o=${config.visibility}:a=${config.action};${safeTitle}${ST}`,
|
||||
);
|
||||
writeSequence(`${ESC}]99;i=${id}:p=body:e=1;${safeBody}${ST}`);
|
||||
}
|
||||
|
||||
function notifyAppleScript(title: string, body: string, spawnCommand: typeof spawn): void {
|
||||
const script = `display notification ${appleScriptString(body)} with title ${appleScriptString(title)}`;
|
||||
spawnDetached(spawnCommand, "osascript", ["-e", script]);
|
||||
}
|
||||
|
||||
function notifyOsc777(title: string, body: string): void {
|
||||
process.stdout.write(`${ESC}]777;notify;${sanitizeOsc777(title)};${sanitizeOsc777(body)}${BEL}`);
|
||||
}
|
||||
|
||||
function writeSequence(sequence: string): void {
|
||||
process.stdout.write(wrapForTmux(sequence));
|
||||
}
|
||||
|
||||
function wrapForTmux(sequence: string): string {
|
||||
if (!process.env.TMUX) return sequence;
|
||||
return `${ESC}Ptmux;${sequence.replaceAll(ESC, ESC + ESC)}${ST}`;
|
||||
}
|
||||
|
||||
function sanitizeMetadataValue(value: string): string {
|
||||
const sanitized = value.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 128);
|
||||
return sanitized || `pi-${process.pid}`;
|
||||
}
|
||||
|
||||
function sanitizeOsc777(value: string): string {
|
||||
return value.replace(/[\x00-\x1f\x7f;]/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function appleScriptString(value: string): string {
|
||||
return `"${value.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\\/g, "\\\\").replace(/"/g, '\\"').trim()}"`;
|
||||
}
|
||||
|
||||
function runSoundHook(command: string | undefined, spawnCommand: typeof spawn): void {
|
||||
if (!command) return;
|
||||
spawnDetached(spawnCommand, command, [], true);
|
||||
}
|
||||
|
||||
function spawnDetached(spawnCommand: typeof spawn, command: string, args: string[], shell = false): void {
|
||||
const child = spawnCommand(command, args, {
|
||||
shell,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on?.("error", () => {});
|
||||
child.unref();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type MessageSource = "user" | "assistant" | "none";
|
||||
|
||||
export type KittyVisibility = "always" | "unfocused" | "invisible";
|
||||
export type NotificationAction = "focus" | "none";
|
||||
export type NotificationBackend = "kitty" | "apple-script" | "osc-777";
|
||||
|
||||
export type NotifyConfig = {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
body: string;
|
||||
durationMs: number;
|
||||
minDurationMs: number;
|
||||
notificationId: string;
|
||||
visibility: KittyVisibility;
|
||||
action: NotificationAction;
|
||||
soundCommand?: string;
|
||||
};
|
||||
|
||||
export type NotificationRuntimeValues = {
|
||||
notificationId: string;
|
||||
toolErrorCount?: number;
|
||||
};
|
||||
|
||||
export type TemplateValues = Record<string, string>;
|
||||
Reference in New Issue
Block a user