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