@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 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 { loadConfig } from "./settings.js";
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.5.0",
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.5.0",
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 the project-wide cached model list from ~/.unipi/config/models-cache.json.
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
- constructor() {
32
- // Load all cached models from project-wide cache
33
- this.models = readModelCache();
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 === "\r") {
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
- if (data === "\x7f" || data === "\b") {
68
- // Backspace
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
- switch (data) {
84
- case "\x1b[A": // Up
85
- case "k":
86
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
87
- break;
88
- case "\x1b[B": // Down
89
- case "j":
90
- this.selectedIndex = Math.min(
91
- this.filteredModels.length - 1,
92
- this.selectedIndex + 1
93
- );
94
- break;
95
- case "/": // Start filter
96
- this.filterMode = true;
97
- this.filter = "";
98
- break;
99
- case "\r": // Enter — select and save
100
- this.selectModel();
101
- break;
102
- case "\x1b": // Escape — close
103
- this.onClose?.();
104
- break;
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", "No models found")}`,
272
+ ` ${this.fg("dim", emptyMsg)}`,
232
273
  innerWidth
233
274
  )
234
275
  );
@@ -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
- switch (data) {
55
- case "\x1b[A": // Up
56
- case "k":
57
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
58
- break;
59
- case "\x1b[B": // Down
60
- case "j":
61
- this.selectedIndex = Math.min(this.maxItems - 1, this.selectedIndex + 1);
62
- break;
63
- case " ": // Space - toggle
64
- this.toggleCurrent();
65
- break;
66
- case "\t": // Tab - switch section
67
- {
68
- const sections: Section[] = ["platforms", "events", "recap"];
69
- const idx = sections.indexOf(this.section);
70
- this.section = sections[(idx + 1) % sections.length];
71
- this.selectedIndex = 0;
72
- }
73
- break;
74
- case "m": // M - open model selector (only in recap section)
75
- if (this.section === "recap") {
76
- this.onOpenModelSelector?.();
77
- }
78
- break;
79
- case "\r": // Enter - save
80
- this.save();
81
- break;
82
- case "\x1b": // Escape - close
83
- this.onClose?.();
84
- break;
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