@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,599 @@
1
+ /**
2
+ * @pi-unipi/notify — ntfy Setup TUI Component
3
+ *
4
+ * Interactive overlay for setting up ntfy push notifications.
5
+ * Guides user through server URL, topic, optional token, and priority.
6
+ * Tests connection before saving.
7
+ */
8
+
9
+ import type { Component } from "@mariozechner/pi-tui";
10
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+ import type { Theme } from "@mariozechner/pi-coding-agent";
12
+ import { sendNtfyNotification } from "../platforms/ntfy.js";
13
+ import { updateConfig, loadConfig } from "../settings.js";
14
+
15
+ type SetupPhase =
16
+ | "instructions"
17
+ | "server-url"
18
+ | "topic"
19
+ | "token"
20
+ | "priority"
21
+ | "testing"
22
+ | "success"
23
+ | "error"
24
+ | "test-failed";
25
+
26
+ /**
27
+ * ntfy setup overlay component.
28
+ */
29
+ export class NtfySetupOverlay implements Component {
30
+ private phase: SetupPhase = "instructions";
31
+ private serverUrl = "";
32
+ private topic = "";
33
+ private token = "";
34
+ private priority = "3";
35
+ private error: string | null = null;
36
+ private testError: string | null = null;
37
+ private isInPaste = false;
38
+ private pasteBuffer = "";
39
+ onClose?: () => void;
40
+ requestRender?: () => void;
41
+ private theme: Theme | null = null;
42
+
43
+ constructor() {
44
+ // Pre-fill from existing config if available
45
+ const config = loadConfig();
46
+ if (config.ntfy.serverUrl) this.serverUrl = config.ntfy.serverUrl;
47
+ if (config.ntfy.topic) this.topic = config.ntfy.topic;
48
+ if (config.ntfy.token) this.token = config.ntfy.token;
49
+ if (config.ntfy.priority) this.priority = String(config.ntfy.priority);
50
+ }
51
+
52
+ setTheme(theme: Theme): void {
53
+ this.theme = theme;
54
+ }
55
+
56
+ invalidate(): void {}
57
+
58
+ handleInput(data: string): void {
59
+ switch (this.phase) {
60
+ case "instructions":
61
+ if (data === "\r" || data === " ") {
62
+ this.phase = this.serverUrl ? "topic" : "server-url";
63
+ } else if (data === "\x1b") {
64
+ this.onClose?.();
65
+ }
66
+ break;
67
+
68
+ case "server-url":
69
+ this.handleTextInput(data, "server-url", () => {
70
+ if (!this.serverUrl) {
71
+ this.serverUrl = "https://ntfy.sh";
72
+ }
73
+ this.phase = "topic";
74
+ });
75
+ break;
76
+
77
+ case "topic":
78
+ this.handleTextInput(data, "topic", () => {
79
+ this.phase = "token";
80
+ });
81
+ break;
82
+
83
+ case "token":
84
+ this.handleTextInput(data, "token", () => {
85
+ this.phase = "priority";
86
+ });
87
+ break;
88
+
89
+ case "priority":
90
+ if (data === "\r" && this.isValidPriority()) {
91
+ this.testConnection();
92
+ } else if (data === "\x1b") {
93
+ this.onClose?.();
94
+ } else if (data === "\x7f" || data === "\b") {
95
+ this.priority = this.priority.slice(0, -1);
96
+ } else {
97
+ const ch = data.replace(/[^\d]/g, "");
98
+ if (ch && this.priority.length < 1) {
99
+ this.priority += ch;
100
+ }
101
+ }
102
+ break;
103
+
104
+ case "testing":
105
+ if (data === "\x1b") {
106
+ this.onClose?.();
107
+ }
108
+ break;
109
+
110
+ case "success":
111
+ case "error":
112
+ case "test-failed":
113
+ if (data === "\r" || data === " " || data === "\x1b") {
114
+ this.onClose?.();
115
+ }
116
+ break;
117
+ }
118
+ }
119
+
120
+ private handleTextInput(
121
+ data: string,
122
+ target: "server-url" | "topic" | "token",
123
+ onEnter: () => void
124
+ ): void {
125
+ // Handle bracketed paste mode
126
+ if (this.isInPaste) {
127
+ this.pasteBuffer += data;
128
+ const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
129
+ if (endIndex !== -1) {
130
+ const pasteContent = this.pasteBuffer.substring(0, endIndex).trim();
131
+ if (target === "server-url") {
132
+ this.serverUrl = pasteContent;
133
+ } else if (target === "topic") {
134
+ this.topic = pasteContent;
135
+ } else {
136
+ this.token = pasteContent;
137
+ }
138
+ this.isInPaste = false;
139
+ this.pasteBuffer = "";
140
+ }
141
+ return;
142
+ }
143
+ // Detect start of bracketed paste
144
+ if (data.includes("\x1b[200~")) {
145
+ this.isInPaste = true;
146
+ this.pasteBuffer = data.replace("\x1b[200~", "");
147
+ return;
148
+ }
149
+ if (data === "\r") {
150
+ onEnter();
151
+ } else if (data === "\x1b") {
152
+ // Escape during token phase — skip token (optional field)
153
+ if (target === "token") {
154
+ this.phase = "priority";
155
+ } else {
156
+ this.onClose?.();
157
+ }
158
+ } else if (data === "\x7f" || data === "\b") {
159
+ if (target === "server-url") {
160
+ this.serverUrl = this.serverUrl.slice(0, -1);
161
+ } else if (target === "topic") {
162
+ this.topic = this.topic.slice(0, -1);
163
+ } else {
164
+ this.token = this.token.slice(0, -1);
165
+ }
166
+ } else {
167
+ // Ignore escape sequences
168
+ if (data.startsWith("\x1b[")) return;
169
+ if (target === "server-url") {
170
+ this.serverUrl += data;
171
+ } else if (target === "topic") {
172
+ this.topic += data;
173
+ } else {
174
+ this.token += data;
175
+ }
176
+ }
177
+ }
178
+
179
+ private isValidPriority(): boolean {
180
+ const num = parseInt(this.priority, 10);
181
+ return !isNaN(num) && num >= 1 && num <= 5;
182
+ }
183
+
184
+ private async testConnection(): Promise<void> {
185
+ this.phase = "testing";
186
+ this.requestRender?.();
187
+
188
+ try {
189
+ await sendNtfyNotification(
190
+ this.serverUrl.replace(/\/$/, ""),
191
+ this.topic,
192
+ "Pi — Setup Test",
193
+ `ntfy configured successfully at ${new Date().toLocaleTimeString()}`,
194
+ parseInt(this.priority, 10) || 3,
195
+ this.token || undefined
196
+ );
197
+ this.saveConfig();
198
+ this.phase = "success";
199
+ this.requestRender?.();
200
+ setTimeout(() => this.onClose?.(), 1500);
201
+ } catch (err) {
202
+ this.testError = err instanceof Error ? err.message : String(err);
203
+ this.phase = "test-failed";
204
+ this.requestRender?.();
205
+ }
206
+ }
207
+
208
+ private saveConfig(): void {
209
+ updateConfig({
210
+ ntfy: {
211
+ enabled: true,
212
+ serverUrl: this.serverUrl.replace(/\/$/, ""),
213
+ topic: this.topic,
214
+ token: this.token || undefined,
215
+ priority: parseInt(this.priority, 10) || 3,
216
+ },
217
+ });
218
+ }
219
+
220
+ // ─── Theme helpers ───────────────────────────────────────────────────
221
+
222
+ private fg(color: string, text: string): string {
223
+ if (this.theme) return this.theme.fg(color as any, text);
224
+ const c: Record<string, string> = {
225
+ accent: "\x1b[36m",
226
+ success: "\x1b[32m",
227
+ warning: "\x1b[33m",
228
+ error: "\x1b[31m",
229
+ dim: "\x1b[2m",
230
+ borderMuted: "\x1b[90m",
231
+ };
232
+ return `${c[color] ?? ""}${text}\x1b[0m`;
233
+ }
234
+
235
+ private bold(text: string): string {
236
+ return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
237
+ }
238
+
239
+ private frameLine(content: string, innerWidth: number): string {
240
+ const truncated = truncateToWidth(content, innerWidth, "");
241
+ const padding = Math.max(0, innerWidth - visibleWidth(truncated));
242
+ return `${this.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.fg("borderMuted", "│")}`;
243
+ }
244
+
245
+ private ruleLine(innerWidth: number): string {
246
+ return this.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
247
+ }
248
+
249
+ private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
250
+ const left = edge === "top" ? "┌" : "└";
251
+ const right = edge === "top" ? "┐" : "┘";
252
+ return this.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
253
+ }
254
+
255
+ private maskToken(token: string): string {
256
+ if (token.length <= 8) return token;
257
+ return token.slice(0, 4) + "•".repeat(token.length - 8) + token.slice(-4);
258
+ }
259
+
260
+ render(width: number): string[] {
261
+ const innerWidth = Math.max(22, width - 2);
262
+ const lines: string[] = [];
263
+
264
+ lines.push(this.borderLine(innerWidth, "top"));
265
+ lines.push(
266
+ this.frameLine(
267
+ this.fg("accent", this.bold("📢 ntfy Setup")),
268
+ innerWidth
269
+ )
270
+ );
271
+ lines.push(this.ruleLine(innerWidth));
272
+
273
+ switch (this.phase) {
274
+ case "instructions":
275
+ lines.push(
276
+ this.frameLine(
277
+ this.fg("dim", "Set up ntfy push notifications:"),
278
+ innerWidth
279
+ )
280
+ );
281
+ lines.push(this.frameLine("", innerWidth));
282
+ lines.push(
283
+ this.frameLine(
284
+ ` ${this.bold("1.")} ntfy is a simple HTTP-based notification service`,
285
+ innerWidth
286
+ )
287
+ );
288
+ lines.push(
289
+ this.frameLine(
290
+ ` ${this.fg("dim", "Public: https://ntfy.sh | Self-hosted: any ntfy server")}`,
291
+ innerWidth
292
+ )
293
+ );
294
+ lines.push(this.frameLine("", innerWidth));
295
+ lines.push(
296
+ this.frameLine(
297
+ ` ${this.bold("2.")} Choose a topic (acts as a channel name)`,
298
+ innerWidth
299
+ )
300
+ );
301
+ lines.push(
302
+ this.frameLine(
303
+ ` ${this.fg("dim", "Subscribe to the topic in the ntfy app or web UI")}`,
304
+ innerWidth
305
+ )
306
+ );
307
+ lines.push(this.frameLine("", innerWidth));
308
+ lines.push(
309
+ this.frameLine(
310
+ ` ${this.bold("3.")} Optionally set an access token for private servers`,
311
+ innerWidth
312
+ )
313
+ );
314
+ if (this.serverUrl) {
315
+ lines.push(
316
+ this.frameLine(
317
+ ` ${this.fg("success", "✓")} Server URL pre-filled from existing config`,
318
+ innerWidth
319
+ )
320
+ );
321
+ }
322
+ if (this.topic) {
323
+ lines.push(
324
+ this.frameLine(
325
+ ` ${this.fg("success", "✓")} Topic pre-filled from existing config`,
326
+ innerWidth
327
+ )
328
+ );
329
+ }
330
+ lines.push(this.ruleLine(innerWidth));
331
+ lines.push(
332
+ this.frameLine(
333
+ this.fg("dim", "Press Enter to continue, Esc to cancel"),
334
+ innerWidth
335
+ )
336
+ );
337
+ break;
338
+
339
+ case "server-url":
340
+ lines.push(
341
+ this.frameLine(
342
+ this.fg("dim", "Enter ntfy server URL:"),
343
+ innerWidth
344
+ )
345
+ );
346
+ lines.push(this.frameLine("", innerWidth));
347
+ lines.push(
348
+ this.frameLine(
349
+ ` ${this.fg("accent", this.bold(this.serverUrl || " "))}${this.fg("dim", "█")}`,
350
+ innerWidth
351
+ )
352
+ );
353
+ lines.push(this.frameLine("", innerWidth));
354
+ lines.push(
355
+ this.frameLine(
356
+ this.fg("dim", "Default: https://ntfy.sh (public)"),
357
+ innerWidth
358
+ )
359
+ );
360
+ lines.push(
361
+ this.frameLine(
362
+ this.fg("dim", "Leave empty and press Enter for default"),
363
+ innerWidth
364
+ )
365
+ );
366
+ lines.push(this.ruleLine(innerWidth));
367
+ lines.push(
368
+ this.frameLine(
369
+ this.fg("dim", "Enter to continue · Esc to cancel"),
370
+ innerWidth
371
+ )
372
+ );
373
+ break;
374
+
375
+ case "topic":
376
+ lines.push(
377
+ this.frameLine(
378
+ this.fg("dim", "Enter topic name:"),
379
+ innerWidth
380
+ )
381
+ );
382
+ lines.push(this.frameLine("", innerWidth));
383
+ lines.push(
384
+ this.frameLine(
385
+ ` ${this.fg("accent", this.bold(this.topic || " "))}${this.fg("dim", "█")}`,
386
+ innerWidth
387
+ )
388
+ );
389
+ lines.push(this.frameLine("", innerWidth));
390
+ lines.push(
391
+ this.frameLine(
392
+ this.fg("dim", "e.g. my-pi-notifications, project-alerts"),
393
+ innerWidth
394
+ )
395
+ );
396
+ lines.push(
397
+ this.frameLine(
398
+ this.fg("dim", "Pick something unique if using public ntfy.sh"),
399
+ innerWidth
400
+ )
401
+ );
402
+ lines.push(this.ruleLine(innerWidth));
403
+ lines.push(
404
+ this.frameLine(
405
+ this.fg("dim", "Enter to continue · Esc to cancel"),
406
+ innerWidth
407
+ )
408
+ );
409
+ break;
410
+
411
+ case "token":
412
+ lines.push(
413
+ this.frameLine(
414
+ this.fg("dim", "Enter access token (optional):"),
415
+ innerWidth
416
+ )
417
+ );
418
+ lines.push(this.frameLine("", innerWidth));
419
+ const displayToken = this.token
420
+ ? this.fg("accent", this.bold(this.maskToken(this.token)))
421
+ : " ";
422
+ lines.push(
423
+ this.frameLine(
424
+ ` ${displayToken}${this.fg("dim", "█")}`,
425
+ innerWidth
426
+ )
427
+ );
428
+ lines.push(this.frameLine("", innerWidth));
429
+ lines.push(
430
+ this.frameLine(
431
+ this.fg("dim", "Only needed for private/authenticated ntfy servers"),
432
+ innerWidth
433
+ )
434
+ );
435
+ lines.push(
436
+ this.frameLine(
437
+ this.fg("dim", "Not needed for public ntfy.sh"),
438
+ innerWidth
439
+ )
440
+ );
441
+ lines.push(this.ruleLine(innerWidth));
442
+ lines.push(
443
+ this.frameLine(
444
+ this.fg("dim", "Enter to continue · Esc to skip"),
445
+ innerWidth
446
+ )
447
+ );
448
+ break;
449
+
450
+ case "priority":
451
+ lines.push(
452
+ this.frameLine(
453
+ this.fg("dim", "Set notification priority (1-5):"),
454
+ innerWidth
455
+ )
456
+ );
457
+ lines.push(this.frameLine("", innerWidth));
458
+ lines.push(
459
+ this.frameLine(
460
+ ` ${this.fg("accent", this.bold(this.priority || " "))}${this.fg("dim", "█")}`,
461
+ innerWidth
462
+ )
463
+ );
464
+ lines.push(this.frameLine("", innerWidth));
465
+ lines.push(
466
+ this.frameLine(
467
+ ` ${this.fg("dim", "1")} = min · ${this.fg("dim", "3")} = default · ${this.fg("dim", "5")} = max`,
468
+ innerWidth
469
+ )
470
+ );
471
+ lines.push(this.ruleLine(innerWidth));
472
+ lines.push(
473
+ this.frameLine(
474
+ this.fg("dim", "Enter to test connection · Esc to cancel"),
475
+ innerWidth
476
+ )
477
+ );
478
+ break;
479
+
480
+ case "testing":
481
+ lines.push(this.frameLine("", innerWidth));
482
+ lines.push(
483
+ this.frameLine(
484
+ ` ${this.fg("accent", "⠋")} ${this.bold("Testing connection...")}`,
485
+ innerWidth
486
+ )
487
+ );
488
+ lines.push(this.frameLine("", innerWidth));
489
+ lines.push(
490
+ this.frameLine(
491
+ ` ${this.fg("dim", `Sending test to ${this.serverUrl}/${this.topic}`)}`,
492
+ innerWidth
493
+ )
494
+ );
495
+ lines.push(this.ruleLine(innerWidth));
496
+ lines.push(
497
+ this.frameLine(
498
+ this.fg("dim", "Esc to cancel"),
499
+ innerWidth
500
+ )
501
+ );
502
+ break;
503
+
504
+ case "success":
505
+ lines.push(this.frameLine("", innerWidth));
506
+ lines.push(
507
+ this.frameLine(
508
+ ` ${this.fg("success", "✓ ntfy configured successfully!")}`,
509
+ innerWidth
510
+ )
511
+ );
512
+ lines.push(this.frameLine("", innerWidth));
513
+ lines.push(
514
+ this.frameLine(
515
+ ` ${this.fg("dim", `Server: ${this.serverUrl}`)}`,
516
+ innerWidth
517
+ )
518
+ );
519
+ lines.push(
520
+ this.frameLine(
521
+ ` ${this.fg("dim", `Topic: ${this.topic}`)}`,
522
+ innerWidth
523
+ )
524
+ );
525
+ lines.push(
526
+ this.frameLine(
527
+ ` ${this.fg("dim", `Priority: ${this.priority}`)}`,
528
+ innerWidth
529
+ )
530
+ );
531
+ lines.push(this.ruleLine(innerWidth));
532
+ lines.push(
533
+ this.frameLine(
534
+ this.fg("dim", "Closing..."),
535
+ innerWidth
536
+ )
537
+ );
538
+ break;
539
+
540
+ case "test-failed":
541
+ lines.push(this.frameLine("", innerWidth));
542
+ lines.push(
543
+ this.frameLine(
544
+ ` ${this.fg("error", "✗ Connection test failed")}`,
545
+ innerWidth
546
+ )
547
+ );
548
+ lines.push(this.frameLine("", innerWidth));
549
+ lines.push(
550
+ this.frameLine(
551
+ ` ${this.fg("dim", this.testError || "Unknown error")}`,
552
+ innerWidth
553
+ )
554
+ );
555
+ lines.push(this.frameLine("", innerWidth));
556
+ lines.push(
557
+ this.frameLine(
558
+ ` ${this.fg("dim", "Check your server URL and topic")}`,
559
+ innerWidth
560
+ )
561
+ );
562
+ lines.push(this.ruleLine(innerWidth));
563
+ lines.push(
564
+ this.frameLine(
565
+ this.fg("dim", "Press Enter to close"),
566
+ innerWidth
567
+ )
568
+ );
569
+ break;
570
+
571
+ case "error":
572
+ lines.push(this.frameLine("", innerWidth));
573
+ lines.push(
574
+ this.frameLine(
575
+ ` ${this.fg("error", "✗ Setup failed")}`,
576
+ innerWidth
577
+ )
578
+ );
579
+ lines.push(this.frameLine("", innerWidth));
580
+ lines.push(
581
+ this.frameLine(
582
+ ` ${this.fg("dim", this.error || "Unknown error")}`,
583
+ innerWidth
584
+ )
585
+ );
586
+ lines.push(this.ruleLine(innerWidth));
587
+ lines.push(
588
+ this.frameLine(
589
+ this.fg("dim", "Press Enter to close"),
590
+ innerWidth
591
+ )
592
+ );
593
+ break;
594
+ }
595
+
596
+ lines.push(this.borderLine(innerWidth, "bottom"));
597
+ return lines;
598
+ }
599
+ }