@pi-unipi/unipi 2.1.3 → 2.2.0

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