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