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 +58 -0
- package/extensions/desktop-notify.ts +562 -0
- package/extensions/host.ps1 +289 -0
- package/package.json +33 -0
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
|
+
}
|