pi-win-notify 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # pi-desktop-notify
2
+
3
+ Desktop notification for [pi coding agent](https://pi.dev). Pops up a WPF notification when pi finishes output โ€” so you don't miss it after switching to another app.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install pi-desktop-notify
9
+ ```
10
+
11
+ Or from GitHub:
12
+
13
+ ```bash
14
+ pi install git:https://github.com/ryanchan720/pi-desktop-notify.git
15
+ ```
16
+
17
+ ## Architecture
18
+
19
+ - **`desktop-notify.ts`** โ€” pi extension (commands, events, foreground detection via koffi)
20
+ - **`host.ps1`** โ€” Persistent PowerShell daemon: loads WPF + compiles C# once, waits for JSON on stdin
21
+
22
+ ## Usage
23
+
24
+ | Command | Description |
25
+ |---------|-------------|
26
+ | `/notify` | Toggle on/off |
27
+ | `/notify on` / `off` | Force state |
28
+ | `/notify timeout 15` | Auto-dismiss seconds (5~60, default 15) |
29
+ | `/notify opacity 1.0` | Window opacity (0.3~1.0, default 1.0) |
30
+ | `/notify message fixed` | Fixed completion text |
31
+ | `/notify message response` | AI reply first 50 chars (default) |
32
+ | `/notify lang en` | Language: `zh` `en` `ja` `ko` (default `en`) |
33
+ | `/notify status` | Show daemon health + current config |
34
+
35
+ ## Features
36
+
37
+ - WPF dark-themed popup, bottom-right corner
38
+ - Follows cursor to active monitor
39
+ - Top-most, doesn't steal focus, configurable opacity, auto-dismiss
40
+ - โฑ Elapsed time display
41
+ - ๐Ÿ”• Mute button on notification: 3 min / 30 min / 1 hour / off
42
+ - Write-through to `notify.json`, survives restarts
43
+ - Multi-instance aware: every `agent_end` checks if mute has expired
44
+ - **Alt+[** = Dismiss, **Alt+]** = Switch back & focus terminal
45
+ - Auto-suppressed during LLM retries & context compaction
46
+ - Skipped when terminal window is already focused
47
+ - Title = first 25 characters of your prompt
48
+ - Multi-pi stacking (offsets upward)
49
+ - Persistent PS host: first notification ~3s, subsequent <0.5s
50
+ - Cross-platform: Windows (WPF), macOS (Notification Center), Linux (notify-send)
51
+
52
+ ## Test
53
+
54
+ ```bash
55
+ node --experimental-strip-types tests/tests.ts
56
+ ```
57
+
58
+ 42 unit tests covering content extraction, retry detection, and state machine.
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Pi Desktop Notification โ€” ๆกŒ้ข้€š็Ÿฅๆ‰ฉๅฑ•
3
+ *
4
+ * pi ๅฎŒๆˆ่พ“ๅ‡บๆ—ถๅณไธ‹่ง’ๅผนๅ‡บ WPF ๆš—่‰ฒ้€š็Ÿฅ็ช—ๅฃใ€‚ๅˆ‡ๅˆฐๅ…ถไป–็จ‹ๅบๆ—ถไธไผš้”™่ฟ‡ใ€‚
5
+ *
6
+ * ## ไฝฟ็”จ
7
+ *
8
+ * /notify ๅˆ‡ๆข้€š็Ÿฅๅผ€ๅ…ณ (on/off)
9
+ * /notify on|off ็›ดๆŽฅ่ฎพ็ฝฎ
10
+ * ๅผน็ช—ๆŒ‰้’ฎ: ็Ÿฅ้“ไบ† / ็ปง็ปญ๏ผˆๅˆ‡ๅ›ž็ปˆ็ซฏๅนถ่š็„ฆ๏ผ‰
11
+ * ๅ…จๅฑ€ๅฟซๆท้”ฎ: Alt+[ = ็Ÿฅ้“ไบ† Alt+] = ็ปง็ปญ
12
+ *
13
+ * ## ่กŒไธบ
14
+ *
15
+ * - ๅณไธ‹่ง’็ฝฎ้กถใ€ไธๆŠข็„ฆ็‚นใ€80% ๅŠ้€ๆ˜Žใ€15 ็ง’่‡ชๅŠจๆถˆๅคฑ
16
+ * - ้€š็Ÿฅๆ ‡้ข˜ = ๅฝ“ๅ‰่ฝฎ็”จๆˆทๆถˆๆฏๅ‰ 10 ๅญ—
17
+ * - LLM ่ถ…ๆ—ถ้‡่ฏ•ๆ—ถ่‡ชๅŠจๆŠ‘ๅˆถ๏ผˆ2 ็ง’ๅ†ทๅดๆœŸ๏ผŒๅชๅœจไธปๅŠจๆƒไบค่ฟ˜็”จๆˆทๆ—ถๅผน๏ผ‰
18
+ * - ๅคš pi ็ช—ๅฃๅฎ‰ๅ…จ๏ผˆๅ„่‡ช็‹ฌ็ซ‹็š„ๅฅๆŸ„็ผ“ๅญ˜๏ผ‰
19
+ * - Footer ็Šถๆ€ๆŒ‡็คบ: ๐Ÿ”” ๅผ€ๅฏ / ๐Ÿ”• ๅ…ณ้—ญ
20
+ * ## ๆ–‡ไปถ
21
+ *
22
+ * ๆœฌๆ–‡ไปถๆ”พๅœจ ~/.pi/agent/extensions/ ไธ‹่‡ชๅŠจ็”Ÿๆ•ˆใ€‚
23
+ * ่ฐƒ่ฏ•ๆ—ฅๅฟ—: %TEMP%/pi-notify-debug.log
24
+ *
25
+ * ## ่ทจๅนณๅฐ
26
+ *
27
+ * Windows: WPF ๆš—่‰ฒ็ช—ๅฃ (ๆœฌๆ‰ฉๅฑ•ไธป่ฆ้’ˆๅฏน)
28
+ * macOS: osascript Notification Center
29
+ * Linux: notify-send
30
+ */
31
+
32
+ import koffi from "koffi";
33
+ import { spawn, type ChildProcess } from "node:child_process";
34
+ import { platform, tmpdir } from "node:os";
35
+ import { dirname, join } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import { appendFileSync, readFileSync, existsSync } from "node:fs";
38
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
39
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
40
+
41
+ const __filename = fileURLToPath(import.meta.url);
42
+ const __dirname = dirname(__filename);
43
+
44
+ // โ”€โ”€ Win32 API via koffi๏ผˆ้›ถ temp ๆ–‡ไปถ๏ผŒ้›ถ PowerShell๏ผ‰โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
45
+ let GetForegroundWindow: () => number;
46
+ let GetWindowTextW: (hwnd: number, buf: unknown, maxCount: number) => number;
47
+ let GetWindowTextLengthW: (hwnd: number) => number;
48
+
49
+ function initWin32(): void {
50
+ if (platform() !== "win32") return;
51
+ try {
52
+ const user32 = koffi.load("user32.dll");
53
+ GetForegroundWindow = user32.func("intptr_t GetForegroundWindow()");
54
+ GetWindowTextW = user32.func("int GetWindowTextW(intptr_t hWnd, char16_t* lpString, int nMaxCount)");
55
+ GetWindowTextLengthW = user32.func("int GetWindowTextLengthW(intptr_t hWnd)");
56
+ log("koffi: Win32 API initialized");
57
+ } catch (e: unknown) {
58
+ log(`koffi: init failed โ€” ${e}`);
59
+ }
60
+ }
61
+
62
+ // โ”€โ”€ ็Šถๆ€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
63
+ let enabled = true;
64
+ let uniqueWindowId = "";
65
+ let notifyTimer: ReturnType<typeof setTimeout> | null = null;
66
+ let taskStartTime = 0;
67
+ let psHost: ChildProcess | null = null;
68
+ let psHostReady = false;
69
+ let psHostCrashCount = 0;
70
+ let psHostLastError = "";
71
+ let piApi: ExtensionAPI | null = null;
72
+
73
+ // โ”€โ”€ ๅฏ้…็ฝฎ้กน โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
74
+ const CONFIG_PATH = join(getAgentDir(), "notify.json");
75
+ type Config = { timeout: number; opacity: number; messageMode: "fixed" | "response"; lang: "zh" | "en" | "ja" | "ko"; muteUntil?: number };
76
+
77
+ function loadConfig(): Config {
78
+ try {
79
+ if (existsSync(CONFIG_PATH)) {
80
+ const saved = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
81
+ return { timeout: saved.timeout ?? 15, opacity: saved.opacity ?? 1.0, messageMode: saved.messageMode ?? "response", lang: saved.lang ?? "en", muteUntil: saved.muteUntil };
82
+ }
83
+ } catch { /* */ }
84
+ return { timeout: 15, opacity: 1.0, messageMode: "response", lang: "en" };
85
+ }
86
+
87
+ function saveConfig(c: Config): void {
88
+ try {
89
+ const { writeFileSync } = require("node:fs");
90
+ writeFileSync(CONFIG_PATH, JSON.stringify(c, null, 2), "utf-8");
91
+ } catch { /* */ }
92
+ }
93
+
94
+ function saveMuteUntil(ts: number | undefined): void {
95
+ config.muteUntil = ts;
96
+ saveConfig(config);
97
+ }
98
+
99
+ const config = loadConfig();
100
+
101
+ // โ”€โ”€ i18n โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
102
+ const i18n: Record<string, Record<string, string>> = {
103
+ zh: { dismissBtn: " ็Ÿฅ ้“ ไบ† ", continueBtn: " ็ปง ็ปญ ", completion: "ไปปๅŠกๅฎŒๆˆ", switchBack: "ๅฏไปฅๅˆ‡ๅ›žๆฅไบ†", enabled: "้€š็Ÿฅๅทฒๅผ€ๅฏ ๐Ÿ””", disabled: "้€š็Ÿฅๅทฒๅ…ณ้—ญ ๐Ÿ”•", configTitle: "้€š็Ÿฅ้…็ฝฎ", timeoutLabel: "่ถ…ๆ—ถ", opacityLabel: "ไธ้€ๆ˜Žๅบฆ", modeLabel: "ๆจกๅผ", langLabel: "่ฏญ่จ€", statusLabel: "็Šถๆ€", on: "ๅผ€", off: "ๅ…ณ", mute3: "3 ๅˆ†้’Ÿ", mute30: "30 ๅˆ†้’Ÿ", mute60: "1 ๅฐๆ—ถ", muteOff: "ๅ…ณ้—ญๅ‹ฟๆ‰ฐ" },
104
+ en: { dismissBtn: " Dismiss ", continueBtn: "Continue", completion: "Task complete", switchBack: "Switch back", enabled: "Notify ON ๐Ÿ””", disabled: "Notify OFF ๐Ÿ”•", configTitle: "Notify Config", timeoutLabel: "Timeout", opacityLabel: "Opacity", modeLabel: "Mode", langLabel: "Language", statusLabel: "Status", on: "ON", off: "OFF", mute3: "3 min", mute30: "30 min", mute60: "1 hour", muteOff: "Turn off" },
105
+ ja: { dismissBtn: " ้–‰ใ˜ใ‚‹ ", continueBtn: " ็ถš ่กŒ ", completion: "ๅฎŒไบ†", switchBack: "ๆˆปใ‚Œใพใ™", enabled: "้€š็ŸฅON ๐Ÿ””", disabled: "้€š็ŸฅOFF ๐Ÿ”•", configTitle: "้€š็Ÿฅ่จญๅฎš", timeoutLabel: "ใ‚ฟใ‚คใƒ ใ‚ขใ‚ฆใƒˆ", opacityLabel: "ไธ้€ๆ˜Žๅบฆ", modeLabel: "ใƒขใƒผใƒ‰", langLabel: "่จ€่ชž", statusLabel: "็Šถๆ…‹", on: "ON", off: "OFF", mute3: "3 ๅˆ†", mute30: "30 ๅˆ†", mute60: "1 ๆ™‚้–“", muteOff: "ใ‚ชใƒ•" },
106
+ ko: { dismissBtn: " ๋‹ซ ๊ธฐ ", continueBtn: " ๊ณ„ ์† ", completion: "์™„๋ฃŒ", switchBack: "๋Œ์•„๊ฐ€๊ธฐ", enabled: "์•Œ๋ฆผ ON ๐Ÿ””", disabled: "์•Œ๋ฆผ OFF ๐Ÿ”•", configTitle: "์•Œ๋ฆผ ์„ค์ •", timeoutLabel: "์‹œ๊ฐ„์ œํ•œ", opacityLabel: "๋ถˆํˆฌ๋ช…๋„", modeLabel: "๋ชจ๋“œ", langLabel: "์–ธ์–ด", statusLabel: "์ƒํƒœ", on: "ON", off: "OFF", mute3: "3 ๋ถ„", mute30: "30 ๋ถ„", mute60: "1 ์‹œ๊ฐ„", muteOff: "๋„๊ธฐ" },
107
+ };
108
+ function t(key: string): string { return i18n[config.lang]?.[key] ?? i18n.zh[key] ?? key; }
109
+ function completionMsg(): string { return `${t("completion")}๏ผŒ${t("switchBack")} ๐ŸŽ‰`; }
110
+
111
+ function formatElapsed(ms: number): string {
112
+ if (ms < 1000) return `${ms}ms`;
113
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
114
+ const m = Math.floor(ms / 60000);
115
+ const s = Math.round((ms % 60000) / 1000);
116
+ return `${m}m ${s}s`;
117
+ }
118
+
119
+ function extractSummary(event: { messages?: unknown[] }): string | null {
120
+ try {
121
+ const msgs = event.messages as Array<{ role?: string; content?: unknown }>;
122
+ if (!msgs) return null;
123
+ for (let i = msgs.length - 1; i >= 0; i--) {
124
+ if (msgs[i].role === "assistant") {
125
+ const text = extractText(msgs[i].content);
126
+ if (text) {
127
+ const cleaned = text.replace(/\s+/g, " ").trim();
128
+ return cleaned.length > 50 ? cleaned.slice(0, 50) + "โ€ฆ" : cleaned;
129
+ }
130
+ }
131
+ }
132
+ } catch { /* */ }
133
+ return null;
134
+ }
135
+
136
+ // โ”€โ”€ ็ช—ๅฃๅฅๆŸ„็ผ“ๅญ˜๏ผˆkoffi ็›ด่ฐƒ user32.dll๏ผ‰โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
137
+ let terminalHwnd = "";
138
+ let terminalTitle = "";
139
+
140
+ function cacheTerminalHwnd(): void {
141
+ if (platform() !== "win32" || !GetForegroundWindow) return;
142
+ try {
143
+ const hwnd = GetForegroundWindow();
144
+ if (!hwnd) return;
145
+ const len = GetWindowTextLengthW(hwnd);
146
+ if (len > 0) {
147
+ const buf = Buffer.alloc((len + 1) * 2);
148
+ GetWindowTextW(hwnd, buf, len + 1);
149
+ terminalTitle = buf.toString("utf16le").replace(/\0+$/, "");
150
+ }
151
+ terminalHwnd = String(hwnd);
152
+ log(`cached terminal: hwnd=${terminalHwnd} title="${terminalTitle}"`);
153
+ } catch (e: unknown) {
154
+ log(`FAIL cache hwnd: ${e}`);
155
+ }
156
+ }
157
+
158
+ // โ”€โ”€ ็ช—ๅฃๆ ‡่ฏ† โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
159
+ function generateWindowId(): string {
160
+ return `pi@${process.pid.toString(36)}`;
161
+ }
162
+
163
+ function setWindowTitle(title: string): void {
164
+ process.stdout.write(`\x1b]0;${title}\x07`);
165
+ }
166
+
167
+ // โ”€โ”€ ่ฐƒ่ฏ•ๆ—ฅๅฟ— โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
168
+ const LOG = join(tmpdir(), "pi-notify-debug.log");
169
+ function log(msg: string): void {
170
+ const ts = new Date().toISOString();
171
+ try { appendFileSync(LOG, `[${ts}] ${msg}\n`, "utf-8"); } catch { /* ignore */ }
172
+ }
173
+
174
+ // โ”€โ”€ ๅธธ้ฉป PowerShell ้€š็Ÿฅๅฎฟไธป๏ผˆhost.ps1 ็‹ฌ็ซ‹ๆ–‡ไปถ๏ผŒไธ€ๆฌก็ผ–่ฏ‘๏ผŒๅŽ็ปญ stdin ไธ€่กŒๅณๅผน๏ผ‰
175
+
176
+ function spawnHost(): void {
177
+ if (platform() !== "win32") return;
178
+ if (psHost) {
179
+ try { psHost.kill(); } catch { /* */ }
180
+ psHost = null;
181
+ }
182
+ psHostReady = false;
183
+
184
+ log("spawning PS host...");
185
+ const psPath = join(__dirname, "host.ps1");
186
+
187
+ const child = spawn("powershell.exe", [
188
+ "-NoProfile",
189
+ "-ExecutionPolicy", "Bypass",
190
+ "-WindowStyle", "Hidden",
191
+ "-File", psPath,
192
+ ], {
193
+ windowsHide: true,
194
+ stdio: ["pipe", "pipe", "pipe"],
195
+ });
196
+
197
+ let stdoutBuf = "";
198
+ child.stdout.on("data", (chunk: Buffer) => {
199
+ stdoutBuf += chunk.toString("utf-8");
200
+ const lines = stdoutBuf.split("\n");
201
+ stdoutBuf = lines.pop() || "";
202
+ for (const line of lines) {
203
+ const trimmed = line.trim();
204
+ if (!trimmed) continue;
205
+ if (trimmed === "READY") {
206
+ psHostReady = true;
207
+ psHostCrashCount = 0;
208
+ psHostLastError = "";
209
+ log("PS host ready");
210
+ } else if (trimmed === "OK") {
211
+ log("PS host: notification dismissed");
212
+ } else if (trimmed.startsWith("ERROR:")) {
213
+ log(`PS host error: ${trimmed}`);
214
+ } else if (trimmed.startsWith("MUTE:")) {
215
+ const mins = parseInt(trimmed.slice(5));
216
+ if (mins > 0) {
217
+ enabled = false;
218
+ saveMuteUntil(Date.now() + mins * 60000);
219
+ log(`mute: notifications off for ${mins}m`);
220
+ } else {
221
+ enabled = true;
222
+ saveMuteUntil(undefined);
223
+ log("mute off");
224
+ }
225
+ }
226
+ }
227
+ });
228
+
229
+ child.stderr.on("data", (chunk: Buffer) => {
230
+ log(`PS host stderr: ${chunk.toString("utf-8").trim()}`);
231
+ });
232
+
233
+ child.on("close", (code) => {
234
+ log(`PS host exited (code=${code})`);
235
+ if (code !== 0 && code !== null) {
236
+ psHostCrashCount++;
237
+ psHostLastError = `exit code ${code}`;
238
+ if (piApi) {
239
+ piApi.ui.notify(`ๆกŒ้ข้€š็ŸฅๆœๅŠกๅผ‚ๅธธ (${psHostLastError})๏ผŒไธ‹ๆฌกๅผน็ช—ๆ—ถ่‡ชๅŠจๆขๅค`, "warning");
240
+ }
241
+ }
242
+ psHost = null;
243
+ psHostReady = false;
244
+ });
245
+
246
+ child.on("error", (err) => {
247
+ log(`PS host spawn error: ${err.message}`);
248
+ psHost = null;
249
+ psHostReady = false;
250
+ });
251
+
252
+ psHost = child;
253
+ }
254
+
255
+ // โ”€โ”€ ๆกŒ้ข้€š็Ÿฅ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
256
+
257
+ function notifyWindows(title: string, body: string, hwnd: string, winTitle: string): void {
258
+ log(`notifyWindows: title="${title}" hwnd=${hwnd} win="${winTitle}"`);
259
+
260
+ if (!hwnd || hwnd === "0") {
261
+ log("no valid hwnd, skipping");
262
+ return;
263
+ }
264
+
265
+ if (!psHost || !psHostReady) {
266
+ log(`PS host not ready (host=${!!psHost}, ready=${psHostReady}), respawning...`);
267
+ spawnHost();
268
+ return;
269
+ }
270
+
271
+ const elapsedLabel = taskStartTime > 0 ? `โฑ ${formatElapsed(Date.now() - taskStartTime)}` : "";
272
+
273
+ const payload = JSON.stringify({
274
+ title,
275
+ body,
276
+ hwnd,
277
+ winTitle,
278
+ dismissLabel: t("dismissBtn"),
279
+ continueLabel: t("continueBtn"),
280
+ mute3Label: t("mute3"),
281
+ mute30Label: t("mute30"),
282
+ mute60Label: t("mute60"),
283
+ muteOffLabel: t("muteOff"),
284
+ timeoutSec: config.timeout,
285
+ opacityVal: config.opacity.toFixed(2),
286
+ elapsedLabel,
287
+ });
288
+
289
+ try {
290
+ psHost.stdin!.write(payload + "\n");
291
+ log("notify payload sent to PS host");
292
+ } catch (e: unknown) {
293
+ log(`notify stdin write failed: ${e}`);
294
+ spawnHost();
295
+ }
296
+ }
297
+
298
+ function notifyMacOS(title: string, body: string): void {
299
+ spawn("osascript", [
300
+ "-e",
301
+ `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`,
302
+ ], { detached: true, stdio: "ignore" }).unref();
303
+ }
304
+
305
+ function notifyLinux(title: string, body: string): void {
306
+ spawn("notify-send", [title, body], { detached: true, stdio: "ignore" }).unref();
307
+ }
308
+
309
+ function showNotification(title: string, body: string): void {
310
+ if (process.env.KITTY_WINDOW_ID) {
311
+ process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
312
+ process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
313
+ } else if (process.env.GHOSTTY_RESOURCES_DIR ||
314
+ process.env.ITERM_SESSION_ID ||
315
+ process.env.WEZTERM_PANE) {
316
+ process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
317
+ } else if (platform() === "win32") {
318
+ notifyWindows(title, body, terminalHwnd, uniqueWindowId);
319
+ } else if (platform() === "darwin") {
320
+ notifyMacOS(title, body);
321
+ } else {
322
+ notifyLinux(title, body);
323
+ }
324
+ }
325
+
326
+ // โ”€โ”€ ๆๅ–้€š็Ÿฅๆ ‡้ข˜ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
327
+
328
+ function extractPromptTitle(ctx: ExtensionContext): string {
329
+ try {
330
+ const entries = ctx.sessionManager.getEntries();
331
+ for (let i = entries.length - 1; i >= 0; i--) {
332
+ const entry = entries[i] as Record<string, unknown>;
333
+ const msg = entry.message as Record<string, unknown> | undefined;
334
+ const role = (entry.role ?? msg?.role) as string | undefined;
335
+ if (role === "user") {
336
+ const content = (entry.content ?? msg?.content);
337
+ const text = extractText(content);
338
+ if (text) {
339
+ const cleaned = text.replace(/\s+/g, " ").trim();
340
+ return cleaned.length > 10
341
+ ? cleaned.slice(0, 25) + "โ€ฆ"
342
+ : cleaned;
343
+ }
344
+ break;
345
+ }
346
+ }
347
+ } catch { /* fallback */ }
348
+ return "pi";
349
+ }
350
+
351
+ function extractText(content: unknown): string {
352
+ if (typeof content === "string") return content;
353
+ if (Array.isArray(content)) {
354
+ for (const block of content) {
355
+ if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
356
+ const t = (block as { text?: string }).text;
357
+ if (t) return t;
358
+ }
359
+ }
360
+ }
361
+ return "";
362
+ }
363
+
364
+ // โ”€โ”€ ๅ‰ๅฐๆฃ€ๆต‹๏ผˆkoffi ็›ด่ฐƒ GetForegroundWindow๏ผ‰โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
365
+
366
+ function isTerminalForeground(): boolean {
367
+ if (platform() !== "win32" || !terminalHwnd || terminalHwnd === "0" || !GetForegroundWindow) {
368
+ return false;
369
+ }
370
+ try {
371
+ const fgHwnd = String(GetForegroundWindow());
372
+ const match = fgHwnd === terminalHwnd;
373
+ log(`fg-check: foreground=${fgHwnd} match=${match}`);
374
+ return match;
375
+ } catch {
376
+ return false;
377
+ }
378
+ }
379
+
380
+ // โ”€โ”€ ๅผ‚ๅธธ็ป“ๆŸๆฃ€ๆต‹ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
381
+
382
+ const RETRY_PATTERN = /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
383
+
384
+ function getLastAssistantMessage(event: { messages?: unknown[] }): { stopReason?: string; errorMessage?: string } | null {
385
+ try {
386
+ const msgs = event.messages as Array<{ role?: string; stopReason?: string; errorMessage?: string }>;
387
+ if (!msgs) return null;
388
+ for (let i = msgs.length - 1; i >= 0; i--) {
389
+ if (msgs[i].role === "assistant") return msgs[i];
390
+ }
391
+ } catch { /* */ }
392
+ return null;
393
+ }
394
+
395
+ function willRetry(msg: { stopReason?: string; errorMessage?: string }): boolean {
396
+ if (msg.stopReason !== "error" || !msg.errorMessage) return false;
397
+ return RETRY_PATTERN.test(msg.errorMessage);
398
+ }
399
+
400
+ // โ”€โ”€ ๆ‰ฉๅฑ•ๅ…ฅๅฃ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
401
+
402
+ export default function (pi: ExtensionAPI) {
403
+ log("extension loaded");
404
+ piApi = pi;
405
+ initWin32();
406
+ uniqueWindowId = `pi@${process.pid.toString(36)}`;
407
+ process.stdout.write(`\x1b]0;${uniqueWindowId}\x07`);
408
+ log(`windowId = ${uniqueWindowId}`);
409
+ cacheTerminalHwnd();
410
+
411
+ // ๆขๅคไธŠๆฌกๆœช่ฟ‡ๆœŸ็š„ๅ‹ฟๆ‰ฐ๏ผˆไป…่ฎพ็ฝฎ็Šถๆ€๏ผŒๅˆฐๆœŸๆฃ€ๆŸฅ็”ฑ agent_end ๅค„็†๏ผ‰
412
+ if (config.muteUntil && config.muteUntil > Date.now()) {
413
+ enabled = false;
414
+ log(`mute restored: ${Math.round((config.muteUntil - Date.now()) / 60000)}m remaining`);
415
+ }
416
+
417
+ spawnHost();
418
+
419
+ // โ”€โ”€ /notify ๅ‘ฝไปค โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
420
+ pi.registerCommand("notify", {
421
+ description: "ๅผ€ๅ…ณ/้…็ฝฎๆกŒ้ข้€š็Ÿฅ",
422
+ getArgumentCompletions: (prefix) => {
423
+ const parts = prefix.trim().split(/\s+/).filter(Boolean);
424
+ const wantsNextLevel = prefix.endsWith(" ");
425
+
426
+ if (parts.length === 0 || (parts.length === 1 && !wantsNextLevel)) {
427
+ const subs = ["on", "off", "timeout", "opacity", "message", "lang", "status"];
428
+ const filtered = subs.filter((s) => s.startsWith(parts[0] ?? ""));
429
+ return filtered.length > 0 ? filtered.map((s) => ({ value: s, label: s })) : null;
430
+ }
431
+ const sub = parts[0];
432
+ const val = parts[1] ?? "";
433
+ if (sub === "timeout") {
434
+ return ["5", "10", "15", "20", "30", "60"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: `${s}s` }));
435
+ }
436
+ if (sub === "opacity") {
437
+ return ["0.5", "0.6", "0.7", "0.8", "0.9", "1.0"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: s }));
438
+ }
439
+ if (sub === "message") {
440
+ return ["fixed", "response"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: s === "response" ? "AI reply (20 chars)" : "Fixed text" }));
441
+ }
442
+ if (sub === "lang") {
443
+ return ["zh", "en", "ja", "ko"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: s }));
444
+ }
445
+ return null;
446
+ },
447
+ handler: async (args, ctx) => {
448
+ const raw = args?.trim() ?? "";
449
+ const parts = raw.split(/\s+/);
450
+ const sub = parts[0]?.toLowerCase();
451
+ const val = parts.slice(1).join(" ").toLowerCase();
452
+
453
+ if (!sub) {
454
+ enabled = !enabled;
455
+ ctx.ui.notify(enabled ? t("enabled") : t("disabled"), "info");
456
+ return;
457
+ }
458
+
459
+ if (sub === "on" || sub === "1" || sub === "true") { enabled = true; ctx.ui.notify(t("enabled"), "info"); return; }
460
+ if (sub === "off" || sub === "0" || sub === "false") { enabled = false; ctx.ui.notify(t("disabled"), "info"); return; }
461
+
462
+ if (sub === "timeout") {
463
+ const n = parseInt(val);
464
+ if (n >= 5 && n <= 60) { config.timeout = n; saveConfig(config); ctx.ui.notify(`Timeout=${n}s`, "info"); }
465
+ else { ctx.ui.notify("timeout: 5~60", "warning"); }
466
+ return;
467
+ }
468
+
469
+ if (sub === "opacity") {
470
+ const n = parseFloat(val);
471
+ if (n >= 0.3 && n <= 1.0) { config.opacity = n; saveConfig(config); ctx.ui.notify(`Opacity=${n}`, "info"); }
472
+ else { ctx.ui.notify("opacity: 0.3~1.0", "warning"); }
473
+ return;
474
+ }
475
+
476
+ if (sub === "message") {
477
+ if (val === "fixed" || val === "response") {
478
+ config.messageMode = val; saveConfig(config);
479
+ ctx.ui.notify(`Message=${val}`, "info");
480
+ } else { ctx.ui.notify("Usage: /notify message fixed|response", "warning"); }
481
+ return;
482
+ }
483
+
484
+ if (sub === "lang") {
485
+ if (i18n[val]) { config.lang = val as typeof config.lang; saveConfig(config); ctx.ui.notify(`Language=${val}`, "info"); }
486
+ else { ctx.ui.notify("Available: zh en ja ko", "warning"); }
487
+ return;
488
+ }
489
+
490
+ // /notify status
491
+ if (sub === "status") {
492
+ const hostStatus = psHostReady ? "๐ŸŸข" : psHost ? "๐ŸŸก" : "๐Ÿ”ด";
493
+ const crashInfo = psHostCrashCount > 0 ? ` ้‡ๅฏ${psHostCrashCount}ๆฌก` : "";
494
+ const lastErr = psHostLastError ? ` (${psHostLastError})` : "";
495
+ ctx.ui.notify(
496
+ `็Šถๆ€${hostStatus}${crashInfo}${lastErr} | ${t("timeoutLabel")}=${config.timeout}s ${t("opacityLabel")}=${config.opacity} ${t("modeLabel")}=${config.messageMode} ${t("langLabel")}=${config.lang} ${enabled ? t("on") : t("off")}`,
497
+ psHostReady ? "info" : "warning",
498
+ );
499
+ return;
500
+ }
501
+
502
+ ctx.ui.notify(`Unknown: ${raw} โ€” try /notify status`, "warning");
503
+ },
504
+ });
505
+
506
+ // โ”€โ”€ ไบ‹ไปถ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
507
+ pi.on("session_start", async () => {
508
+ log("session_start");
509
+ });
510
+
511
+ let isCompacting = false;
512
+ pi.on("session_before_compact", () => { isCompacting = true; log("compaction started"); });
513
+ pi.on("session_compact", () => { isCompacting = false; log("compaction ended"); });
514
+
515
+ pi.on("agent_start", () => {
516
+ taskStartTime = Date.now();
517
+ if (notifyTimer) {
518
+ clearTimeout(notifyTimer);
519
+ notifyTimer = null;
520
+ log(`agent_start: cancelled pending notification`);
521
+ }
522
+ });
523
+
524
+ pi.on("agent_end", (_event, ctx) => {
525
+ // ๅฎžๆ—ถๆฃ€ๆŸฅๅ‹ฟๆ‰ฐ่ฟ‡ๆœŸ๏ผˆๆฏๆฌก agent_end ่ฏปๆ–‡ไปถ๏ผŒ่ทจๅฎžไพ‹่‡ชๅŠจๅŒๆญฅ๏ผ‰
526
+ if (config.muteUntil && Date.now() > config.muteUntil) {
527
+ enabled = true;
528
+ saveMuteUntil(undefined);
529
+ log("mute expired (checked on agent_end)");
530
+ if (piApi) piApi.ui.notify("้€š็Ÿฅๅทฒๆขๅค ๐Ÿ””", "info");
531
+ }
532
+
533
+ log(`agent_end: enabled=${enabled} compacting=${isCompacting}`);
534
+ if (!enabled || isCompacting) return;
535
+
536
+ const msg = getLastAssistantMessage(_event);
537
+ if (msg) log(`agent_end: stopReason=${msg.stopReason} error=${!!msg.errorMessage}`);
538
+
539
+ if (msg) {
540
+ if (msg.stopReason === "aborted") { log("aborted, skip"); return; }
541
+ if (msg.stopReason === "error" && !willRetry(msg)) { log("non-retryable error, skip"); return; }
542
+ }
543
+
544
+ const title = extractPromptTitle(ctx);
545
+
546
+ if (notifyTimer) clearTimeout(notifyTimer);
547
+ const delay = (msg && willRetry(msg)) ? 10000 : 2000;
548
+ log(`agent_end: delay=${delay}ms`);
549
+ notifyTimer = setTimeout(() => {
550
+ notifyTimer = null;
551
+ if (isCompacting) { log("compaction in progress, skip"); return; }
552
+ const inForeground = isTerminalForeground();
553
+ log(`foreground: ${inForeground}`);
554
+ if (inForeground) return;
555
+ const body = config.messageMode === "response"
556
+ ? extractSummary(_event) ?? completionMsg()
557
+ : completionMsg();
558
+ log(`notification: "${title}" body="${body}"`);
559
+ showNotification(title, body);
560
+ }, delay);
561
+ });
562
+ }
@@ -0,0 +1,289 @@
1
+ ๏ปฟ$ErrorActionPreference = 'Stop'
2
+ [Console]::InputEncoding = [Text.Encoding]::UTF8
3
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
4
+ $log = "$env:TEMP\pi-notify-debug.log"
5
+ function l($m) { try { "$m" | Out-File -Append -Encoding utf8 $log } catch {} }
6
+
7
+ l '[host-start]'
8
+
9
+ Add-Type -AssemblyName PresentationFramework,PresentationCore,WindowsBase,System.Windows.Forms
10
+ l '[host-assemblies-ok]'
11
+
12
+ # PF: ๅˆ‡ๅ›ž็ปˆ็ซฏ
13
+ Add-Type -TypeDefinition @"
14
+ using System;
15
+ using System.Runtime.InteropServices;
16
+ using System.Text;
17
+ public class PF {
18
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n);
19
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
20
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
21
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumCb cb, IntPtr l);
22
+ [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
23
+ [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr h);
24
+ public delegate bool EnumCb(IntPtr h, IntPtr l);
25
+ public static string Search;
26
+ public static IntPtr Found = IntPtr.Zero;
27
+ public static string FoundTitle = "";
28
+ public static bool Callback(IntPtr h, IntPtr l) {
29
+ int len = GetWindowTextLength(h);
30
+ if (len > 0) {
31
+ var sb = new StringBuilder(len + 1);
32
+ GetWindowText(h, sb, sb.Capacity);
33
+ string t = sb.ToString();
34
+ if (t.Contains(Search)) { Found = h; FoundTitle = t; return false; }
35
+ }
36
+ return true;
37
+ }
38
+ }
39
+ "@
40
+
41
+ # HotkeyHelper: ็ƒญ้”ฎๆณจๅ†Œ + WPF ็ช—ๅฃ็ป‘ๅฎš
42
+ Add-Type -TypeDefinition @"
43
+ using System;
44
+ using System.Runtime.InteropServices;
45
+ using System.Windows;
46
+ using System.Windows.Interop;
47
+ public class HotkeyHelper {
48
+ [DllImport("user32.dll")] public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
49
+ [DllImport("user32.dll")] public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
50
+
51
+ public static void Setup(Window win, Action dismiss, Action focus, int idDismiss, int idFocus) {
52
+ var source = PresentationSource.FromVisual(win) as HwndSource;
53
+ if (source == null) return;
54
+ IntPtr hwnd = source.Handle;
55
+ uint MOD_ALT = 0x0001;
56
+ uint VK_OEM_4 = 0xDB;
57
+ uint VK_OEM_6 = 0xDD;
58
+ RegisterHotKey(hwnd, idDismiss, MOD_ALT, VK_OEM_4);
59
+ RegisterHotKey(hwnd, idFocus, MOD_ALT, VK_OEM_6);
60
+ source.AddHook((IntPtr h, int msg, IntPtr wp, IntPtr lp, ref bool handled) => {
61
+ if (msg == 0x0312) {
62
+ int id = wp.ToInt32();
63
+ if (id == idDismiss) { handled = true; win.Dispatcher.Invoke(dismiss); }
64
+ else if (id == idFocus) { handled = true; win.Dispatcher.Invoke(focus); }
65
+ }
66
+ return IntPtr.Zero;
67
+ });
68
+ }
69
+ }
70
+ "@ -ReferencedAssemblies "WindowsBase","PresentationCore","PresentationFramework","System.Xaml"
71
+
72
+ l '[host-compiled]'
73
+
74
+ function Build-Xaml($opacity, $dismissLabel, $continueLabel) {
75
+ return @"
76
+ <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
77
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
78
+ WindowStyle="None" AllowsTransparency="True" Background="Transparent"
79
+ Topmost="True" ShowActivated="False" ShowInTaskbar="False" ResizeMode="NoResize"
80
+ Opacity="$opacity" Width="380" Height="115">
81
+ <Window.Resources>
82
+ <DropShadowEffect x:Key="shadow" BlurRadius="20" ShadowDepth="2" Opacity="0.3"/>
83
+ </Window.Resources>
84
+ <Border CornerRadius="10" Background="#2B2B2B" BorderBrush="#444" BorderThickness="1"
85
+ Effect="{StaticResource shadow}">
86
+ <Grid Margin="12">
87
+ <Grid.RowDefinitions>
88
+ <RowDefinition Height="Auto"/>
89
+ <RowDefinition Height="*"/>
90
+ <RowDefinition Height="Auto"/>
91
+ </Grid.RowDefinitions>
92
+ <TextBlock Grid.Row="0" x:Name="Title" FontWeight="SemiBold" Foreground="#E0E0E0" FontSize="13"/>
93
+ <TextBlock Grid.Row="1" x:Name="Body" Foreground="#999" FontSize="12" Margin="0,4,0,6" TextWrapping="Wrap" VerticalAlignment="Top" TextTrimming="CharacterEllipsis"/>
94
+ <Grid Grid.Row="2">
95
+ <Grid.ColumnDefinitions>
96
+ <ColumnDefinition Width="*"/>
97
+ <ColumnDefinition Width="Auto"/>
98
+ </Grid.ColumnDefinitions>
99
+ <TextBlock x:Name="Timer" Foreground="#666" FontSize="11" VerticalAlignment="Bottom"/>
100
+ <StackPanel Grid.Column="1" Orientation="Horizontal">
101
+ <Button x:Name="MuteBtn" Width="32" Height="30" Margin="0,0,6,0" Cursor="Hand"
102
+ ToolTip="ๅ‹ฟๆ‰ฐ">
103
+ <Button.Template>
104
+ <ControlTemplate TargetType="Button">
105
+ <Border CornerRadius="4" Background="#3A3A3A">
106
+ <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center"
107
+ Text="๐Ÿ”•" FontSize="13" Foreground="#AAA"/>
108
+ </Border>
109
+ </ControlTemplate>
110
+ </Button.Template>
111
+ </Button>
112
+ <Button x:Name="DismissBtn" Content="$dismissLabel" Width="78" Height="30" Margin="0,0,10,0" Cursor="Hand">
113
+ <Button.Template>
114
+ <ControlTemplate TargetType="Button">
115
+ <Border CornerRadius="4" Background="#3A3A3A">
116
+ <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
117
+ TextElement.Foreground="#CCC" TextElement.FontSize="12"/>
118
+ </Border>
119
+ </ControlTemplate>
120
+ </Button.Template>
121
+ </Button>
122
+ <Button x:Name="FocusBtn" Content="$continueLabel" Width="78" Height="30" Cursor="Hand">
123
+ <Button.Template>
124
+ <ControlTemplate TargetType="Button">
125
+ <Border CornerRadius="4" Background="#0E639C">
126
+ <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
127
+ TextElement.Foreground="White" TextElement.FontSize="12"/>
128
+ </Border>
129
+ </ControlTemplate>
130
+ </Button.Template>
131
+ </Button>
132
+ </StackPanel>
133
+ </Grid>
134
+ </Grid>
135
+ </Border>
136
+ </Window>
137
+ "@
138
+ }
139
+
140
+ function Focus-PiTerminal($hwndNum, $winTitle) {
141
+ l '[focus-called]'
142
+ $h = [IntPtr]::new($hwndNum)
143
+ if ([PF]::IsIconic($h)) { [PF]::ShowWindow($h, 9) }
144
+ $r = [PF]::SetForegroundWindow($h)
145
+ l "[focus-cached-sfg] result=$r"
146
+ if ($r) { return }
147
+
148
+ l "[focus-fallback] searching for '$winTitle'"
149
+ [PF]::Search = $winTitle
150
+ $cb = [PF+EnumCb]{ param($h2,$l2) [PF]::Callback($h2,$l2) }
151
+ [PF]::EnumWindows($cb, [IntPtr]::Zero)
152
+ if ([PF]::Found -ne [IntPtr]::Zero) {
153
+ l "[focus-found] title='$([PF]::FoundTitle)' hwnd=$([PF]::Found)"
154
+ if ([PF]::IsIconic([PF]::Found)) { [PF]::ShowWindow([PF]::Found, 9) }
155
+ [PF]::SetForegroundWindow([PF]::Found) | Out-Null
156
+ }
157
+ }
158
+
159
+ $stackFile = "$env:TEMP\pi-notify-stack.txt"
160
+
161
+ function inc-stack {
162
+ $count = 0
163
+ if (Test-Path $stackFile) { try { $count = [int](Get-Content $stackFile -Raw).Trim() } catch {} }
164
+ $count++
165
+ $count | Out-File -Encoding ascii $stackFile
166
+ return $count
167
+ }
168
+
169
+ function dec-stack {
170
+ if (-not (Test-Path $stackFile)) { return }
171
+ try {
172
+ $c = [int](Get-Content $stackFile -Raw).Trim()
173
+ $c--
174
+ if ($c -le 0) { Remove-Item $stackFile -Force } else { $c | Out-File -Encoding ascii $stackFile }
175
+ } catch {}
176
+ }
177
+
178
+ function Show-Notify($data) {
179
+ $title = $data.title
180
+ $body = $data.body
181
+ $hwndNum = [long]$data.hwnd
182
+ $winTitle= $data.winTitle
183
+ $dismiss = $data.dismissLabel
184
+ $continue= $data.continueLabel
185
+ $timeout = [int]$data.timeoutSec
186
+ $opacity = $data.opacityVal
187
+ $elapsed = $data.elapsedLabel
188
+
189
+ $MOD_ALT = 0x0001; $VK_OEM_4 = 0xDB; $VK_OEM_6 = 0xDD
190
+ $HOTKEY_DISMISS = 1; $HOTKEY_FOCUS = 2
191
+
192
+ $xamlText = Build-Xaml $opacity $dismiss $continue
193
+ $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]$xamlText)
194
+ $win = [System.Windows.Markup.XamlReader]::Load($reader)
195
+ $reader.Close()
196
+
197
+ $win.FindName('Title').Text = $title
198
+ $win.FindName('Body').Text = $body
199
+ $win.FindName('Timer').Text = $elapsed
200
+ $win.FindName('DismissBtn').Add_Click({ $win.Close() })
201
+ $win.FindName('FocusBtn').Add_Click({ $win.Close(); Focus-PiTerminal $hwndNum $winTitle })
202
+
203
+ # ๅ‹ฟๆ‰ฐ่œๅ•
204
+ $popup = New-Object System.Windows.Controls.Primitives.Popup
205
+ $popup.PlacementTarget = $win.FindName('MuteBtn')
206
+ $popup.Placement = [System.Windows.Controls.Primitives.PlacementMode]::Bottom
207
+ $popup.StaysOpen = $false
208
+ $popup.AllowsTransparency = $true
209
+
210
+ $panel = New-Object System.Windows.Controls.StackPanel
211
+ $panel.Background = "#333"
212
+ $panel.MinWidth = 100
213
+
214
+ $opts = @(
215
+ @{Header=$data.mute3Label; Val=3},
216
+ @{Header=$data.mute30Label; Val=30},
217
+ @{Header=$data.mute60Label; Val=60},
218
+ @{Header=$data.muteOffLabel; Val=0}
219
+ )
220
+ foreach ($o in $opts) {
221
+ $btn = New-Object System.Windows.Controls.Button
222
+ $btn.Content = $o.Header
223
+ $btn.Background = "Transparent"
224
+ $btn.Foreground = "#CCC"
225
+ $btn.BorderThickness = 0
226
+ $btn.FontSize = 12
227
+ $btn.HorizontalContentAlignment = "Left"
228
+ $btn.Padding = "10,6"
229
+ $btn.Cursor = "Hand"
230
+ $btn.Tag = $o.Val
231
+ $btn.Add_Click({
232
+ $popup.IsOpen = $false
233
+ $win.Close()
234
+ Write-Output ("MUTE:" + $this.Tag)
235
+ }.GetNewClosure())
236
+ $btn.Add_MouseEnter({ $this.Background = "#444" })
237
+ $btn.Add_MouseLeave({ $this.Background = "Transparent" })
238
+ $panel.Children.Add($btn) | Out-Null
239
+ }
240
+ $popup.Child = $panel
241
+ $win.FindName('MuteBtn').Add_Click({ $popup.IsOpen = $true })
242
+
243
+ $cursor = [System.Windows.Forms.Cursor]::Position
244
+ $wa = [System.Windows.Forms.Screen]::FromPoint($cursor).WorkingArea
245
+ $count = inc-stack
246
+ $offset = ($count - 1) * 128
247
+ $win.Left = $wa.Right - $win.Width - 20
248
+ $win.Top = $wa.Bottom - $win.Height - 10 - $offset
249
+ l "[ps-stack] count=$count offset=$offset"
250
+
251
+ $timer = New-Object System.Windows.Threading.DispatcherTimer
252
+ $timer.Interval = [TimeSpan]::FromSeconds($timeout)
253
+ $timer.Add_Tick({ $win.Close(); $timer.Stop() })
254
+
255
+ $win.Add_Closed({
256
+ $timer.Stop()
257
+ try { [HotkeyHelper]::UnregisterHotKey((New-Object System.Windows.Interop.WindowInteropHelper($win)).Handle, $HOTKEY_DISMISS) } catch {}
258
+ try { [HotkeyHelper]::UnregisterHotKey((New-Object System.Windows.Interop.WindowInteropHelper($win)).Handle, $HOTKEY_FOCUS) } catch {}
259
+ dec-stack
260
+ $frame.Continue = $false
261
+ })
262
+
263
+ $timer.Start()
264
+ $win.Show()
265
+ l '[ps-shown]'
266
+
267
+ [HotkeyHelper]::Setup($win, { $win.Close() }, { $win.Close(); Focus-PiTerminal $hwndNum $winTitle }, $HOTKEY_DISMISS, $HOTKEY_FOCUS)
268
+ l '[ps-hotkeys-setup]'
269
+
270
+ $frame = [System.Windows.Threading.DispatcherFrame]::new($true)
271
+ [System.Windows.Threading.Dispatcher]::PushFrame($frame)
272
+ l '[ps-done]'
273
+ }
274
+
275
+ l '[host-ready]'
276
+ Write-Output "READY"
277
+
278
+ while (($line = [Console]::In.ReadLine()) -ne $null) {
279
+ try {
280
+ $data = $line | ConvertFrom-Json
281
+ Show-Notify $data
282
+ Write-Output "OK"
283
+ } catch {
284
+ l "[host-error] $_"
285
+ Write-Output "ERROR:$_"
286
+ }
287
+ }
288
+
289
+ l '[host-exit]'
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "pi-win-notify",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "WPF desktop notification for pi coding agent โ€” rich popup with mute, multi-monitor, i18n",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "wpf",
10
+ "notification",
11
+ "windows"
12
+ ],
13
+ "author": "ryanchan720",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/ryanchan720/pi-desktop-notify.git"
18
+ },
19
+ "pi": {
20
+ "extensions": [
21
+ "./extensions"
22
+ ]
23
+ },
24
+ "files": [
25
+ "extensions/desktop-notify.ts",
26
+ "extensions/host.ps1",
27
+ "README.md",
28
+ "package.json"
29
+ ],
30
+ "dependencies": {
31
+ "koffi": "^3.0.2"
32
+ }
33
+ }