@pi-unipi/unipi 2.1.2 → 2.1.3
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/package.json +22 -22
- package/packages/ask-user/package.json +3 -3
- package/packages/autocomplete/package.json +1 -1
- package/packages/btw/package.json +2 -2
- package/packages/cocoindex/package.json +2 -2
- package/packages/compactor/package.json +4 -4
- package/packages/core/package.json +1 -1
- package/packages/footer/package.json +3 -3
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/package.json +3 -3
- package/packages/kanboard/package.json +2 -2
- package/packages/mcp/package.json +2 -2
- package/packages/memory/package.json +3 -3
- package/packages/milestone/package.json +2 -2
- package/packages/notify/README.md +2 -1
- package/packages/notify/events.ts +119 -81
- package/packages/notify/package.json +2 -2
- package/packages/notify/settings.ts +1 -0
- package/packages/notify/skills/configure-notify/SKILL.md +3 -1
- package/packages/notify/src/__tests__/event-bus.test.ts +3 -2
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/package.json +5 -5
- package/packages/updater/package.json +3 -3
- package/packages/utility/package.json +2 -2
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/package.json +2 -2
- package/packages/info-screen/tui/info-overlay.ts +0 -504
- package/packages/kanboard/tui/kanboard-overlay.ts +0 -300
- package/packages/memory/tui/settings-tui.ts +0 -281
- package/packages/notify/platforms/focus-win.ts +0 -123
- package/packages/notify/platforms/focus.ts +0 -33
- package/packages/notify/platforms/gotify.ts +0 -36
- package/packages/notify/platforms/native.ts +0 -79
- package/packages/notify/platforms/node-notifier.d.ts +0 -20
- package/packages/notify/platforms/ntfy.ts +0 -48
- package/packages/notify/platforms/telegram.ts +0 -77
- package/packages/notify/tui/gotify-setup.ts +0 -528
- package/packages/notify/tui/ntfy-setup.ts +0 -652
- package/packages/notify/tui/recap-model-selector.ts +0 -301
- package/packages/notify/tui/settings-overlay.ts +0 -352
- package/packages/notify/tui/telegram-setup.ts +0 -301
|
@@ -1,301 +0,0 @@
|
|
|
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 "@earendil-works/pi-tui";
|
|
9
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
-
import type { Theme } from "@earendil-works/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
|
-
}
|
|
@@ -1,352 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pi-unipi/notify — Settings TUI Component
|
|
3
|
-
*
|
|
4
|
-
* Interactive settings editor for notification configuration.
|
|
5
|
-
* Allows toggling platforms, configuring credentials, and per-event settings.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { Component } from "@earendil-works/pi-tui";
|
|
9
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import {
|
|
12
|
-
loadConfig,
|
|
13
|
-
saveConfig,
|
|
14
|
-
validateConfig,
|
|
15
|
-
} from "../settings.js";
|
|
16
|
-
import { loadNtfyConfig, saveNtfyConfig, getNtfyConfigScope } from "../ntfy-config.js";
|
|
17
|
-
import type { NotifyConfig, NtfyConfig } from "../types.js";
|
|
18
|
-
|
|
19
|
-
/** Section types */
|
|
20
|
-
type Section = "platforms" | "events" | "recap";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Settings overlay component.
|
|
24
|
-
*/
|
|
25
|
-
export class NotifySettingsOverlay implements Component {
|
|
26
|
-
private config: NotifyConfig;
|
|
27
|
-
private ntfyConfig: NtfyConfig;
|
|
28
|
-
private ntfyScope: "project" | "global" | "none";
|
|
29
|
-
private section: Section = "platforms";
|
|
30
|
-
private selectedIndex = 0;
|
|
31
|
-
private error: string | null = null;
|
|
32
|
-
private saved = false;
|
|
33
|
-
onClose?: () => void;
|
|
34
|
-
requestRender?: () => void;
|
|
35
|
-
/** Called when user presses M in recap section to open model selector */
|
|
36
|
-
onOpenModelSelector?: () => void;
|
|
37
|
-
private theme: Theme | null = null;
|
|
38
|
-
|
|
39
|
-
constructor() {
|
|
40
|
-
this.config = loadConfig();
|
|
41
|
-
const cwd = process.cwd();
|
|
42
|
-
this.ntfyConfig = loadNtfyConfig(cwd);
|
|
43
|
-
this.ntfyScope = getNtfyConfigScope(cwd);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
setTheme(theme: Theme): void {
|
|
47
|
-
this.theme = theme;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
invalidate(): void {}
|
|
51
|
-
|
|
52
|
-
handleInput(data: string): void {
|
|
53
|
-
switch (data) {
|
|
54
|
-
case "\x1b[A": // Up
|
|
55
|
-
case "k":
|
|
56
|
-
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
57
|
-
break;
|
|
58
|
-
case "\x1b[B": // Down
|
|
59
|
-
case "j":
|
|
60
|
-
this.selectedIndex = Math.min(this.maxItems - 1, this.selectedIndex + 1);
|
|
61
|
-
break;
|
|
62
|
-
case " ": // Space - toggle
|
|
63
|
-
this.toggleCurrent();
|
|
64
|
-
break;
|
|
65
|
-
case "\t": // Tab - switch section
|
|
66
|
-
{
|
|
67
|
-
const sections: Section[] = ["platforms", "events", "recap"];
|
|
68
|
-
const idx = sections.indexOf(this.section);
|
|
69
|
-
this.section = sections[(idx + 1) % sections.length];
|
|
70
|
-
this.selectedIndex = 0;
|
|
71
|
-
}
|
|
72
|
-
break;
|
|
73
|
-
case "m": // M - open model selector (only in recap section)
|
|
74
|
-
if (this.section === "recap") {
|
|
75
|
-
this.onOpenModelSelector?.();
|
|
76
|
-
}
|
|
77
|
-
break;
|
|
78
|
-
case "\r": // Enter - save
|
|
79
|
-
this.save();
|
|
80
|
-
break;
|
|
81
|
-
case "\x1b": // Escape - close
|
|
82
|
-
this.onClose?.();
|
|
83
|
-
break;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
private get maxItems(): number {
|
|
88
|
-
if (this.section === "platforms") return 5; // native, gotify, telegram, ntfy + suppress option
|
|
89
|
-
if (this.section === "recap") return 1; // toggle
|
|
90
|
-
return Object.keys(this.config.events).length;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
private toggleCurrent(): void {
|
|
94
|
-
if (this.section === "platforms") {
|
|
95
|
-
const platforms: Array<"native" | "gotify" | "telegram" | "ntfy"> = [
|
|
96
|
-
"native",
|
|
97
|
-
"gotify",
|
|
98
|
-
"telegram",
|
|
99
|
-
"ntfy",
|
|
100
|
-
];
|
|
101
|
-
if (this.selectedIndex < platforms.length) {
|
|
102
|
-
const key = platforms[this.selectedIndex];
|
|
103
|
-
if (key === "ntfy") {
|
|
104
|
-
// ntfy toggle updates the resolved ntfy config
|
|
105
|
-
this.ntfyConfig.enabled = !this.ntfyConfig.enabled;
|
|
106
|
-
} else if (key) {
|
|
107
|
-
this.config[key].enabled = !this.config[key].enabled;
|
|
108
|
-
}
|
|
109
|
-
} else {
|
|
110
|
-
// suppressWhenFocused toggle (index 4)
|
|
111
|
-
this.config.native.suppressWhenFocused = !this.config.native.suppressWhenFocused;
|
|
112
|
-
}
|
|
113
|
-
} else if (this.section === "recap") {
|
|
114
|
-
this.config.recap.enabled = !this.config.recap.enabled;
|
|
115
|
-
} else {
|
|
116
|
-
const eventKeys = Object.keys(this.config.events);
|
|
117
|
-
const key = eventKeys[this.selectedIndex];
|
|
118
|
-
if (key && this.config.events[key]) {
|
|
119
|
-
this.config.events[key].enabled = !this.config.events[key].enabled;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
private save(): void {
|
|
125
|
-
const errors = validateConfig(this.config);
|
|
126
|
-
if (errors.length > 0) {
|
|
127
|
-
this.error = errors.join("; ");
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
this.error = null;
|
|
131
|
-
saveConfig(this.config);
|
|
132
|
-
// Save ntfy config to its own file if scope is known
|
|
133
|
-
if (this.ntfyScope !== "none") {
|
|
134
|
-
saveNtfyConfig(this.ntfyScope, process.cwd(), this.ntfyConfig);
|
|
135
|
-
}
|
|
136
|
-
this.saved = true;
|
|
137
|
-
setTimeout(() => this.onClose?.(), 500);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ─── Theme helpers ───────────────────────────────────────────────────
|
|
141
|
-
|
|
142
|
-
private fg(color: string, text: string): string {
|
|
143
|
-
if (this.theme) return this.theme.fg(color as any, text);
|
|
144
|
-
const c: Record<string, string> = {
|
|
145
|
-
accent: "\x1b[36m", success: "\x1b[32m", warning: "\x1b[33m",
|
|
146
|
-
error: "\x1b[31m", dim: "\x1b[2m", borderMuted: "\x1b[90m",
|
|
147
|
-
};
|
|
148
|
-
return `${c[color] ?? ""}${text}\x1b[0m`;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
private bold(text: string): string {
|
|
152
|
-
return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private frameLine(content: string, innerWidth: number): string {
|
|
156
|
-
const truncated = truncateToWidth(content, innerWidth, "");
|
|
157
|
-
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
158
|
-
return `${this.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.fg("borderMuted", "│")}`;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
private ruleLine(innerWidth: number): string {
|
|
162
|
-
return this.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
166
|
-
const left = edge === "top" ? "┌" : "└";
|
|
167
|
-
const right = edge === "top" ? "┐" : "┘";
|
|
168
|
-
return this.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
private getDialogHeight(): number {
|
|
172
|
-
const terminalRows = process.stdout.rows ?? 30;
|
|
173
|
-
return Math.max(14, Math.min(24, Math.floor(terminalRows * 0.65)));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
render(width: number): string[] {
|
|
177
|
-
const innerWidth = Math.max(22, width - 2);
|
|
178
|
-
const lines: string[] = [];
|
|
179
|
-
|
|
180
|
-
lines.push(this.borderLine(innerWidth, "top"));
|
|
181
|
-
lines.push(this.frameLine(this.fg("accent", this.bold("🔔 Notify Settings")), innerWidth));
|
|
182
|
-
lines.push(this.frameLine(this.fg("dim", "Configure notification platforms and events"), innerWidth));
|
|
183
|
-
lines.push(this.ruleLine(innerWidth));
|
|
184
|
-
|
|
185
|
-
// Section tabs
|
|
186
|
-
const platformTab =
|
|
187
|
-
this.section === "platforms"
|
|
188
|
-
? this.fg("accent", this.bold("[Platforms]"))
|
|
189
|
-
: this.fg("dim", "Platforms");
|
|
190
|
-
const eventsTab =
|
|
191
|
-
this.section === "events"
|
|
192
|
-
? this.fg("accent", this.bold("[Events]"))
|
|
193
|
-
: this.fg("dim", "Events");
|
|
194
|
-
const recapTab =
|
|
195
|
-
this.section === "recap"
|
|
196
|
-
? this.fg("accent", this.bold("[Recap]"))
|
|
197
|
-
: this.fg("dim", "Recap");
|
|
198
|
-
lines.push(this.frameLine(` ${platformTab} ${eventsTab} ${recapTab}`, innerWidth));
|
|
199
|
-
lines.push(this.ruleLine(innerWidth));
|
|
200
|
-
|
|
201
|
-
if (this.section === "platforms") {
|
|
202
|
-
this.renderPlatforms(lines, innerWidth);
|
|
203
|
-
} else if (this.section === "recap") {
|
|
204
|
-
this.renderRecap(lines, innerWidth);
|
|
205
|
-
} else {
|
|
206
|
-
this.renderEvents(lines, innerWidth);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Status messages
|
|
210
|
-
if (this.error) {
|
|
211
|
-
lines.push(this.ruleLine(innerWidth));
|
|
212
|
-
lines.push(this.frameLine(` ${this.fg("error", `⚠ ${this.error}`)}`, innerWidth));
|
|
213
|
-
}
|
|
214
|
-
if (this.saved) {
|
|
215
|
-
lines.push(this.ruleLine(innerWidth));
|
|
216
|
-
lines.push(this.frameLine(` ${this.fg("success", "✓ Settings saved")}`, innerWidth));
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Footer
|
|
220
|
-
lines.push(this.ruleLine(innerWidth));
|
|
221
|
-
const footerHint = this.section === "recap"
|
|
222
|
-
? "↑↓ navigate · Space toggle · M change model · Tab switch · Enter save · Esc cancel"
|
|
223
|
-
: "↑↓ navigate · Space toggle · Tab switch · Enter save · Esc cancel";
|
|
224
|
-
lines.push(this.frameLine(this.fg("dim", footerHint), innerWidth));
|
|
225
|
-
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
226
|
-
|
|
227
|
-
return lines;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
private renderPlatforms(lines: string[], innerWidth: number): void {
|
|
231
|
-
const platforms: Array<{
|
|
232
|
-
key: "native" | "gotify" | "telegram" | "ntfy";
|
|
233
|
-
label: string;
|
|
234
|
-
detail: string;
|
|
235
|
-
}> = [
|
|
236
|
-
{
|
|
237
|
-
key: "native",
|
|
238
|
-
label: "Native OS",
|
|
239
|
-
detail: "Desktop notifications (node-notifier)",
|
|
240
|
-
},
|
|
241
|
-
{
|
|
242
|
-
key: "gotify",
|
|
243
|
-
label: "Gotify",
|
|
244
|
-
detail: this.config.gotify.serverUrl
|
|
245
|
-
? `Server: ${this.config.gotify.serverUrl}`
|
|
246
|
-
: "Self-hosted push server",
|
|
247
|
-
},
|
|
248
|
-
{
|
|
249
|
-
key: "telegram",
|
|
250
|
-
label: "Telegram",
|
|
251
|
-
detail: this.config.telegram.botToken
|
|
252
|
-
? "Bot configured"
|
|
253
|
-
: "Bot API notifications",
|
|
254
|
-
},
|
|
255
|
-
{
|
|
256
|
-
key: "ntfy",
|
|
257
|
-
label: "ntfy",
|
|
258
|
-
detail: this.ntfyScope !== "none"
|
|
259
|
-
? `Topic: ${this.ntfyConfig.topic ?? "—"} · P${this.ntfyConfig.priority} · [${this.ntfyScope}]`
|
|
260
|
-
: "Not configured",
|
|
261
|
-
},
|
|
262
|
-
];
|
|
263
|
-
|
|
264
|
-
for (let i = 0; i < platforms.length; i++) {
|
|
265
|
-
const p = platforms[i];
|
|
266
|
-
const isSelected = i === this.selectedIndex;
|
|
267
|
-
const toggleOn = this.fg("success", "●");
|
|
268
|
-
const toggleOff = this.fg("dim", "○");
|
|
269
|
-
// ntfy enabled state comes from resolved ntfy.json, not config.json
|
|
270
|
-
const isEnabled = p.key === "ntfy" ? this.ntfyConfig.enabled : this.config[p.key].enabled;
|
|
271
|
-
const toggle = isEnabled ? toggleOn : toggleOff;
|
|
272
|
-
const label = isSelected ? this.bold(p.label) : this.fg("dim", p.label);
|
|
273
|
-
|
|
274
|
-
lines.push(
|
|
275
|
-
this.frameLine(
|
|
276
|
-
`${isSelected ? this.fg("accent", "▸") : " "} ${toggle} ${label} ${this.fg("dim", p.detail)}`,
|
|
277
|
-
innerWidth
|
|
278
|
-
)
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// suppressWhenFocused toggle (index 4)
|
|
283
|
-
{
|
|
284
|
-
const i = platforms.length;
|
|
285
|
-
const isSelected = i === this.selectedIndex;
|
|
286
|
-
const isEnabled = this.config.native.suppressWhenFocused === true;
|
|
287
|
-
const toggleOn = this.fg("success", "●");
|
|
288
|
-
const toggleOff = this.fg("dim", "○");
|
|
289
|
-
const toggle = isEnabled ? toggleOn : toggleOff;
|
|
290
|
-
const label = isSelected
|
|
291
|
-
? this.bold("Suppress when focused")
|
|
292
|
-
: this.fg("dim", "Suppress when focused");
|
|
293
|
-
const detail = this.fg("dim", isEnabled ? "Windows only — terminal in foreground → skip" : "Windows only");
|
|
294
|
-
|
|
295
|
-
lines.push(
|
|
296
|
-
this.frameLine(
|
|
297
|
-
`${isSelected ? this.fg("accent", "▸") : " "} ${toggle} ${label} ${detail}`,
|
|
298
|
-
innerWidth
|
|
299
|
-
)
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
private renderEvents(lines: string[], innerWidth: number): void {
|
|
305
|
-
const events = Object.entries(this.config.events);
|
|
306
|
-
|
|
307
|
-
for (let i = 0; i < events.length; i++) {
|
|
308
|
-
const [key, cfg] = events[i];
|
|
309
|
-
const isSelected = i === this.selectedIndex;
|
|
310
|
-
const toggleOn = this.fg("success", "●");
|
|
311
|
-
const toggleOff = this.fg("dim", "○");
|
|
312
|
-
const toggle = cfg.enabled ? toggleOn : toggleOff;
|
|
313
|
-
const label = isSelected ? this.bold(key) : this.fg("dim", key);
|
|
314
|
-
|
|
315
|
-
lines.push(
|
|
316
|
-
this.frameLine(
|
|
317
|
-
`${isSelected ? this.fg("accent", "▸") : " "} ${toggle} ${label}`,
|
|
318
|
-
innerWidth
|
|
319
|
-
)
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
private renderRecap(lines: string[], innerWidth: number): void {
|
|
325
|
-
// Toggle
|
|
326
|
-
const isSelected = this.selectedIndex === 0;
|
|
327
|
-
const toggleOn = this.fg("success", "●");
|
|
328
|
-
const toggleOff = this.fg("dim", "○");
|
|
329
|
-
const toggle = this.config.recap.enabled ? toggleOn : toggleOff;
|
|
330
|
-
const label = isSelected
|
|
331
|
-
? this.bold("Enable Recap")
|
|
332
|
-
: this.fg("dim", "Enable Recap");
|
|
333
|
-
|
|
334
|
-
lines.push(
|
|
335
|
-
this.frameLine(
|
|
336
|
-
`${isSelected ? this.fg("accent", "▸") : " "} ${toggle} ${label}`,
|
|
337
|
-
innerWidth
|
|
338
|
-
)
|
|
339
|
-
);
|
|
340
|
-
|
|
341
|
-
// Current model display
|
|
342
|
-
const modelRef = this.config.recap.model;
|
|
343
|
-
const modelLabel = this.fg("dim", ` Model: ${modelRef}`);
|
|
344
|
-
lines.push(this.frameLine(modelLabel, innerWidth));
|
|
345
|
-
lines.push(
|
|
346
|
-
this.frameLine(
|
|
347
|
-
this.fg("dim", " Press M to change model"),
|
|
348
|
-
innerWidth
|
|
349
|
-
)
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
}
|