pi-llama-cpp 0.11.0 → 0.12.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.
@@ -0,0 +1,682 @@
1
+ import type { ModelCostRates } from "@earendil-works/pi-ai";
2
+ import {
3
+ getSettingsListTheme,
4
+ type Theme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type {
7
+ Component,
8
+ Focusable,
9
+ KeybindingsManager,
10
+ TUI,
11
+ } from "@earendil-works/pi-tui";
12
+ import { SettingsList, type SettingItem } from "@earendil-works/pi-tui";
13
+ import type { LlamaServer, ModelOverride } from "../interfaces/settings";
14
+ import { ConfirmDialog, InputDialog } from "./dialog";
15
+ import {
16
+ addOverrideEntry,
17
+ applyCostFieldValue,
18
+ formatOverrideSummary,
19
+ parseCostValue,
20
+ removeOverrideEntry,
21
+ updateOverrideEntry,
22
+ } from "./overrideEntryEditor";
23
+ import { fieldMessage, FIELDS, HINTS, TITLES } from "./strings";
24
+
25
+ /**
26
+ * Options for the SettingsList-based overrides editor.
27
+ */
28
+ export interface OverrideSettingsListOptions {
29
+ /** TUI instance, used to request re-renders after async persists */
30
+ tui: TUI;
31
+ /** Theme for dialogs (from the ctx.ui.custom factory) */
32
+ theme: Theme;
33
+ /** App keybindings manager (injected by ctx.ui.custom) */
34
+ keybindings: KeybindingsManager;
35
+ /**
36
+ * Snapshot of the merged `llamaSettings.servers` to edit. Replaced
37
+ * in place (`options.servers = next`) after every successful persist —
38
+ * this object is shared between the top-level list and the per-server
39
+ * entry editors so re-entering a server sees the adopted values.
40
+ */
41
+ servers: LlamaServer[];
42
+ /** Persists a new server list; a rejection keeps the current list */
43
+ persist: (next: LlamaServer[]) => Promise<void>;
44
+ /** Closes the editor (called on Esc in the server list) */
45
+ done: () => void;
46
+ /** Notifies about persistence errors */
47
+ onError: (message: string) => void;
48
+ /** Called after a successful add/edit/delete */
49
+ onChanged: () => void;
50
+ }
51
+
52
+ // ─── Field items for one override entry ───────────────────────────────────
53
+
54
+ /**
55
+ * Builds the SettingItem for one override entry's editable fields.
56
+ *
57
+ * - Pattern / costs: infinite → `InputDialog` submenus
58
+ * - Capabilities: finite → `["text", "text | image"]`
59
+ * - Reasoning: finite → `["true", "false"]`
60
+ *
61
+ * Commits are reported uniformly through the containing `SettingsList`'s
62
+ * `onChange` (both finite cycling and `done(value)` from the input
63
+ * submenus arrive there), so no per-field callback is needed here.
64
+ */
65
+ const buildOverrideFieldItems = (
66
+ entry: { pattern: string; override: ModelOverride },
67
+ theme: Theme,
68
+ tui: TUI,
69
+ ): SettingItem[] => [
70
+ // Pattern — infinite
71
+ {
72
+ id: "pattern",
73
+ label: FIELDS.pattern.label,
74
+ description: FIELDS.pattern.description,
75
+ currentValue: entry.pattern,
76
+ submenu: InputDialog.inputSubmenu(
77
+ theme,
78
+ tui,
79
+ TITLES.edit(FIELDS.pattern.label),
80
+ fieldMessage(FIELDS.pattern),
81
+ FIELDS.pattern.placeholder,
82
+ entry.pattern,
83
+ (raw) => {
84
+ const trimmed = raw.trim();
85
+ return trimmed.length > 0 ? trimmed : null;
86
+ },
87
+ ),
88
+ },
89
+ // Input cost — infinite
90
+ {
91
+ id: "cost.input",
92
+ label: FIELDS.inputCost.label,
93
+ description: FIELDS.inputCost.description,
94
+ currentValue: String(entry.override.cost?.input ?? 0),
95
+ submenu: InputDialog.inputSubmenu(
96
+ theme,
97
+ tui,
98
+ TITLES.edit(FIELDS.inputCost.label),
99
+ fieldMessage(FIELDS.inputCost),
100
+ FIELDS.inputCost.placeholder,
101
+ String(entry.override.cost?.input ?? 0),
102
+ (raw) => {
103
+ const parsed = parseCostValue(raw);
104
+ return parsed === null ? null : String(parsed);
105
+ },
106
+ ),
107
+ },
108
+ // Output cost — infinite
109
+ {
110
+ id: "cost.output",
111
+ label: FIELDS.outputCost.label,
112
+ description: FIELDS.outputCost.description,
113
+ currentValue: String(entry.override.cost?.output ?? 0),
114
+ submenu: InputDialog.inputSubmenu(
115
+ theme,
116
+ tui,
117
+ TITLES.edit(FIELDS.outputCost.label),
118
+ fieldMessage(FIELDS.outputCost),
119
+ FIELDS.outputCost.placeholder,
120
+ String(entry.override.cost?.output ?? 0),
121
+ (raw) => {
122
+ const parsed = parseCostValue(raw);
123
+ return parsed === null ? null : String(parsed);
124
+ },
125
+ ),
126
+ },
127
+ // Cache read cost — infinite
128
+ {
129
+ id: "cost.cacheRead",
130
+ label: FIELDS.cacheReadCost.label,
131
+ description: FIELDS.cacheReadCost.description,
132
+ currentValue: String(entry.override.cost?.cacheRead ?? 0),
133
+ submenu: InputDialog.inputSubmenu(
134
+ theme,
135
+ tui,
136
+ TITLES.edit(FIELDS.cacheReadCost.label),
137
+ fieldMessage(FIELDS.cacheReadCost),
138
+ FIELDS.cacheReadCost.placeholder,
139
+ String(entry.override.cost?.cacheRead ?? 0),
140
+ (raw) => {
141
+ const parsed = parseCostValue(raw);
142
+ return parsed === null ? null : String(parsed);
143
+ },
144
+ ),
145
+ },
146
+ // Cache write cost — infinite
147
+ {
148
+ id: "cost.cacheWrite",
149
+ label: FIELDS.cacheWriteCost.label,
150
+ description: FIELDS.cacheWriteCost.description,
151
+ currentValue: String(entry.override.cost?.cacheWrite ?? 0),
152
+ submenu: InputDialog.inputSubmenu(
153
+ theme,
154
+ tui,
155
+ TITLES.edit(FIELDS.cacheWriteCost.label),
156
+ fieldMessage(FIELDS.cacheWriteCost),
157
+ FIELDS.cacheWriteCost.placeholder,
158
+ String(entry.override.cost?.cacheWrite ?? 0),
159
+ (raw) => {
160
+ const parsed = parseCostValue(raw);
161
+ return parsed === null ? null : String(parsed);
162
+ },
163
+ ),
164
+ },
165
+ // Capabilities — finite: text or text | image
166
+ {
167
+ id: "capabilities",
168
+ label: FIELDS.capabilities.label,
169
+ description: FIELDS.capabilities.description,
170
+ currentValue: entry.override.capabilities?.join(" | ") ?? "text",
171
+ values: ["text", "text | image"],
172
+ },
173
+ // Reasoning — finite: true or false
174
+ {
175
+ id: "reasoning",
176
+ label: FIELDS.reasoning.label,
177
+ description: FIELDS.reasoning.description,
178
+ currentValue:
179
+ entry.override.reasoning === undefined
180
+ ? "true"
181
+ : entry.override.reasoning
182
+ ? "true"
183
+ : "false",
184
+ values: ["true", "false"],
185
+ },
186
+ // Context size — infinite
187
+ {
188
+ id: "contextSize",
189
+ label: FIELDS.contextSize.label,
190
+ description: FIELDS.contextSize.description,
191
+ currentValue: String(entry.override.contextSize ?? 0),
192
+ submenu: InputDialog.inputSubmenu(
193
+ theme,
194
+ tui,
195
+ TITLES.edit(FIELDS.contextSize.label),
196
+ fieldMessage(FIELDS.contextSize),
197
+ FIELDS.contextSize.placeholder,
198
+ String(entry.override.contextSize ?? 0),
199
+ (raw) => {
200
+ const n = Number(raw.trim());
201
+ return n >= 0 && isFinite(n) ? String(n) : null;
202
+ },
203
+ ),
204
+ },
205
+ // Max tokens — infinite
206
+ {
207
+ id: "maxTokens",
208
+ label: FIELDS.maxTokens.label,
209
+ description: FIELDS.maxTokens.description,
210
+ currentValue: String(entry.override.maxTokens ?? 0),
211
+ submenu: InputDialog.inputSubmenu(
212
+ theme,
213
+ tui,
214
+ TITLES.edit(FIELDS.maxTokens.label),
215
+ fieldMessage(FIELDS.maxTokens),
216
+ FIELDS.maxTokens.placeholder,
217
+ String(entry.override.maxTokens ?? 0),
218
+ (raw) => {
219
+ const n = Number(raw.trim());
220
+ return n >= 0 && isFinite(n) ? String(n) : null;
221
+ },
222
+ ),
223
+ },
224
+ ];
225
+
226
+ /**
227
+ * Parses the capabilities label back to an array or undefined.
228
+ */
229
+ const parseCapabilitiesLabel = (
230
+ label: string,
231
+ ): ("text" | "image")[] | undefined => {
232
+ if (label === "text") return ["text"];
233
+ if (label === "text | image") return ["text", "image"];
234
+ return undefined;
235
+ };
236
+
237
+ /**
238
+ * Parses the reasoning label back to a boolean or undefined.
239
+ */
240
+ const parseReasoningLabel = (label: string): boolean | undefined => {
241
+ if (label === "true") return true;
242
+ if (label === "false") return false;
243
+ return undefined;
244
+ };
245
+
246
+ // ─── Override entry list (per-server) ─────────────────────────────────────
247
+
248
+ /**
249
+ * The override entries of a single server, as a list view with add/delete.
250
+ */
251
+ class OverrideEntryListEditor implements Component, Focusable {
252
+ private settingsList: SettingsList | null = null;
253
+ private mode: "list" | "add" | "confirm" = "list";
254
+ private addDialog: InputDialog | undefined;
255
+ private confirmDialog: ConfirmDialog | undefined;
256
+ private isFocused = false;
257
+ private _selectedIndex = 0;
258
+ private submenuOpen = false;
259
+
260
+ constructor(
261
+ /** Shared options object — `persistSnapshot` updates `options.servers`
262
+ * in place so later drill-downs (and sibling editors) see the change */
263
+ private readonly options: OverrideSettingsListOptions,
264
+ private readonly serverIndex: number,
265
+ /** Closes this editor (called on Esc in the entry list) */
266
+ private readonly done: () => void,
267
+ ) {
268
+ this.settingsList = this.buildSettingsList();
269
+ }
270
+
271
+ // -- Focusable -----------------------------------------------------------
272
+
273
+ get focused(): boolean {
274
+ return this.isFocused;
275
+ }
276
+
277
+ set focused(value: boolean) {
278
+ this.isFocused = value;
279
+ if (this.addDialog) this.addDialog.focused = value;
280
+ if (this.confirmDialog) this.confirmDialog.focused = value;
281
+ }
282
+
283
+ // -- Component -----------------------------------------------------------
284
+
285
+ invalidate(): void {
286
+ this.settingsList?.invalidate();
287
+ this.addDialog?.invalidate();
288
+ this.confirmDialog?.invalidate();
289
+ }
290
+
291
+ handleInput(data: string): void {
292
+ if (this.mode === "add") {
293
+ this.addDialog?.handleInput(data);
294
+ return;
295
+ }
296
+
297
+ if (this.mode === "confirm") {
298
+ this.confirmDialog?.handleInput(data);
299
+ return;
300
+ }
301
+
302
+ // A pattern row's field submenu is open: delegate everything (Esc,
303
+ // a/d, arrows) to the containing SettingsList, which forwards input
304
+ // to the submenu. Intercepting here would close the whole editor on
305
+ // Esc and trigger add/delete while typing in a field.
306
+ if (this.submenuOpen && this.settingsList) {
307
+ this.settingsList.handleInput(data);
308
+ return;
309
+ }
310
+
311
+ const kb = this.options.keybindings;
312
+ if (kb.matches(data, "tui.select.cancel")) {
313
+ this.done();
314
+ return;
315
+ }
316
+ // Track selection for add/delete (delegate to SettingsList for rendering)
317
+ if (kb.matches(data, "tui.select.up")) {
318
+ this._selectedIndex =
319
+ this._selectedIndex === 0
320
+ ? this.getEntries().length - 1
321
+ : this._selectedIndex - 1;
322
+ }
323
+ if (kb.matches(data, "tui.select.down")) {
324
+ this._selectedIndex =
325
+ this._selectedIndex === this.getEntries().length - 1
326
+ ? 0
327
+ : this._selectedIndex + 1;
328
+ }
329
+ if (data === "a") {
330
+ this.beginAdd();
331
+ return;
332
+ }
333
+ if (data === "d") {
334
+ this.beginConfirm();
335
+ return;
336
+ }
337
+ if (this.settingsList) {
338
+ this.settingsList.handleInput(data);
339
+ }
340
+ }
341
+
342
+ render(width: number): string[] {
343
+ if (this.mode === "add") {
344
+ return this.addDialog?.render(width) ?? [];
345
+ }
346
+
347
+ if (this.mode === "confirm") {
348
+ return this.confirmDialog?.render(width) ?? [];
349
+ }
350
+
351
+ if (this.settingsList) {
352
+ // The shortcuts live in the rows' description; only when the list is
353
+ // empty (no rows → no description) show them as the hint line
354
+ const lines = this.settingsList.render(width);
355
+ if (this.getEntries().length === 0) {
356
+ lines[lines.length - 1] = getSettingsListTheme().hint(
357
+ HINTS.emptyOverrideEntries,
358
+ );
359
+ }
360
+ return lines;
361
+ }
362
+ return ["Loading..."];
363
+ }
364
+
365
+ // -- helpers ---------------------------------------------------------------
366
+
367
+ private getEntries(): { pattern: string; override: ModelOverride }[] {
368
+ const server = this.options.servers[this.serverIndex];
369
+ return Object.entries(server?.overrides ?? {}).map(
370
+ ([pattern, override]) => ({ pattern, override }),
371
+ );
372
+ }
373
+
374
+ private buildSettingsList(): SettingsList {
375
+ const { theme, tui } = this.options;
376
+ // A fresh list never has a submenu open (also covers the pattern-key
377
+ // rebuild path, which discards the open field submenu)
378
+ this.submenuOpen = false;
379
+ const items: SettingItem[] = this.getEntries().map((entry, i) => ({
380
+ id: `entry-${i}`,
381
+ label: entry.pattern,
382
+ // Shortcuts live here instead of a custom hint line; the summary
383
+ // itself is already shown as the row's currentValue
384
+ description: HINTS.overrideEntryRow,
385
+ currentValue: formatOverrideSummary(entry.override),
386
+ submenu: (_cv, done) => {
387
+ // Re-read the entry so the submenu prefills with the current
388
+ // values (the list is rebuilt after every successful persist,
389
+ // so `i` is stable for this list instance's lifetime)
390
+ const current = this.getEntries()[i] ?? entry;
391
+ const fieldItems = buildOverrideFieldItems(current, theme, tui);
392
+ this.submenuOpen = true;
393
+ return new SettingsList(
394
+ fieldItems,
395
+ Math.min(fieldItems.length + 2, 15),
396
+ getSettingsListTheme(),
397
+ // Field commits — finite cycling (capabilities, reasoning) and
398
+ // input submenus (pattern, costs) — all arrive here
399
+ (field, value) => void this.handleFieldChange(i, field, value),
400
+ () => {
401
+ this.submenuOpen = false;
402
+ done();
403
+ },
404
+ );
405
+ },
406
+ }));
407
+
408
+ return new SettingsList(
409
+ items,
410
+ Math.min(items.length + 2, 15),
411
+ getSettingsListTheme(),
412
+ () => {},
413
+ () => {
414
+ this.done();
415
+ },
416
+ );
417
+ }
418
+
419
+ /**
420
+ * Rebuilds the entry list and places the cursor on `targetIndex`
421
+ * (clamped to the last entry). A fresh SettingsList starts its cursor
422
+ * at index 0 and `selectedIndex` is private, so `selectItem` is the
423
+ * supported way to restore the position; the manual `_selectedIndex`
424
+ * tracker (used for add/delete targeting) is kept in sync.
425
+ */
426
+ private rebuildList(targetIndex: number): void {
427
+ this.settingsList = this.buildSettingsList();
428
+ const count = this.getEntries().length;
429
+ if (count === 0) {
430
+ this._selectedIndex = 0;
431
+ return;
432
+ }
433
+ const target = Math.min(targetIndex, count - 1);
434
+ this._selectedIndex = target;
435
+ this.settingsList.selectItem(`entry-${target}`);
436
+ }
437
+
438
+ private beginAdd(): void {
439
+ this.mode = "add";
440
+ this.addDialog = new InputDialog({
441
+ theme: this.options.theme,
442
+ tui: this.options.tui,
443
+ title: TITLES.addOverride,
444
+ message: fieldMessage(FIELDS.pattern),
445
+ placeholder: FIELDS.pattern.placeholder,
446
+ validate: (raw) => {
447
+ const trimmed = raw.trim();
448
+ return trimmed.length > 0 ? trimmed : null;
449
+ },
450
+ onSubmit: (value) => {
451
+ this.addDialog = undefined;
452
+ void this.saveAdd(value);
453
+ },
454
+ onCancel: () => {
455
+ this.addDialog = undefined;
456
+ this.mode = "list";
457
+ this.options.tui.requestRender();
458
+ },
459
+ });
460
+ this.addDialog.focused = this.isFocused;
461
+ this.options.tui.requestRender();
462
+ }
463
+
464
+ private beginConfirm(): void {
465
+ const entry = this.getEntries()[this._selectedIndex];
466
+ if (!entry) return;
467
+
468
+ this.mode = "confirm";
469
+ this.confirmDialog = new ConfirmDialog({
470
+ theme: this.options.theme,
471
+ tui: this.options.tui,
472
+ title: TITLES.deleteOverride,
473
+ message: `Delete "${entry.pattern}"?`,
474
+ onConfirm: () => {
475
+ this.confirmDialog = undefined;
476
+ void this.deleteSelected();
477
+ },
478
+ onCancel: () => {
479
+ this.confirmDialog = undefined;
480
+ this.mode = "list";
481
+ this.options.tui.requestRender();
482
+ },
483
+ });
484
+ this.confirmDialog.focused = this.isFocused;
485
+ this.options.tui.requestRender();
486
+ }
487
+
488
+ private async saveAdd(pattern: string): Promise<void> {
489
+ const server = this.options.servers[this.serverIndex];
490
+ if (!server) {
491
+ this.mode = "list";
492
+ this.options.tui.requestRender();
493
+ return;
494
+ }
495
+
496
+ // Uniquify the pattern
497
+ const existing = new Set(Object.keys(server.overrides ?? {}));
498
+ let uniquePattern = pattern;
499
+ let n = 2;
500
+ while (existing.has(uniquePattern)) {
501
+ uniquePattern = `${pattern}-${n++}`;
502
+ }
503
+
504
+ const next = addOverrideEntry(
505
+ this.options.servers,
506
+ this.serverIndex,
507
+ uniquePattern,
508
+ );
509
+ await this.persistSnapshot(next, () => {
510
+ this.mode = "list";
511
+ // The new entry is appended last — put the cursor on it
512
+ this.rebuildList(this.getEntries().length - 1);
513
+ this.options.onChanged();
514
+ this.options.tui.requestRender();
515
+ });
516
+ }
517
+
518
+ private async deleteSelected(): Promise<void> {
519
+ const idx = this._selectedIndex;
520
+ const next = removeOverrideEntry(
521
+ this.options.servers,
522
+ this.serverIndex,
523
+ idx,
524
+ );
525
+ await this.persistSnapshot(next, () => {
526
+ this.mode = "list";
527
+ // The entry that followed the deleted one now sits at the same
528
+ // index (rebuildList clamps when the last entry was deleted)
529
+ this.rebuildList(idx);
530
+ this.options.onChanged();
531
+ this.options.tui.requestRender();
532
+ });
533
+ }
534
+
535
+ /**
536
+ * Persists `next`; on success adopts it as the editor's snapshot and
537
+ * runs `onSuccess`; on failure notifies and keeps the current state.
538
+ *
539
+ * Adopting the persisted list is essential: the editor holds a snapshot
540
+ * taken when it opened (not a live reference), so without this the next
541
+ * edit would build on stale data and rebuilt rows would show old values.
542
+ */
543
+ private async persistSnapshot(
544
+ next: LlamaServer[],
545
+ onSuccess: () => void,
546
+ ): Promise<void> {
547
+ try {
548
+ await this.options.persist(next);
549
+ } catch (err) {
550
+ this.options.onError(String(err));
551
+ this.mode = "list";
552
+ this.options.tui.requestRender();
553
+ return;
554
+ }
555
+ this.options.servers = next;
556
+ onSuccess();
557
+ }
558
+
559
+ private async handleFieldChange(
560
+ entryIndex: number,
561
+ field: string,
562
+ value: string,
563
+ ): Promise<void> {
564
+ const entries = this.getEntries();
565
+ const entry = entries[entryIndex];
566
+ if (!entry) return;
567
+
568
+ const server = this.options.servers[this.serverIndex];
569
+ if (!server) return;
570
+
571
+ if (field === "pattern") {
572
+ // Pattern change: update the key (row label changes → full rebuild)
573
+ const next = updateOverrideEntry(
574
+ this.options.servers,
575
+ this.serverIndex,
576
+ entryIndex,
577
+ value,
578
+ entry.override,
579
+ );
580
+ await this.persistSnapshot(next, () => {
581
+ // Keep the cursor on the renamed entry
582
+ this.rebuildList(entryIndex);
583
+ this.options.tui.requestRender();
584
+ });
585
+ return;
586
+ }
587
+
588
+ const updatedOverride: ModelOverride = { ...entry.override };
589
+
590
+ if (field.startsWith("cost.")) {
591
+ const costField = field.split(".")[1] as keyof ModelCostRates;
592
+ const nextCost = applyCostFieldValue(
593
+ entry.override.cost,
594
+ costField,
595
+ Number(value),
596
+ );
597
+ if (nextCost) updatedOverride.cost = nextCost;
598
+ else delete updatedOverride.cost;
599
+ } else if (field === "capabilities") {
600
+ updatedOverride.capabilities = parseCapabilitiesLabel(value);
601
+ } else if (field === "reasoning") {
602
+ const reasoning = parseReasoningLabel(value);
603
+ if (reasoning === undefined) {
604
+ delete updatedOverride.reasoning;
605
+ } else {
606
+ updatedOverride.reasoning = reasoning;
607
+ }
608
+ } else if (field === "maxTokens") {
609
+ const n = Number(value);
610
+ if (isFinite(n) && n > 0) updatedOverride.maxTokens = n;
611
+ else delete updatedOverride.maxTokens;
612
+ } else if (field === "contextSize") {
613
+ const n = Number(value);
614
+ if (isFinite(n) && n > 0) updatedOverride.contextSize = n;
615
+ else delete updatedOverride.contextSize;
616
+ }
617
+
618
+ const next = updateOverrideEntry(
619
+ this.options.servers,
620
+ this.serverIndex,
621
+ entryIndex,
622
+ entry.pattern,
623
+ updatedOverride,
624
+ );
625
+ // Keep the field submenu open — just refresh the entry's summary row
626
+ await this.persistSnapshot(next, () => {
627
+ this.settingsList?.updateValue(
628
+ `entry-${entryIndex}`,
629
+ formatOverrideSummary(updatedOverride),
630
+ );
631
+ this.options.tui.requestRender();
632
+ });
633
+ }
634
+ }
635
+
636
+ // ─── Top-level overrides editor ───────────────────────────────────────────
637
+
638
+ /**
639
+ * Builds the top-level SettingsList for the overrides editor.
640
+ * One row per server; Enter drills into the server's override entries.
641
+ */
642
+ export const createOverrideSettingsList = (
643
+ options: OverrideSettingsListOptions,
644
+ ): SettingsList => {
645
+ // Assigned right after the items are built; the submenu closures below
646
+ // only run after that, so they can safely reference the list
647
+ let serverList: SettingsList;
648
+
649
+ const serverItems: SettingItem[] = options.servers.map((server, i) => ({
650
+ id: `server-${i}`,
651
+ label: server.url,
652
+ description: "Enter: edit this server's override entries",
653
+ currentValue: `${Object.keys(server.overrides ?? {}).length} entries`,
654
+ submenu: (_cv, done) =>
655
+ // Pass the shared options object through: the entry editor replaces
656
+ // `options.servers` after each successful persist, so a fresh editor
657
+ // created on the next drill-down starts from the adopted list
658
+ new OverrideEntryListEditor(options, i, () => {
659
+ // The entry count may have changed (add/delete/pattern rename)
660
+ // while this editor was open — refresh the row before redisplay
661
+ const current = options.servers[i];
662
+ serverList?.updateValue(
663
+ `server-${i}`,
664
+ `${Object.keys(current?.overrides ?? {}).length} entries`,
665
+ );
666
+ done();
667
+ }),
668
+ }));
669
+
670
+ serverList = new SettingsList(
671
+ serverItems,
672
+ Math.min(serverItems.length + 2, 15),
673
+ getSettingsListTheme(),
674
+ () => {
675
+ // Server rows only open submenus; nothing changes at this level
676
+ },
677
+ () => {
678
+ options.done();
679
+ },
680
+ );
681
+ return serverList;
682
+ };