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
+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();
}