@pi-unipi/notify 2.6.0 → 2.6.2

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.
@@ -2,13 +2,14 @@
2
2
  * @pi-unipi/notify — Recap Model Selector TUI
3
3
  *
4
4
  * Interactive overlay for selecting the recap summarization model.
5
- * Uses the project-wide cached model list from ~/.unipi/config/models-cache.json.
5
+ * Uses models injected from Pi's live model registry (preferred), falling back
6
+ * to the project-wide cached model list from ~/.unipi/config/models-cache.json.
6
7
  */
7
8
 
8
9
  import type { Component } from "@earendil-works/pi-tui";
9
- import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import { matchesKey } from "@earendil-works/pi-tui";
10
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
- import { readModelCache, type CachedModel, boxInnerWidth } from "@pi-unipi/core";
12
+ import { readModelCache, type CachedModel, boxInnerWidth, OverlayTheme } from "@pi-unipi/core";
12
13
  import { loadConfig, saveConfig } from "../settings.js";
13
14
 
14
15
  const DEFAULT_MODEL = "openrouter/openai/gpt-oss-20b";
@@ -26,11 +27,18 @@ export class RecapModelSelectorOverlay implements Component {
26
27
  private error: string | null = null;
27
28
  onClose?: () => void;
28
29
  requestRender?: () => void;
29
- private theme: Theme | null = null;
30
+ private overlay = new OverlayTheme();
30
31
 
31
- constructor() {
32
- // Load all cached models from project-wide cache
33
- this.models = readModelCache();
32
+ /**
33
+ * @param models Optional model list, preferably collected from Pi's live
34
+ * model registry by the command handler. Falls back to the project-wide
35
+ * cache file (`~/.unipi/config/models-cache.json`), which may not exist
36
+ * (issue #27: selector showed "No models found" while Pi itself had
37
+ * models configured in `~/.pi/agent/models.json`).
38
+ */
39
+ constructor(models?: CachedModel[]) {
40
+ // Prefer models injected from Pi's live model registry; fall back to cache.
41
+ this.models = models ?? readModelCache();
34
42
  this.applyFilter();
35
43
 
36
44
  // Pre-select current config model
@@ -43,29 +51,48 @@ export class RecapModelSelectorOverlay implements Component {
43
51
  }
44
52
 
45
53
  setTheme(theme: Theme): void {
46
- this.theme = theme;
54
+ this.overlay.setTheme(theme);
47
55
  }
48
56
 
49
57
  invalidate(): void {}
50
58
 
51
59
  handleInput(data: string): void {
60
+ // Ctrl+C must always close, even mid-filter — without this the overlay
61
+ // could trap the user on terminals with unexpected key encodings.
62
+ if (matchesKey(data, "ctrl+c")) {
63
+ this.onClose?.();
64
+ return;
65
+ }
66
+
52
67
  // Filter mode: type to search
53
68
  if (this.filterMode) {
54
- if (data === "\r") {
69
+ if (matchesKey(data, "enter")) {
55
70
  // Enter — exit filter mode
56
71
  this.filterMode = false;
57
72
  return;
58
73
  }
59
74
  if (matchesKey(data, "escape")) {
60
- // Escape — clear filter and exit filter mode
75
+ // Escape — clear filter and exit filter mode (does NOT close overlay)
61
76
  this.filter = "";
62
77
  this.filterMode = false;
63
78
  this.applyFilter();
64
79
  this.selectedIndex = 0;
65
80
  return;
66
81
  }
67
- if (data === "\x7f" || data === "\b") {
68
- // Backspace
82
+ // Let the list be navigated without leaving filter mode.
83
+ if (matchesKey(data, "up")) {
84
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
85
+ return;
86
+ }
87
+ if (matchesKey(data, "down")) {
88
+ this.selectedIndex = Math.min(
89
+ Math.max(0, this.filteredModels.length - 1),
90
+ this.selectedIndex + 1
91
+ );
92
+ return;
93
+ }
94
+ if (matchesKey(data, "backspace") || data === "\x7f" || data === "\b") {
95
+ // Backspace (legacy or kitty "\x1b[127u")
69
96
  this.filter = this.filter.slice(0, -1);
70
97
  this.applyFilter();
71
98
  this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
@@ -80,28 +107,36 @@ export class RecapModelSelectorOverlay implements Component {
80
107
  return;
81
108
  }
82
109
 
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;
110
+ // Keys are matched via matchesKey, never raw byte comparison: under the
111
+ // kitty keyboard protocol / enhanced encodings (Ghostty, Herdr) Escape
112
+ // arrives as "\x1b[27u" and arrows as "\x1b[57419u"/"\x1b[57420u", so
113
+ // exact legacy comparisons silently fail there (issue #27).
114
+ if (matchesKey(data, "up") || data === "k") {
115
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
116
+ return;
117
+ }
118
+ if (matchesKey(data, "down") || data === "j") {
119
+ this.selectedIndex = Math.min(
120
+ Math.max(0, this.filteredModels.length - 1),
121
+ this.selectedIndex + 1
122
+ );
123
+ return;
124
+ }
125
+ if (data === "/") {
126
+ // Start filter
127
+ this.filterMode = true;
128
+ this.filter = "";
129
+ return;
130
+ }
131
+ if (matchesKey(data, "enter")) {
132
+ // Enter — select and save
133
+ this.selectModel();
134
+ return;
135
+ }
136
+ if (matchesKey(data, "escape")) {
137
+ // Escape — close
138
+ this.onClose?.();
139
+ return;
105
140
  }
106
141
  }
107
142
 
@@ -134,84 +169,49 @@ export class RecapModelSelectorOverlay implements Component {
134
169
  setTimeout(() => this.onClose?.(), 500);
135
170
  }
136
171
 
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
172
  render(width: number): string[] {
173
173
  const innerWidth = boxInnerWidth(width);
174
174
  const lines: string[] = [];
175
175
 
176
- lines.push(this.borderLine(innerWidth, "top"));
176
+ lines.push(this.overlay.borderLine(innerWidth, "top"));
177
177
  lines.push(
178
- this.frameLine(
179
- this.fg("accent", this.bold("🤖 Recap Model Selector")),
178
+ this.overlay.frameLine(
179
+ this.overlay.fg("accent", this.overlay.bold("🤖 Recap Model Selector")),
180
180
  innerWidth
181
181
  )
182
182
  );
183
183
  lines.push(
184
- this.frameLine(
185
- this.fg("dim", "Select model for notification recaps"),
184
+ this.overlay.frameLine(
185
+ this.overlay.fg("dim", "Select model for notification recaps"),
186
186
  innerWidth
187
187
  )
188
188
  );
189
- lines.push(this.ruleLine(innerWidth));
189
+ lines.push(this.overlay.ruleLine(innerWidth));
190
190
 
191
191
  // Filter bar
192
192
  if (this.filterMode) {
193
193
  lines.push(
194
- this.frameLine(
195
- ` ${this.fg("accent", "Filter:")} ${this.filter}${this.fg("accent", "█")}`,
194
+ this.overlay.frameLine(
195
+ ` ${this.overlay.fg("accent", "Filter:")} ${this.filter}${this.overlay.fg("accent", "█")}`,
196
196
  innerWidth
197
197
  )
198
198
  );
199
199
  } else if (this.filter) {
200
200
  lines.push(
201
- this.frameLine(
202
- ` ${this.fg("dim", "Filter:")} ${this.filter} ${this.fg("dim", "(press / to edit)")}`,
201
+ this.overlay.frameLine(
202
+ ` ${this.overlay.fg("dim", "Filter:")} ${this.filter} ${this.overlay.fg("dim", "(press / to edit)")}`,
203
203
  innerWidth
204
204
  )
205
205
  );
206
206
  } else {
207
207
  lines.push(
208
- this.frameLine(
209
- ` ${this.fg("dim", `/${this.models.length} models · press / to filter`)}`,
208
+ this.overlay.frameLine(
209
+ ` ${this.overlay.fg("dim", `/${this.models.length} models · press / to filter`)}`,
210
210
  innerWidth
211
211
  )
212
212
  );
213
213
  }
214
- lines.push(this.ruleLine(innerWidth));
214
+ lines.push(this.overlay.ruleLine(innerWidth));
215
215
 
216
216
  // Model list
217
217
  const terminalRows = process.stdout.rows ?? 30;
@@ -226,9 +226,15 @@ export class RecapModelSelectorOverlay implements Component {
226
226
  );
227
227
 
228
228
  if (this.filteredModels.length === 0) {
229
+ const emptyMsg =
230
+ this.filter.length > 0
231
+ ? `No models match "${this.filter}"`
232
+ : this.models.length === 0
233
+ ? "No models — check ~/.pi/agent/models.json or API keys, then reopen"
234
+ : "No models found";
229
235
  lines.push(
230
- this.frameLine(
231
- ` ${this.fg("dim", "No models found")}`,
236
+ this.overlay.frameLine(
237
+ ` ${this.overlay.fg("dim", emptyMsg)}`,
232
238
  innerWidth
233
239
  )
234
240
  );
@@ -236,20 +242,20 @@ export class RecapModelSelectorOverlay implements Component {
236
242
  for (let i = startIdx; i < endIdx; i++) {
237
243
  const m = this.filteredModels[i];
238
244
  const isSelected = i === this.selectedIndex;
239
- const marker = isSelected ? this.fg("accent", "▸") : " ";
245
+ const marker = isSelected ? this.overlay.fg("accent", "▸") : " ";
240
246
  const label = m.name || m.id;
241
247
  const fullRef = `${m.provider}/${m.id}`;
242
248
  const isDefault = fullRef === DEFAULT_MODEL;
243
249
  const defaultTag = isDefault
244
- ? ` ${this.fg("warning", "(default)")}`
250
+ ? ` ${this.overlay.fg("warning", "(default)")}`
245
251
  : "";
246
252
 
247
- const providerTag = this.fg("dim", `[${m.provider}]`);
253
+ const providerTag = this.overlay.fg("dim", `[${m.provider}]`);
248
254
  const display = isSelected
249
- ? `${providerTag} ${this.bold(label)}${defaultTag}`
250
- : `${providerTag} ${this.fg("dim", label)}${defaultTag}`;
255
+ ? `${providerTag} ${this.overlay.bold(label)}${defaultTag}`
256
+ : `${providerTag} ${this.overlay.fg("dim", label)}${defaultTag}`;
251
257
 
252
- lines.push(this.frameLine(` ${marker} ${display}`, innerWidth));
258
+ lines.push(this.overlay.frameLine(` ${marker} ${display}`, innerWidth));
253
259
  }
254
260
  }
255
261
 
@@ -259,8 +265,8 @@ export class RecapModelSelectorOverlay implements Component {
259
265
  ((this.selectedIndex + 1) / this.filteredModels.length) * 100
260
266
  );
261
267
  lines.push(
262
- this.frameLine(
263
- this.fg("dim", ` ${pct}% (${this.selectedIndex + 1}/${this.filteredModels.length})`),
268
+ this.overlay.frameLine(
269
+ this.overlay.fg("dim", ` ${pct}% (${this.selectedIndex + 1}/${this.filteredModels.length})`),
264
270
  innerWidth
265
271
  )
266
272
  );
@@ -268,33 +274,33 @@ export class RecapModelSelectorOverlay implements Component {
268
274
 
269
275
  // Status messages
270
276
  if (this.error) {
271
- lines.push(this.ruleLine(innerWidth));
277
+ lines.push(this.overlay.ruleLine(innerWidth));
272
278
  lines.push(
273
- this.frameLine(` ${this.fg("error", `⚠ ${this.error}`)}`, innerWidth)
279
+ this.overlay.frameLine(` ${this.overlay.fg("error", `⚠ ${this.error}`)}`, innerWidth)
274
280
  );
275
281
  }
276
282
  if (this.saved) {
277
- lines.push(this.ruleLine(innerWidth));
283
+ lines.push(this.overlay.ruleLine(innerWidth));
278
284
  lines.push(
279
- this.frameLine(
280
- ` ${this.fg("success", "✓ Model saved")}`,
285
+ this.overlay.frameLine(
286
+ ` ${this.overlay.fg("success", "✓ Model saved")}`,
281
287
  innerWidth
282
288
  )
283
289
  );
284
290
  }
285
291
 
286
292
  // Footer
287
- lines.push(this.ruleLine(innerWidth));
293
+ lines.push(this.overlay.ruleLine(innerWidth));
288
294
  lines.push(
289
- this.frameLine(
290
- this.fg(
295
+ this.overlay.frameLine(
296
+ this.overlay.fg(
291
297
  "dim",
292
298
  "↑↓ navigate · / filter · Enter select · Esc cancel"
293
299
  ),
294
300
  innerWidth
295
301
  )
296
302
  );
297
- lines.push(this.borderLine(innerWidth, "bottom"));
303
+ lines.push(this.overlay.borderLine(innerWidth, "bottom"));
298
304
 
299
305
  return lines;
300
306
  }