@pi-unipi/notify 2.5.0 → 2.6.1
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 +1 -0
- package/commands.ts +61 -3
- package/package.json +2 -2
- package/tui/recap-model-selector.ts +72 -31
- package/tui/settings-overlay.ts +45 -32
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ Configure once, get alerts everywhere. Per-event platform routing lets you send
|
|
|
13
13
|
| `/unipi:notify-set-tg` | Interactive Telegram bot setup |
|
|
14
14
|
| `/unipi:notify-set-ntfy` | Configure ntfy topic and server |
|
|
15
15
|
| `/unipi:notify-recap-model` | Set model for notification recaps |
|
|
16
|
+
| `/unipi:notify-event` | Toggle a single event without the TUI (`<event> <on\|off>`) — reports the new value; run `/reload` to re-register listeners |
|
|
16
17
|
| `/unipi:notify-test` | Send test notification to all enabled platforms |
|
|
17
18
|
|
|
18
19
|
## Special Triggers
|
package/commands.ts
CHANGED
|
@@ -12,13 +12,32 @@ import { GotifySetupOverlay } from "./tui/gotify-setup.js";
|
|
|
12
12
|
import { TelegramSetupOverlay } from "./tui/telegram-setup.js";
|
|
13
13
|
import { NtfySetupOverlay } from "./tui/ntfy-setup.js";
|
|
14
14
|
import { RecapModelSelectorOverlay } from "./tui/recap-model-selector.js";
|
|
15
|
-
import {
|
|
15
|
+
import type { CachedModel } from "@pi-unipi/core";
|
|
16
|
+
import { loadConfig, saveConfig } from "./settings.js";
|
|
16
17
|
import { loadNtfyConfig } from "./ntfy-config.js";
|
|
17
18
|
import { sendNativeNotification, SuppressedError } from "./platforms/native.js";
|
|
18
19
|
import { sendGotifyNotification } from "./platforms/gotify.js";
|
|
19
20
|
import { sendTelegramNotification } from "./platforms/telegram.js";
|
|
20
21
|
import { sendNtfyNotification } from "./platforms/ntfy.js";
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Collect models for the recap selector from Pi's live model registry.
|
|
25
|
+
* Returns undefined when the registry is unavailable so the overlay can fall
|
|
26
|
+
* back to the project-wide model cache (issue #27: selector was empty because
|
|
27
|
+
* it only read ~/.unipi/config/models-cache.json, which may not exist even
|
|
28
|
+
* when models are configured in ~/.pi/agent/models.json).
|
|
29
|
+
*/
|
|
30
|
+
function registryModels(ctx: ExtensionContext): CachedModel[] | undefined {
|
|
31
|
+
const registry = ctx.modelRegistry;
|
|
32
|
+
if (!registry) return undefined;
|
|
33
|
+
try {
|
|
34
|
+
const models = registry.getAvailable?.() ?? registry.getAll() ?? [];
|
|
35
|
+
return models.map((m) => ({ provider: m.provider, id: m.id, name: m.name }));
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
/**
|
|
23
42
|
* Register notify commands.
|
|
24
43
|
*/
|
|
@@ -44,7 +63,7 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
|
|
|
44
63
|
// Open model selector as nested overlay
|
|
45
64
|
ctx.ui.custom(
|
|
46
65
|
(innerTui: any, innerTheme: any, _innerKb: any, innerDone: any) => {
|
|
47
|
-
const selector = new RecapModelSelectorOverlay();
|
|
66
|
+
const selector = new RecapModelSelectorOverlay(registryModels(ctx));
|
|
48
67
|
selector.setTheme(innerTheme);
|
|
49
68
|
selector.onClose = () => innerDone(undefined);
|
|
50
69
|
selector.requestRender = () => innerTui.requestRender();
|
|
@@ -102,9 +121,10 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
|
|
|
102
121
|
return;
|
|
103
122
|
}
|
|
104
123
|
|
|
124
|
+
const models = registryModels(ctx);
|
|
105
125
|
ctx.ui.custom(
|
|
106
126
|
(tui, theme, _keybindings, done) => {
|
|
107
|
-
const overlay = new RecapModelSelectorOverlay();
|
|
127
|
+
const overlay = new RecapModelSelectorOverlay(models);
|
|
108
128
|
overlay.setTheme(theme);
|
|
109
129
|
overlay.onClose = () => done(undefined);
|
|
110
130
|
overlay.requestRender = () => tui.requestRender();
|
|
@@ -251,6 +271,44 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
|
|
|
251
271
|
}
|
|
252
272
|
);
|
|
253
273
|
|
|
274
|
+
// /unipi:notify-event <event> <on|off> — Non-TUI event toggle (issue #27 escape
|
|
275
|
+
// hatch for terminals where overlay input is unusable)
|
|
276
|
+
pi.registerCommand(
|
|
277
|
+
`${UNIPI_PREFIX}${NOTIFY_COMMANDS.NOTIFY_EVENT}`,
|
|
278
|
+
{
|
|
279
|
+
description: "Toggle a notify event without the TUI: <event> <on|off>",
|
|
280
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
281
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
282
|
+
const [event, value] = parts;
|
|
283
|
+
|
|
284
|
+
if (parts.length !== 2 || (value !== "on" && value !== "off")) {
|
|
285
|
+
ctx.ui.notify(
|
|
286
|
+
"Usage: /unipi:notify-event <event> <on|off>",
|
|
287
|
+
"warning"
|
|
288
|
+
);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const config = loadConfig();
|
|
293
|
+
if (!(event in config.events)) {
|
|
294
|
+
const known = Object.keys(config.events).join(", ");
|
|
295
|
+
ctx.ui.notify(
|
|
296
|
+
`Unknown event "${event}". Known events: ${known}`,
|
|
297
|
+
"error"
|
|
298
|
+
);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
config.events[event].enabled = value === "on";
|
|
303
|
+
saveConfig(config);
|
|
304
|
+
ctx.ui.notify(
|
|
305
|
+
`notify: ${event} is now ${value}. Run /reload to re-register listeners.`,
|
|
306
|
+
"info"
|
|
307
|
+
);
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
);
|
|
311
|
+
|
|
254
312
|
// /unipi:notify-test — Send test notification to all enabled platforms
|
|
255
313
|
pi.registerCommand(
|
|
256
314
|
`${UNIPI_PREFIX}${NOTIFY_COMMANDS.TEST}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/notify",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@pi-unipi/core": "2.
|
|
37
|
+
"@pi-unipi/core": "2.6.1",
|
|
38
38
|
"node-notifier": "^10.0.1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* @pi-unipi/notify — Recap Model Selector TUI
|
|
3
3
|
*
|
|
4
4
|
* Interactive overlay for selecting the recap summarization model.
|
|
5
|
-
* Uses
|
|
5
|
+
* Uses models injected from Pi's live model registry (preferred), falling back
|
|
6
|
+
* to the project-wide cached model list from ~/.unipi/config/models-cache.json.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { Component } from "@earendil-works/pi-tui";
|
|
@@ -28,9 +29,16 @@ export class RecapModelSelectorOverlay implements Component {
|
|
|
28
29
|
requestRender?: () => void;
|
|
29
30
|
private theme: Theme | null = null;
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* @param models Optional model list, preferably collected from Pi's live
|
|
34
|
+
* model registry by the command handler. Falls back to the project-wide
|
|
35
|
+
* cache file (`~/.unipi/config/models-cache.json`), which may not exist
|
|
36
|
+
* (issue #27: selector showed "No models found" while Pi itself had
|
|
37
|
+
* models configured in `~/.pi/agent/models.json`).
|
|
38
|
+
*/
|
|
39
|
+
constructor(models?: CachedModel[]) {
|
|
40
|
+
// Prefer models injected from Pi's live model registry; fall back to cache.
|
|
41
|
+
this.models = models ?? readModelCache();
|
|
34
42
|
this.applyFilter();
|
|
35
43
|
|
|
36
44
|
// Pre-select current config model
|
|
@@ -49,23 +57,42 @@ export class RecapModelSelectorOverlay implements Component {
|
|
|
49
57
|
invalidate(): void {}
|
|
50
58
|
|
|
51
59
|
handleInput(data: string): void {
|
|
60
|
+
// Ctrl+C must always close, even mid-filter — without this the overlay
|
|
61
|
+
// could trap the user on terminals with unexpected key encodings.
|
|
62
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
63
|
+
this.onClose?.();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
// Filter mode: type to search
|
|
53
68
|
if (this.filterMode) {
|
|
54
|
-
if (data
|
|
69
|
+
if (matchesKey(data, "enter")) {
|
|
55
70
|
// Enter — exit filter mode
|
|
56
71
|
this.filterMode = false;
|
|
57
72
|
return;
|
|
58
73
|
}
|
|
59
74
|
if (matchesKey(data, "escape")) {
|
|
60
|
-
// Escape — clear filter and exit filter mode
|
|
75
|
+
// Escape — clear filter and exit filter mode (does NOT close overlay)
|
|
61
76
|
this.filter = "";
|
|
62
77
|
this.filterMode = false;
|
|
63
78
|
this.applyFilter();
|
|
64
79
|
this.selectedIndex = 0;
|
|
65
80
|
return;
|
|
66
81
|
}
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
// Let the list be navigated without leaving filter mode.
|
|
83
|
+
if (matchesKey(data, "up")) {
|
|
84
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (matchesKey(data, "down")) {
|
|
88
|
+
this.selectedIndex = Math.min(
|
|
89
|
+
Math.max(0, this.filteredModels.length - 1),
|
|
90
|
+
this.selectedIndex + 1
|
|
91
|
+
);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (matchesKey(data, "backspace") || data === "\x7f" || data === "\b") {
|
|
95
|
+
// Backspace (legacy or kitty "\x1b[127u")
|
|
69
96
|
this.filter = this.filter.slice(0, -1);
|
|
70
97
|
this.applyFilter();
|
|
71
98
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
|
|
@@ -80,28 +107,36 @@ export class RecapModelSelectorOverlay implements Component {
|
|
|
80
107
|
return;
|
|
81
108
|
}
|
|
82
109
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
// Keys are matched via matchesKey, never raw byte comparison: under the
|
|
111
|
+
// kitty keyboard protocol / enhanced encodings (Ghostty, Herdr) Escape
|
|
112
|
+
// arrives as "\x1b[27u" and arrows as "\x1b[57419u"/"\x1b[57420u", so
|
|
113
|
+
// exact legacy comparisons silently fail there (issue #27).
|
|
114
|
+
if (matchesKey(data, "up") || data === "k") {
|
|
115
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (matchesKey(data, "down") || data === "j") {
|
|
119
|
+
this.selectedIndex = Math.min(
|
|
120
|
+
Math.max(0, this.filteredModels.length - 1),
|
|
121
|
+
this.selectedIndex + 1
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (data === "/") {
|
|
126
|
+
// Start filter
|
|
127
|
+
this.filterMode = true;
|
|
128
|
+
this.filter = "";
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (matchesKey(data, "enter")) {
|
|
132
|
+
// Enter — select and save
|
|
133
|
+
this.selectModel();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (matchesKey(data, "escape")) {
|
|
137
|
+
// Escape — close
|
|
138
|
+
this.onClose?.();
|
|
139
|
+
return;
|
|
105
140
|
}
|
|
106
141
|
}
|
|
107
142
|
|
|
@@ -226,9 +261,15 @@ export class RecapModelSelectorOverlay implements Component {
|
|
|
226
261
|
);
|
|
227
262
|
|
|
228
263
|
if (this.filteredModels.length === 0) {
|
|
264
|
+
const emptyMsg =
|
|
265
|
+
this.filter.length > 0
|
|
266
|
+
? `No models match "${this.filter}"`
|
|
267
|
+
: this.models.length === 0
|
|
268
|
+
? "No models — check ~/.pi/agent/models.json or API keys, then reopen"
|
|
269
|
+
: "No models found";
|
|
229
270
|
lines.push(
|
|
230
271
|
this.frameLine(
|
|
231
|
-
` ${this.fg("dim",
|
|
272
|
+
` ${this.fg("dim", emptyMsg)}`,
|
|
232
273
|
innerWidth
|
|
233
274
|
)
|
|
234
275
|
);
|
package/tui/settings-overlay.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Component } from "@earendil-works/pi-tui";
|
|
9
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
10
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import {
|
|
12
12
|
loadConfig,
|
|
@@ -51,37 +51,50 @@ export class NotifySettingsOverlay implements Component {
|
|
|
51
51
|
invalidate(): void {}
|
|
52
52
|
|
|
53
53
|
handleInput(data: string): void {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
54
|
+
// Ctrl+C always closes — escape hatch for terminals with key encodings
|
|
55
|
+
// this overlay does not understand (issue #27).
|
|
56
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
57
|
+
this.onClose?.();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Navigation keys are matched via matchesKey, never raw byte comparison:
|
|
61
|
+
// under the kitty keyboard protocol / enhanced encodings (Ghostty, Herdr)
|
|
62
|
+
// Escape arrives as "\x1b[27u" (or "\x1b[27;1;27~" with modifyOtherKeys)
|
|
63
|
+
// and arrows as "\x1b[57419u"/"\x1b[57420u" — exact legacy comparisons
|
|
64
|
+
// like data === "\x1b[A" silently fail there.
|
|
65
|
+
if (matchesKey(data, "up") || data === "k") {
|
|
66
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (matchesKey(data, "down") || data === "j") {
|
|
70
|
+
this.selectedIndex = Math.min(this.maxItems - 1, this.selectedIndex + 1);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (matchesKey(data, "space")) {
|
|
74
|
+
this.toggleCurrent();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (matchesKey(data, "tab")) {
|
|
78
|
+
const sections: Section[] = ["platforms", "events", "recap"];
|
|
79
|
+
const idx = sections.indexOf(this.section);
|
|
80
|
+
this.section = sections[(idx + 1) % sections.length];
|
|
81
|
+
this.selectedIndex = 0;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (data === "m" || data === "M") {
|
|
85
|
+
// Open model selector (only in recap section)
|
|
86
|
+
if (this.section === "recap") {
|
|
87
|
+
this.onOpenModelSelector?.();
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (matchesKey(data, "enter")) {
|
|
92
|
+
this.save();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (matchesKey(data, "escape")) {
|
|
96
|
+
this.onClose?.();
|
|
97
|
+
return;
|
|
85
98
|
}
|
|
86
99
|
}
|
|
87
100
|
|