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,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { getConfig, shouldNotify } from "../src/config.ts";
|
||||
|
||||
const envKeys = [
|
||||
"PI_NOTIFY_ENABLED",
|
||||
"PI_NOTIFY_TITLE",
|
||||
"PI_NOTIFY_BODY",
|
||||
"PI_NOTIFY_MESSAGE_SOURCE",
|
||||
"PI_NOTIFY_MESSAGE_MAX",
|
||||
"PI_NOTIFY_MIN_SECONDS",
|
||||
"PI_NOTIFY_VISIBILITY",
|
||||
"PI_NOTIFY_ACTION",
|
||||
"PI_NOTIFY_SOUND_CMD",
|
||||
] as const;
|
||||
|
||||
const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
const value = savedEnv.get(key);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function context(): ExtensionContext {
|
||||
return {
|
||||
cwd: "/tmp/pi-notify",
|
||||
model: { provider: "openai", id: "gpt-4.1" },
|
||||
} as ExtensionContext;
|
||||
}
|
||||
|
||||
const runtime = { notificationId: "pi-session", toolErrorCount: 0 };
|
||||
|
||||
describe("config", () => {
|
||||
it("builds safe Kitty defaults from the settled assistant output", () => {
|
||||
const config = getConfig(
|
||||
context(),
|
||||
800,
|
||||
[
|
||||
{ role: "user", content: "please fix this" },
|
||||
{ role: "assistant", content: "fixed it" },
|
||||
],
|
||||
runtime,
|
||||
5_000,
|
||||
);
|
||||
|
||||
assert.deepEqual(config, {
|
||||
enabled: true,
|
||||
title: "π - pi-notify",
|
||||
body: "fixed it",
|
||||
durationMs: 4_200,
|
||||
minDurationMs: 3_000,
|
||||
notificationId: "pi-session",
|
||||
visibility: "unfocused",
|
||||
action: "focus",
|
||||
soundCommand: undefined,
|
||||
});
|
||||
assert.equal(shouldNotify(config), true);
|
||||
});
|
||||
|
||||
it("supports privacy, threshold, focus, and status template settings", () => {
|
||||
process.env.PI_NOTIFY_ENABLED = "off";
|
||||
process.env.PI_NOTIFY_TITLE = "{status_icon} {status} {tool_error_count}";
|
||||
process.env.PI_NOTIFY_BODY = "{message}";
|
||||
process.env.PI_NOTIFY_MESSAGE_SOURCE = "none";
|
||||
process.env.PI_NOTIFY_MIN_SECONDS = "10.5";
|
||||
process.env.PI_NOTIFY_VISIBILITY = "invisible";
|
||||
process.env.PI_NOTIFY_ACTION = "none";
|
||||
process.env.PI_NOTIFY_SOUND_CMD = " say done ";
|
||||
|
||||
const config = getConfig(context(), 1_000, [{ role: "assistant", content: "secret" }], {
|
||||
notificationId: "pi-session",
|
||||
toolErrorCount: 2,
|
||||
}, 2_000);
|
||||
|
||||
assert.equal(config.title, "⚠ ready · 2 tool errors 2");
|
||||
assert.equal(config.body, "Ready for input");
|
||||
assert.equal(config.enabled, false);
|
||||
assert.equal(config.minDurationMs, 10_500);
|
||||
assert.equal(config.visibility, "invisible");
|
||||
assert.equal(config.action, "none");
|
||||
assert.equal(config.soundCommand, "say done");
|
||||
assert.equal(shouldNotify(config), false);
|
||||
assert.equal(shouldNotify(config, true), false, "the minimum duration still applies to command overrides");
|
||||
});
|
||||
|
||||
it("applies selected-message truncation and a session override", () => {
|
||||
process.env.PI_NOTIFY_MESSAGE_SOURCE = "user";
|
||||
process.env.PI_NOTIFY_MESSAGE_MAX = "5";
|
||||
process.env.PI_NOTIFY_MIN_SECONDS = "0";
|
||||
const config = getConfig(
|
||||
context(),
|
||||
1_000,
|
||||
[{ role: "user", content: "hello world" }],
|
||||
runtime,
|
||||
2_000,
|
||||
);
|
||||
|
||||
assert.equal(config.body, "hell…");
|
||||
assert.equal(shouldNotify(config, false), false);
|
||||
assert.equal(shouldNotify(config, true), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { createPiNotifyExtension } from "../src/index.ts";
|
||||
import type { NotifyConfig } from "../src/types.ts";
|
||||
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
const savedMinSeconds = process.env.PI_NOTIFY_MIN_SECONDS;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedMinSeconds === undefined) delete process.env.PI_NOTIFY_MIN_SECONDS;
|
||||
else process.env.PI_NOTIFY_MIN_SECONDS = savedMinSeconds;
|
||||
});
|
||||
|
||||
function harness(mode: ExtensionContext["mode"] = "tui") {
|
||||
const handlers = new Map<string, Handler>();
|
||||
const commands = new Map<string, { handler: Handler }>();
|
||||
const notifications: NotifyConfig[] = [];
|
||||
const uiMessages: string[] = [];
|
||||
let now = 1_000;
|
||||
|
||||
const pi = {
|
||||
on(name: string, handler: Handler) {
|
||||
handlers.set(name, handler);
|
||||
},
|
||||
registerCommand(name: string, command: { handler: Handler }) {
|
||||
commands.set(name, command);
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
createPiNotifyExtension({
|
||||
now: () => now,
|
||||
createNotificationId: () => "pi-session-test",
|
||||
notify: (config) => notifications.push(config),
|
||||
getBackend: () => "kitty",
|
||||
})(pi);
|
||||
|
||||
const ctx = {
|
||||
mode,
|
||||
hasUI: mode === "tui" || mode === "rpc",
|
||||
cwd: "/tmp/project-a",
|
||||
model: { provider: "openai", id: "gpt-test" },
|
||||
ui: {
|
||||
notify(message: string) {
|
||||
uiMessages.push(message);
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext;
|
||||
|
||||
return {
|
||||
handlers,
|
||||
commands,
|
||||
notifications,
|
||||
uiMessages,
|
||||
ctx,
|
||||
setNow(value: number) {
|
||||
now = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("pi-notify extension", () => {
|
||||
it("notifies once only after retries and follow-ups have settled", async () => {
|
||||
const h = harness();
|
||||
await h.handlers.get("agent_start")?.({}, h.ctx);
|
||||
h.setNow(2_000);
|
||||
await h.handlers.get("agent_end")?.({ messages: [{ role: "assistant", content: "retrying" }] }, h.ctx);
|
||||
await h.handlers.get("agent_start")?.({}, h.ctx);
|
||||
await h.handlers.get("tool_execution_end")?.({ isError: true }, h.ctx);
|
||||
h.setNow(5_000);
|
||||
await h.handlers.get("agent_end")?.({ messages: [{ role: "assistant", content: "finally done" }] }, h.ctx);
|
||||
|
||||
assert.equal(h.notifications.length, 0, "agent_end must not notify before Pi is settled");
|
||||
await h.handlers.get("agent_settled")?.({}, h.ctx);
|
||||
|
||||
assert.equal(h.notifications.length, 1);
|
||||
assert.equal(h.notifications[0]?.body, "finally done");
|
||||
assert.equal(h.notifications[0]?.durationMs, 4_000, "duration includes retries");
|
||||
assert.equal(h.notifications[0]?.notificationId, "pi-session-test");
|
||||
});
|
||||
|
||||
it("does not write terminal notifications in RPC or non-interactive modes", async () => {
|
||||
process.env.PI_NOTIFY_MIN_SECONDS = "0";
|
||||
for (const mode of ["rpc", "json", "print"] as const) {
|
||||
const h = harness(mode);
|
||||
await h.handlers.get("agent_start")?.({}, h.ctx);
|
||||
await h.handlers.get("agent_settled")?.({}, h.ctx);
|
||||
assert.equal(h.notifications.length, 0, mode);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers session controls and a test notification", async () => {
|
||||
const h = harness();
|
||||
const command = h.commands.get("notify");
|
||||
assert.ok(command);
|
||||
|
||||
await command.handler("off", h.ctx);
|
||||
assert.match(h.uiMessages.at(-1) ?? "", /off/);
|
||||
await command.handler("test", h.ctx);
|
||||
assert.equal(h.notifications.length, 1);
|
||||
assert.match(h.notifications[0]?.body ?? "", /click to focus this window/);
|
||||
await command.handler("status", h.ctx);
|
||||
assert.match(h.uiMessages.at(-1) ?? "", /kitty/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { extractMessageText, truncateText } from "../src/messages.ts";
|
||||
|
||||
describe("messages", () => {
|
||||
it("extracts the latest text message for a role", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "first user" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "first assistant" }] },
|
||||
{ role: "user", content: [{ type: "text", text: "latest\nuser" }] },
|
||||
];
|
||||
|
||||
assert.equal(extractMessageText(messages, "user"), "latest user");
|
||||
assert.equal(extractMessageText(messages, "assistant"), "first assistant");
|
||||
});
|
||||
|
||||
it("ignores non-text content blocks", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "image", text: "ignore me" },
|
||||
{ type: "text", text: "keep me" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(extractMessageText(messages, "assistant"), "keep me");
|
||||
});
|
||||
|
||||
it("truncates by unicode code points", () => {
|
||||
assert.equal(truncateText("hello", 10), "hello");
|
||||
assert.equal(truncateText("hello", 4), "hel…");
|
||||
assert.equal(truncateText("🤖abc", 3), "🤖a…");
|
||||
assert.equal(truncateText("hello", 1), "…");
|
||||
assert.equal(truncateText("hello", 0), "");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
parseEnabled,
|
||||
parseKittyVisibility,
|
||||
parseMessageMax,
|
||||
parseMessageSource,
|
||||
parseMinDurationMs,
|
||||
parseNotificationAction,
|
||||
} from "../src/parsers.ts";
|
||||
|
||||
describe("parsers", () => {
|
||||
it("parses message source including privacy mode", () => {
|
||||
assert.equal(parseMessageSource("assistant"), "assistant");
|
||||
assert.equal(parseMessageSource(" user "), "user");
|
||||
assert.equal(parseMessageSource("none"), "none");
|
||||
assert.equal(parseMessageSource("invalid"), "assistant");
|
||||
});
|
||||
|
||||
it("parses and clamps numeric settings", () => {
|
||||
assert.equal(parseMessageMax(undefined), 80);
|
||||
assert.equal(parseMessageMax("12"), 12);
|
||||
assert.equal(parseMessageMax("-1"), 0);
|
||||
assert.equal(parseMessageMax("999"), 500);
|
||||
assert.equal(parseMessageMax("nope"), 80);
|
||||
assert.equal(parseMinDurationMs(undefined), 3_000);
|
||||
assert.equal(parseMinDurationMs(" "), 3_000);
|
||||
assert.equal(parseMinDurationMs("1.25"), 1_250);
|
||||
assert.equal(parseMinDurationMs("9999"), 3_600_000);
|
||||
});
|
||||
|
||||
it("uses fail-safe defaults for boolean and Kitty behavior settings", () => {
|
||||
assert.equal(parseEnabled(undefined), true);
|
||||
assert.equal(parseEnabled("off"), false);
|
||||
assert.equal(parseEnabled("yes"), true);
|
||||
assert.equal(parseKittyVisibility(undefined), "unfocused");
|
||||
assert.equal(parseKittyVisibility("always"), "always");
|
||||
assert.equal(parseKittyVisibility("invalid"), "unfocused");
|
||||
assert.equal(parseNotificationAction("none"), "none");
|
||||
assert.equal(parseNotificationAction("invalid"), "focus");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { renderTemplate } from "../src/template.ts";
|
||||
|
||||
function context(): ExtensionContext {
|
||||
return {
|
||||
cwd: "/tmp/pi-notify",
|
||||
model: { provider: "ollama", id: "qwen3" },
|
||||
} as ExtensionContext;
|
||||
}
|
||||
|
||||
describe("template", () => {
|
||||
it("renders built-in values and keeps unknown placeholders", () => {
|
||||
const output = renderTemplate(
|
||||
"{project} {cwd} {model} {model_short} {duration} {duration_ms} {missing}",
|
||||
context(),
|
||||
4200,
|
||||
);
|
||||
|
||||
assert.equal(output, "/tmp/pi-notify".split("/").at(-1) + " /tmp/pi-notify ollama/qwen3 qwen3 4.2s 4200 {missing}");
|
||||
});
|
||||
|
||||
it("allows extra values to override built-ins", () => {
|
||||
assert.equal(renderTemplate("{project} {message}", context(), 0, { project: "custom", message: "done" }), "custom done");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
import { createNotifier, detectNotificationBackend } from "../src/terminal.ts";
|
||||
import type { NotifyConfig } from "../src/types.ts";
|
||||
|
||||
const ESC = "\x1b";
|
||||
const ST = `${ESC}\\`;
|
||||
const savedEnv = {
|
||||
KITTY_WINDOW_ID: process.env.KITTY_WINDOW_ID,
|
||||
TMUX: process.env.TMUX,
|
||||
};
|
||||
|
||||
let originalWrite: typeof process.stdout.write;
|
||||
let output = "";
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.KITTY_WINDOW_ID;
|
||||
delete process.env.TMUX;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stdout.write = originalWrite;
|
||||
output = "";
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function captureStdout(): void {
|
||||
originalWrite = process.stdout.write;
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
output += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
}
|
||||
|
||||
function config(title = "Title", body = "Body"): NotifyConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
title,
|
||||
body,
|
||||
durationMs: 5_000,
|
||||
minDurationMs: 3_000,
|
||||
notificationId: "pi-session:unsafe",
|
||||
visibility: "unfocused",
|
||||
action: "focus",
|
||||
};
|
||||
}
|
||||
|
||||
function captureSpawn(): { calls: unknown[][]; spawn: typeof import("node:child_process").spawn } {
|
||||
const calls: unknown[][] = [];
|
||||
const spawn = ((...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
return { on() {}, unref() {} };
|
||||
}) as unknown as typeof import("node:child_process").spawn;
|
||||
return { calls, spawn };
|
||||
}
|
||||
|
||||
describe("terminal notifications", () => {
|
||||
it("writes encoded Kitty OSC 99 payloads with exact-window focus metadata", () => {
|
||||
process.env.KITTY_WINDOW_ID = "1";
|
||||
const notifier = createNotifier();
|
||||
captureStdout();
|
||||
notifier.sendNotification(config("π - pi-notify", "done\nnow"));
|
||||
|
||||
const title = Buffer.from("π - pi-notify", "utf8").toString("base64");
|
||||
const body = Buffer.from("done\nnow", "utf8").toString("base64");
|
||||
assert.equal(
|
||||
output,
|
||||
`${ESC}]99;i=pi-session-unsafe:d=0:e=1:o=unfocused:a=focus;${title}${ST}` +
|
||||
`${ESC}]99;i=pi-session-unsafe:p=body:e=1;${body}${ST}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps Kitty OSC 99 sequences for tmux passthrough", () => {
|
||||
process.env.TMUX = "/tmp/tmux";
|
||||
const notifier = createNotifier({ readTmuxClientInfo: () => "xterm-kitty xterm-kitty" });
|
||||
captureStdout();
|
||||
notifier.sendNotification(config("Pi", "done"));
|
||||
|
||||
const title = Buffer.from("Pi", "utf8").toString("base64");
|
||||
const body = Buffer.from("done", "utf8").toString("base64");
|
||||
const titleSequence = `${ESC}]99;i=pi-session-unsafe:d=0:e=1:o=unfocused:a=focus;${title}${ST}`.replaceAll(ESC, ESC + ESC);
|
||||
const bodySequence = `${ESC}]99;i=pi-session-unsafe:p=body:e=1;${body}${ST}`.replaceAll(ESC, ESC + ESC);
|
||||
assert.equal(output, `${ESC}Ptmux;${titleSequence}${ST}${ESC}Ptmux;${bodySequence}${ST}`);
|
||||
});
|
||||
|
||||
it("uses AppleScript safely on macOS when Kitty is not detected", () => {
|
||||
const { calls, spawn } = captureSpawn();
|
||||
const notifier = createNotifier({ spawn, platform: "darwin" });
|
||||
captureStdout();
|
||||
notifier.sendNotification(config('Pi "notify"', "done\\now"));
|
||||
|
||||
assert.equal(output, "");
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
"osascript",
|
||||
["-e", 'display notification "done\\\\now" with title "Pi \\"notify\\""'],
|
||||
{ shell: false, detached: true, stdio: "ignore" },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let a stale Kitty variable override the active tmux client", () => {
|
||||
process.env.TMUX = "/tmp/tmux";
|
||||
process.env.KITTY_WINDOW_ID = "stale";
|
||||
assert.equal(
|
||||
detectNotificationBackend(() => "xterm-ghostty ghostty", "darwin"),
|
||||
"apple-script",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a sanitized OSC 777 fallback outside macOS", () => {
|
||||
const notifier = createNotifier({ platform: "linux" });
|
||||
captureStdout();
|
||||
notifier.sendNotification(config("Pi;bad", "done\u0007now"));
|
||||
assert.equal(output, `${ESC}]777;notify;Pi bad;done now\x07`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user