@pi-unipi/notify 0.1.5 → 0.1.6

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.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * @pi-unipi/notify — Recap Model Selector TUI
3
+ *
4
+ * Interactive overlay for selecting the recap summarization model.
5
+ * Uses the project-wide cached model list from ~/.unipi/config/models-cache.json.
6
+ */
7
+
8
+ import type { Component } from "@mariozechner/pi-tui";
9
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
10
+ import type { Theme } from "@mariozechner/pi-coding-agent";
11
+ import { readModelCache, type CachedModel } from "@pi-unipi/core";
12
+ import { loadConfig, saveConfig } from "../settings.js";
13
+
14
+ const DEFAULT_MODEL = "openrouter/openai/gpt-oss-20b";
15
+
16
+ /**
17
+ * Model selector overlay for recap model selection.
18
+ */
19
+ export class RecapModelSelectorOverlay implements Component {
20
+ private models: CachedModel[] = [];
21
+ private filteredModels: CachedModel[] = [];
22
+ private selectedIndex = 0;
23
+ private filter = "";
24
+ private filterMode = false;
25
+ private saved = false;
26
+ private error: string | null = null;
27
+ onClose?: () => void;
28
+ requestRender?: () => void;
29
+ private theme: Theme | null = null;
30
+
31
+ constructor() {
32
+ // Load all cached models from project-wide cache
33
+ this.models = readModelCache();
34
+ this.applyFilter();
35
+
36
+ // Pre-select current config model
37
+ const config = loadConfig();
38
+ const currentModel = config.recap.model;
39
+ const idx = this.filteredModels.findIndex(
40
+ (m) => `${m.provider}/${m.id}` === currentModel
41
+ );
42
+ if (idx >= 0) this.selectedIndex = idx;
43
+ }
44
+
45
+ setTheme(theme: Theme): void {
46
+ this.theme = theme;
47
+ }
48
+
49
+ invalidate(): void {}
50
+
51
+ handleInput(data: string): void {
52
+ // Filter mode: type to search
53
+ if (this.filterMode) {
54
+ if (data === "\r") {
55
+ // Enter — exit filter mode
56
+ this.filterMode = false;
57
+ return;
58
+ }
59
+ if (data === "\x1b") {
60
+ // Escape — clear filter and exit filter mode
61
+ this.filter = "";
62
+ this.filterMode = false;
63
+ this.applyFilter();
64
+ this.selectedIndex = 0;
65
+ return;
66
+ }
67
+ if (data === "\x7f" || data === "\b") {
68
+ // Backspace
69
+ this.filter = this.filter.slice(0, -1);
70
+ this.applyFilter();
71
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
72
+ return;
73
+ }
74
+ if (data.length === 1 && data >= " ") {
75
+ this.filter += data;
76
+ this.applyFilter();
77
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
78
+ return;
79
+ }
80
+ return;
81
+ }
82
+
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;
105
+ }
106
+ }
107
+
108
+ private applyFilter(): void {
109
+ const q = this.filter.toLowerCase();
110
+ if (!q) {
111
+ this.filteredModels = [...this.models];
112
+ } else {
113
+ this.filteredModels = this.models.filter(
114
+ (m) =>
115
+ m.id.toLowerCase().includes(q) ||
116
+ (m.name?.toLowerCase().includes(q) ?? false)
117
+ );
118
+ }
119
+ }
120
+
121
+ private selectModel(): void {
122
+ const model = this.filteredModels[this.selectedIndex];
123
+ if (!model) {
124
+ this.error = "No model selected";
125
+ return;
126
+ }
127
+
128
+ const modelRef = `${model.provider}/${model.id}`;
129
+ const config = loadConfig();
130
+ config.recap.model = modelRef;
131
+ saveConfig(config);
132
+ this.saved = true;
133
+ this.error = null;
134
+ setTimeout(() => this.onClose?.(), 500);
135
+ }
136
+
137
+ // ─── Theme helpers ───────────────────────────────────────────────────
138
+
139
+ private fg(color: string, text: string): string {
140
+ if (this.theme) return this.theme.fg(color as any, text);
141
+ const c: Record<string, string> = {
142
+ accent: "\x1b[36m",
143
+ success: "\x1b[32m",
144
+ warning: "\x1b[33m",
145
+ error: "\x1b[31m",
146
+ dim: "\x1b[2m",
147
+ borderMuted: "\x1b[90m",
148
+ };
149
+ return `${c[color] ?? ""}${text}\x1b[0m`;
150
+ }
151
+
152
+ private bold(text: string): string {
153
+ return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
154
+ }
155
+
156
+ private frameLine(content: string, innerWidth: number): string {
157
+ const truncated = truncateToWidth(content, innerWidth, "");
158
+ const padding = Math.max(0, innerWidth - visibleWidth(truncated));
159
+ return `${this.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.fg("borderMuted", "│")}`;
160
+ }
161
+
162
+ private ruleLine(innerWidth: number): string {
163
+ return this.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
164
+ }
165
+
166
+ private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
167
+ const left = edge === "top" ? "┌" : "└";
168
+ const right = edge === "top" ? "┐" : "┘";
169
+ return this.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
170
+ }
171
+
172
+ render(width: number): string[] {
173
+ const innerWidth = Math.max(40, width - 2);
174
+ const lines: string[] = [];
175
+
176
+ lines.push(this.borderLine(innerWidth, "top"));
177
+ lines.push(
178
+ this.frameLine(
179
+ this.fg("accent", this.bold("🤖 Recap Model Selector")),
180
+ innerWidth
181
+ )
182
+ );
183
+ lines.push(
184
+ this.frameLine(
185
+ this.fg("dim", "Select model for notification recaps"),
186
+ innerWidth
187
+ )
188
+ );
189
+ lines.push(this.ruleLine(innerWidth));
190
+
191
+ // Filter bar
192
+ if (this.filterMode) {
193
+ lines.push(
194
+ this.frameLine(
195
+ ` ${this.fg("accent", "Filter:")} ${this.filter}${this.fg("accent", "█")}`,
196
+ innerWidth
197
+ )
198
+ );
199
+ } else if (this.filter) {
200
+ lines.push(
201
+ this.frameLine(
202
+ ` ${this.fg("dim", "Filter:")} ${this.filter} ${this.fg("dim", "(press / to edit)")}`,
203
+ innerWidth
204
+ )
205
+ );
206
+ } else {
207
+ lines.push(
208
+ this.frameLine(
209
+ ` ${this.fg("dim", `/${this.models.length} models · press / to filter`)}`,
210
+ innerWidth
211
+ )
212
+ );
213
+ }
214
+ lines.push(this.ruleLine(innerWidth));
215
+
216
+ // Model list
217
+ const terminalRows = process.stdout.rows ?? 30;
218
+ const maxVisible = Math.max(5, terminalRows - 14);
219
+ const startIdx = Math.max(
220
+ 0,
221
+ this.selectedIndex - Math.floor(maxVisible / 2)
222
+ );
223
+ const endIdx = Math.min(
224
+ this.filteredModels.length,
225
+ startIdx + maxVisible
226
+ );
227
+
228
+ if (this.filteredModels.length === 0) {
229
+ lines.push(
230
+ this.frameLine(
231
+ ` ${this.fg("dim", "No models found")}`,
232
+ innerWidth
233
+ )
234
+ );
235
+ } else {
236
+ for (let i = startIdx; i < endIdx; i++) {
237
+ const m = this.filteredModels[i];
238
+ const isSelected = i === this.selectedIndex;
239
+ const marker = isSelected ? this.fg("accent", "▸") : " ";
240
+ const label = m.name || m.id;
241
+ const fullRef = `${m.provider}/${m.id}`;
242
+ const isDefault = fullRef === DEFAULT_MODEL;
243
+ const defaultTag = isDefault
244
+ ? ` ${this.fg("warning", "(default)")}`
245
+ : "";
246
+
247
+ const providerTag = this.fg("dim", `[${m.provider}]`);
248
+ const display = isSelected
249
+ ? `${providerTag} ${this.bold(label)}${defaultTag}`
250
+ : `${providerTag} ${this.fg("dim", label)}${defaultTag}`;
251
+
252
+ lines.push(this.frameLine(` ${marker} ${display}`, innerWidth));
253
+ }
254
+ }
255
+
256
+ // Scroll indicator
257
+ if (this.filteredModels.length > maxVisible) {
258
+ const pct = Math.round(
259
+ ((this.selectedIndex + 1) / this.filteredModels.length) * 100
260
+ );
261
+ lines.push(
262
+ this.frameLine(
263
+ this.fg("dim", ` ${pct}% (${this.selectedIndex + 1}/${this.filteredModels.length})`),
264
+ innerWidth
265
+ )
266
+ );
267
+ }
268
+
269
+ // Status messages
270
+ if (this.error) {
271
+ lines.push(this.ruleLine(innerWidth));
272
+ lines.push(
273
+ this.frameLine(` ${this.fg("error", `⚠ ${this.error}`)}`, innerWidth)
274
+ );
275
+ }
276
+ if (this.saved) {
277
+ lines.push(this.ruleLine(innerWidth));
278
+ lines.push(
279
+ this.frameLine(
280
+ ` ${this.fg("success", "✓ Model saved")}`,
281
+ innerWidth
282
+ )
283
+ );
284
+ }
285
+
286
+ // Footer
287
+ lines.push(this.ruleLine(innerWidth));
288
+ lines.push(
289
+ this.frameLine(
290
+ this.fg(
291
+ "dim",
292
+ "↑↓ navigate · / filter · Enter select · Esc cancel"
293
+ ),
294
+ innerWidth
295
+ )
296
+ );
297
+ lines.push(this.borderLine(innerWidth, "bottom"));
298
+
299
+ return lines;
300
+ }
301
+ }
@@ -16,7 +16,7 @@ import {
16
16
  import type { NotifyConfig } from "../types.js";
17
17
 
18
18
  /** Section types */
19
- type Section = "platforms" | "events";
19
+ type Section = "platforms" | "events" | "recap";
20
20
 
21
21
  /**
22
22
  * Settings overlay component.
@@ -29,6 +29,8 @@ export class NotifySettingsOverlay implements Component {
29
29
  private saved = false;
30
30
  onClose?: () => void;
31
31
  requestRender?: () => void;
32
+ /** Called when user presses M in recap section to open model selector */
33
+ onOpenModelSelector?: () => void;
32
34
  private theme: Theme | null = null;
33
35
 
34
36
  constructor() {
@@ -55,8 +57,17 @@ export class NotifySettingsOverlay implements Component {
55
57
  this.toggleCurrent();
56
58
  break;
57
59
  case "\t": // Tab - switch section
58
- this.section = this.section === "platforms" ? "events" : "platforms";
59
- this.selectedIndex = 0;
60
+ {
61
+ const sections: Section[] = ["platforms", "events", "recap"];
62
+ const idx = sections.indexOf(this.section);
63
+ this.section = sections[(idx + 1) % sections.length];
64
+ this.selectedIndex = 0;
65
+ }
66
+ break;
67
+ case "m": // M - open model selector (only in recap section)
68
+ if (this.section === "recap") {
69
+ this.onOpenModelSelector?.();
70
+ }
60
71
  break;
61
72
  case "\r": // Enter - save
62
73
  this.save();
@@ -68,21 +79,25 @@ export class NotifySettingsOverlay implements Component {
68
79
  }
69
80
 
70
81
  private get maxItems(): number {
71
- if (this.section === "platforms") return 3; // native, gotify, telegram
82
+ if (this.section === "platforms") return 4; // native, gotify, telegram, ntfy
83
+ if (this.section === "recap") return 1; // toggle
72
84
  return Object.keys(this.config.events).length;
73
85
  }
74
86
 
75
87
  private toggleCurrent(): void {
76
88
  if (this.section === "platforms") {
77
- const platforms: Array<"native" | "gotify" | "telegram"> = [
89
+ const platforms: Array<"native" | "gotify" | "telegram" | "ntfy"> = [
78
90
  "native",
79
91
  "gotify",
80
92
  "telegram",
93
+ "ntfy",
81
94
  ];
82
95
  const key = platforms[this.selectedIndex];
83
96
  if (key) {
84
97
  this.config[key].enabled = !this.config[key].enabled;
85
98
  }
99
+ } else if (this.section === "recap") {
100
+ this.config.recap.enabled = !this.config.recap.enabled;
86
101
  } else {
87
102
  const eventKeys = Object.keys(this.config.events);
88
103
  const key = eventKeys[this.selectedIndex];
@@ -158,11 +173,17 @@ export class NotifySettingsOverlay implements Component {
158
173
  this.section === "events"
159
174
  ? this.fg("accent", this.bold("[Events]"))
160
175
  : this.fg("dim", "Events");
161
- lines.push(this.frameLine(` ${platformTab} ${eventsTab}`, innerWidth));
176
+ const recapTab =
177
+ this.section === "recap"
178
+ ? this.fg("accent", this.bold("[Recap]"))
179
+ : this.fg("dim", "Recap");
180
+ lines.push(this.frameLine(` ${platformTab} ${eventsTab} ${recapTab}`, innerWidth));
162
181
  lines.push(this.ruleLine(innerWidth));
163
182
 
164
183
  if (this.section === "platforms") {
165
184
  this.renderPlatforms(lines, innerWidth);
185
+ } else if (this.section === "recap") {
186
+ this.renderRecap(lines, innerWidth);
166
187
  } else {
167
188
  this.renderEvents(lines, innerWidth);
168
189
  }
@@ -179,7 +200,10 @@ export class NotifySettingsOverlay implements Component {
179
200
 
180
201
  // Footer
181
202
  lines.push(this.ruleLine(innerWidth));
182
- lines.push(this.frameLine(this.fg("dim", "↑↓ navigate · Space toggle · Tab switch · Enter save · Esc cancel"), innerWidth));
203
+ const footerHint = this.section === "recap"
204
+ ? "↑↓ navigate · Space toggle · M change model · Tab switch · Enter save · Esc cancel"
205
+ : "↑↓ navigate · Space toggle · Tab switch · Enter save · Esc cancel";
206
+ lines.push(this.frameLine(this.fg("dim", footerHint), innerWidth));
183
207
  lines.push(this.borderLine(innerWidth, "bottom"));
184
208
 
185
209
  return lines;
@@ -187,7 +211,7 @@ export class NotifySettingsOverlay implements Component {
187
211
 
188
212
  private renderPlatforms(lines: string[], innerWidth: number): void {
189
213
  const platforms: Array<{
190
- key: "native" | "gotify" | "telegram";
214
+ key: "native" | "gotify" | "telegram" | "ntfy";
191
215
  label: string;
192
216
  detail: string;
193
217
  }> = [
@@ -210,6 +234,13 @@ export class NotifySettingsOverlay implements Component {
210
234
  ? "Bot configured"
211
235
  : "Bot API notifications",
212
236
  },
237
+ {
238
+ key: "ntfy",
239
+ label: "ntfy",
240
+ detail: this.config.ntfy.serverUrl
241
+ ? `Server: ${this.config.ntfy.serverUrl}`
242
+ : "Self-hosted push service",
243
+ },
213
244
  ];
214
245
 
215
246
  for (let i = 0; i < platforms.length; i++) {
@@ -248,4 +279,33 @@ export class NotifySettingsOverlay implements Component {
248
279
  );
249
280
  }
250
281
  }
282
+
283
+ private renderRecap(lines: string[], innerWidth: number): void {
284
+ // Toggle
285
+ const isSelected = this.selectedIndex === 0;
286
+ const toggleOn = this.fg("success", "●");
287
+ const toggleOff = this.fg("dim", "○");
288
+ const toggle = this.config.recap.enabled ? toggleOn : toggleOff;
289
+ const label = isSelected
290
+ ? this.bold("Enable Recap")
291
+ : this.fg("dim", "Enable Recap");
292
+
293
+ lines.push(
294
+ this.frameLine(
295
+ `${isSelected ? this.fg("accent", "▸") : " "} ${toggle} ${label}`,
296
+ innerWidth
297
+ )
298
+ );
299
+
300
+ // Current model display
301
+ const modelRef = this.config.recap.model;
302
+ const modelLabel = this.fg("dim", ` Model: ${modelRef}`);
303
+ lines.push(this.frameLine(modelLabel, innerWidth));
304
+ lines.push(
305
+ this.frameLine(
306
+ this.fg("dim", " Press M to change model"),
307
+ innerWidth
308
+ )
309
+ );
310
+ }
251
311
  }
package/types.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  /** Supported notification platforms */
6
- export type NotifyPlatform = "native" | "gotify" | "telegram";
6
+ export type NotifyPlatform = "native" | "gotify" | "telegram" | "ntfy";
7
7
 
8
8
  /** Per-event notification configuration */
9
9
  export interface EventNotifyConfig {
@@ -43,6 +43,28 @@ export interface TelegramConfig {
43
43
  chatId?: string;
44
44
  }
45
45
 
46
+ /** ntfy notification platform config */
47
+ export interface NtfyConfig {
48
+ /** Whether ntfy is enabled */
49
+ enabled: boolean;
50
+ /** ntfy server URL (default: https://ntfy.sh) */
51
+ serverUrl?: string;
52
+ /** ntfy topic to publish to */
53
+ topic?: string;
54
+ /** Optional access token for authenticated ntfy servers */
55
+ token?: string;
56
+ /** Priority level (1-5, default: 3) */
57
+ priority: number;
58
+ }
59
+
60
+ /** Recap notification config */
61
+ export interface RecapConfig {
62
+ /** Whether recap summarization is enabled */
63
+ enabled: boolean;
64
+ /** Model to use for recap (e.g. "openrouter/openai/gpt-oss-20b") */
65
+ model: string;
66
+ }
67
+
46
68
  /** Full notification configuration */
47
69
  export interface NotifyConfig {
48
70
  /** Global default platforms for all events */
@@ -55,6 +77,10 @@ export interface NotifyConfig {
55
77
  gotify: GotifyConfig;
56
78
  /** Telegram settings */
57
79
  telegram: TelegramConfig;
80
+ /** ntfy settings */
81
+ ntfy: NtfyConfig;
82
+ /** Recap summarization settings */
83
+ recap: RecapConfig;
58
84
  }
59
85
 
60
86
  /** Parameters for the notify_user agent tool */