feat: add Kitty-aware completion notifications

This commit is contained in:
叶林立
2026-08-24 20:13:12 +08:00
parent d3bf562189
commit d65ce3800c
24 changed files with 3120 additions and 1 deletions
+56
View File
@@ -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;
}
+91
View File
@@ -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();
+42
View File
@@ -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;
}
+44
View File
@@ -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));
}
+41
View File
@@ -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`;
}
+127
View File
@@ -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();
}
+24
View File
@@ -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>;