pi-llama-cpp 0.13.0 → 0.15.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 (77) hide show
  1. package/README.md +19 -18
  2. package/package.json +3 -3
  3. package/src/api/client.ts +25 -0
  4. package/src/constants.ts +2 -2
  5. package/src/enums/status.ts +0 -1
  6. package/src/index.ts +2 -1
  7. package/src/interfaces/endpoints/models.ts +1 -1
  8. package/src/interfaces/settings.ts +7 -1
  9. package/src/interfaces/sortBy.ts +4 -0
  10. package/src/managers/command/models.ts +254 -0
  11. package/src/managers/command.ts +34 -461
  12. package/src/managers/events.ts +2 -2
  13. package/src/managers/server.ts +34 -18
  14. package/src/managers/settings.ts +26 -90
  15. package/src/models/baseModel.ts +27 -10
  16. package/src/models/legacyModel.ts +2 -2
  17. package/src/models/routerModel.ts +2 -2
  18. package/src/models/singleModel.ts +1 -1
  19. package/src/server.ts +17 -38
  20. package/src/sse/client.ts +113 -59
  21. package/src/sse/fetch.ts +43 -0
  22. package/src/sse/manager.ts +9 -27
  23. package/src/sse/types.ts +0 -4
  24. package/src/ui/dialog/base.ts +118 -0
  25. package/src/ui/dialog/confirm.ts +45 -0
  26. package/src/ui/dialog/factory.ts +111 -0
  27. package/src/ui/dialog/input.ts +63 -0
  28. package/src/ui/dialog/options.ts +23 -0
  29. package/src/ui/editors/editorOptions.ts +50 -0
  30. package/src/ui/editors/itemBuilder.ts +43 -0
  31. package/src/ui/editors/listEditor.ts +300 -0
  32. package/src/ui/editors/override/entry.ts +24 -0
  33. package/src/ui/editors/override/entryEditor.ts +248 -0
  34. package/src/ui/editors/override/fields/base.ts +53 -0
  35. package/src/ui/editors/override/fields/capabilities.ts +36 -0
  36. package/src/ui/editors/override/fields/cost.ts +64 -0
  37. package/src/ui/editors/override/fields/index.ts +68 -0
  38. package/src/ui/editors/override/fields/numeric.ts +55 -0
  39. package/src/ui/editors/override/fields/pattern.ts +24 -0
  40. package/src/ui/editors/override/fields/reasoning.ts +33 -0
  41. package/src/ui/editors/override/itemBuilder.ts +39 -0
  42. package/src/ui/editors/override/overrideList.ts +127 -0
  43. package/src/ui/editors/server/builder.ts +60 -0
  44. package/src/ui/editors/server/fields.ts +84 -0
  45. package/src/ui/editors/server/itemBuilder.ts +64 -0
  46. package/src/ui/editors/server/serverEditor.ts +197 -0
  47. package/src/ui/editors/server/utils.ts +95 -0
  48. package/src/ui/editors/server/wizard.ts +110 -0
  49. package/src/ui/editors/settingField.ts +37 -0
  50. package/src/ui/editors/settingsListFactory.ts +33 -0
  51. package/src/ui/settings/index.ts +248 -0
  52. package/src/ui/strings.ts +25 -4
  53. package/src/utils/health.ts +2 -1
  54. package/src/utils/serverIds.ts +21 -0
  55. package/src/utils/settingsStore.ts +1 -1
  56. package/src/utils/urlResolver.ts +129 -0
  57. package/src/utils/urls.ts +33 -13
  58. package/tests/{commandManager.test.ts → command/commandManager.test.ts} +88 -12
  59. package/tests/{events.test.ts → events/events.test.ts} +6 -6
  60. package/tests/mocks.ts +2 -0
  61. package/tests/models/legacyModel.test.ts +103 -0
  62. package/tests/{routerModel.test.ts → models/routerModel.test.ts} +71 -72
  63. package/tests/{singleModel.test.ts → models/singleModel.test.ts} +17 -17
  64. package/tests/{health.test.ts → server/health.test.ts} +2 -2
  65. package/tests/{server.test.ts → server/server.test.ts} +5 -17
  66. package/tests/{serverManager.test.ts → server/serverManager.test.ts} +4 -4
  67. package/tests/{settings.test.ts → settings/settings.test.ts} +184 -141
  68. package/tests/{settingsStore.test.ts → settings/settingsStore.test.ts} +1 -1
  69. package/tests/{sseManager.test.ts → sse/sseManager.test.ts} +8 -26
  70. package/tests/{dialog.test.ts → ui/dialog.test.ts} +56 -4
  71. package/tests/{overrides.test.ts → ui/overrides.test.ts} +101 -111
  72. package/src/ui/dialog.ts +0 -290
  73. package/src/ui/overrideEntryEditor.ts +0 -119
  74. package/src/ui/overrideSettingsList.ts +0 -710
  75. package/src/ui/serverListEditor.ts +0 -59
  76. package/src/ui/serverSettingsList.ts +0 -513
  77. package/tests/legacyModel.test.ts +0 -97
@@ -0,0 +1,300 @@
1
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import { SettingsList } from "@earendil-works/pi-tui";
4
+ import type { LlamaServer } from "../../interfaces/settings";
5
+ import { BaseDialog } from "../dialog/base";
6
+ import { DialogFactory } from "../dialog/factory";
7
+ import { HINTS } from "../strings";
8
+ import type { SettingsListEditorOptions } from "./editorOptions";
9
+
10
+ // ─── Abstract base class ──────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Base class for SettingsList-based editors that manage a list of servers
14
+ * with add/delete support at the top level.
15
+ *
16
+ * Subclasses must implement:
17
+ * - {@link buildSettingsList} — how to build the top-level list items
18
+ * - {@link beginAdd} — how to start the add wizard
19
+ * - {@link deleteSelected} — how to delete the selected entry
20
+ * - {@link getRowLabel} — display name for a row at the given index
21
+ * - {@link emptyHintKey} — which hint to show when the list is empty
22
+ * - {@link deleteTitle} — dialog title for the delete confirmation
23
+ */
24
+ export abstract class ListEditor<
25
+ O extends SettingsListEditorOptions = SettingsListEditorOptions,
26
+ > implements Component {
27
+ protected settingsList: SettingsList | null = null;
28
+ protected isFocused = false;
29
+ /** The currently open modal dialog (add wizard, input, or confirm),
30
+ * when one is up: input/render/invalidate/focus all delegate to it
31
+ * until it is cleared via {@link closeDialog}. */
32
+ protected activeDialog: BaseDialog | undefined;
33
+ /** Dialog factory holding `theme`/`tui` once for all dialog construction. */
34
+ protected readonly dialogs: DialogFactory;
35
+ /** Whether a row's field submenu is open. While open, input is delegated
36
+ * to the settingsList and list rebuilds are deferred: rebuilding would
37
+ * discard the submenu and kick the user back to the list. */
38
+ protected submenuOpen = false;
39
+ /** Row index of a rebuild deferred by {@link commitFieldChange} while a
40
+ * submenu was open; flushed by {@link trackSubmenu} on close. */
41
+ private deferredRebuild: number | null = null;
42
+ /** Selected row index. Mirrors the `SettingsList`'s selection, which is
43
+ * `private` in pi-tui's typings and thus unreachable — so this copy is
44
+ * advanced by the same wrap-around logic the list applies internally
45
+ * (both handle the same up/down keypress). Kept in sync explicitly in
46
+ * {@link rebuildList} via `selectItem`. */
47
+ protected selectedIndex = 0;
48
+
49
+ constructor(protected readonly options: O) {
50
+ this.dialogs = new DialogFactory(options.theme, options.tui);
51
+ }
52
+
53
+ // -- abstract hooks -------------------------------------------------------
54
+
55
+ /** Build the top-level SettingsList from the current data snapshot. */
56
+ protected abstract buildSettingsList(): SettingsList | Promise<SettingsList>;
57
+
58
+ /** Start the add wizard (called when user presses "a"). */
59
+ protected abstract beginAdd(): void;
60
+
61
+ /** Delete the currently selected entry (called after confirmation). */
62
+ protected abstract deleteSelected(): void;
63
+
64
+ /** Hint line key for the empty-list state. */
65
+ protected abstract readonly emptyHintKey:
66
+ | "emptyServers"
67
+ | "emptyOverrideEntries";
68
+
69
+ /** Returns the id for a row at the given index. */
70
+ protected abstract getRowId(index: number): string;
71
+
72
+ /** Returns the display label for a row at the given index. */
73
+ protected abstract getRowLabel(index: number): string;
74
+
75
+ /** Title for the delete confirmation dialog (e.g. "Delete server"). */
76
+ protected abstract get deleteTitle(): string;
77
+
78
+ /** Returns the number of items in the current list. Override when the
79
+ * list items don't directly correspond to `options.servers`. */
80
+ protected getCount(): number {
81
+ return this.options.servers.length;
82
+ }
83
+
84
+ // -- hooks -----------------------------------------------------------------
85
+
86
+ /**
87
+ * Closes this editor on Esc. The base implementation ends the whole
88
+ * dialog (`options.done`) — correct for top-level editors. Drill-down
89
+ * subclasses that live inside another editor's submenu override this
90
+ * to step back one level instead (their `options.done` still points
91
+ * at the top-level close inherited through the shared options).
92
+ */
93
+ protected close(): void {
94
+ this.options.done();
95
+ }
96
+
97
+ // -- Focusable ------------------------------------------------------------
98
+
99
+ get focused(): boolean {
100
+ return this.isFocused;
101
+ }
102
+
103
+ set focused(value: boolean) {
104
+ this.isFocused = value;
105
+ if (this.activeDialog) this.activeDialog.focused = value;
106
+ }
107
+
108
+ // -- Component ------------------------------------------------------------
109
+
110
+ invalidate(): void {
111
+ this.settingsList?.invalidate();
112
+ this.activeDialog?.invalidate();
113
+ }
114
+
115
+ handleInput(data: string): void {
116
+ // Delegate to the open modal dialog, if any
117
+ if (this.delegateToDialogInput(data)) return;
118
+
119
+ // Delegate to settingsList if a submenu is open
120
+ if (this.submenuOpen && this.settingsList) {
121
+ this.settingsList.handleInput(data);
122
+ return;
123
+ }
124
+
125
+ // Top-level keybindings. Up/down mirror the wrap-around the
126
+ // `SettingsList` applies internally to the same keypress (its
127
+ // selection is private — see the {@link selectedIndex} doc); the
128
+ // trailing forward to `settingsList.handleInput` is what actually
129
+ // moves the rendered cursor.
130
+ const kb = this.options.keybindings;
131
+ if (kb.matches(data, "tui.select.cancel")) {
132
+ this.close();
133
+ return;
134
+ }
135
+ if (kb.matches(data, "tui.select.up")) {
136
+ this.selectedIndex =
137
+ this.selectedIndex === 0 ? this.getCount() - 1 : this.selectedIndex - 1;
138
+ }
139
+ if (kb.matches(data, "tui.select.down")) {
140
+ this.selectedIndex =
141
+ this.selectedIndex === this.getCount() - 1 ? 0 : this.selectedIndex + 1;
142
+ }
143
+ if (data === "a") {
144
+ this.beginAdd();
145
+ return;
146
+ }
147
+ if (data === "d") {
148
+ this.beginConfirm();
149
+ return;
150
+ }
151
+ if (this.settingsList) {
152
+ this.settingsList.handleInput(data);
153
+ }
154
+ }
155
+
156
+ render(width: number): string[] {
157
+ // Delegate to the open modal dialog, if any
158
+ if (this.activeDialog) return this.activeDialog.render(width);
159
+
160
+ // Empty list hint
161
+ if (!this.settingsList) return ["Loading..."];
162
+ const lines = this.settingsList.render(width);
163
+ if (this.getCount() === 0) {
164
+ lines[lines.length - 1] = getSettingsListTheme().hint(
165
+ HINTS[this.emptyHintKey],
166
+ );
167
+ }
168
+ return lines;
169
+ }
170
+
171
+ // -- helpers ---------------------------------------------------------------
172
+
173
+ /**
174
+ * If a dialog is open, delegates input to it and returns true;
175
+ * otherwise returns false. Used to short-circuit handleInput.
176
+ */
177
+ private delegateToDialogInput(data: string): boolean {
178
+ if (this.activeDialog) {
179
+ this.activeDialog.handleInput(data);
180
+ return true;
181
+ }
182
+ return false;
183
+ }
184
+
185
+ /**
186
+ * Rebuilds the list and places the cursor on `targetIndex`.
187
+ * Subclasses call this after add/delete/persist to refresh the UI.
188
+ */
189
+ protected async rebuildList(targetIndex: number): Promise<void> {
190
+ const list = await this.buildSettingsList();
191
+ this.settingsList = list;
192
+ const count = this.getCount();
193
+ if (count === 0) {
194
+ this.selectedIndex = 0;
195
+ this.options.tui.requestRender();
196
+ return;
197
+ }
198
+ this.selectedIndex = Math.min(targetIndex, count - 1);
199
+ this.settingsList.selectItem(this.getRowId(this.selectedIndex));
200
+ // Rebuilding is async (health checks); request a render only once the
201
+ // new list is in place, otherwise the old frame stays on screen
202
+ this.options.tui.requestRender();
203
+ }
204
+
205
+ /**
206
+ * Tracks a row's field submenu opening or closing. While a submenu is
207
+ * open, {@link rebuildList} would discard it and kick the user back to
208
+ * the list — so a rebuild deferred by {@link commitFieldChange} is
209
+ * flushed here on close instead of running mid-edit.
210
+ */
211
+ protected trackSubmenu(open: boolean): void {
212
+ this.submenuOpen = open;
213
+ if (open) return;
214
+ const index = this.deferredRebuild;
215
+ this.deferredRebuild = null;
216
+ if (index !== null) void this.rebuildList(index);
217
+ }
218
+
219
+ /**
220
+ * Opens a modal dialog: input/render delegate to it until
221
+ * {@link closeDialog}. Focus follows the editor's.
222
+ */
223
+ protected openDialog(dialog: BaseDialog): void {
224
+ this.activeDialog = dialog;
225
+ dialog.focused = this.isFocused;
226
+ this.options.tui.requestRender();
227
+ }
228
+
229
+ /** Closes the open modal dialog and returns to the list. */
230
+ protected closeDialog(): void {
231
+ this.activeDialog = undefined;
232
+ this.options.tui.requestRender();
233
+ }
234
+
235
+ /**
236
+ * Opens the delete confirmation dialog.
237
+ */
238
+ protected beginConfirm(): void {
239
+ const idx = this.selectedIndex;
240
+ const name = this.getRowLabel(idx);
241
+ if (!name) return;
242
+
243
+ this.openDialog(
244
+ this.dialogs.confirmDelete(
245
+ this.deleteTitle,
246
+ name,
247
+ () => {
248
+ this.closeDialog();
249
+ void this.deleteSelected();
250
+ },
251
+ () => this.closeDialog(),
252
+ ),
253
+ );
254
+ }
255
+
256
+ /**
257
+ * Persists `next`; on success adopts it and runs `onSuccess`;
258
+ * on failure notifies and stays in list mode.
259
+ *
260
+ * Callers must close any open dialog *before* the async persist:
261
+ * render() returns the list once `activeDialog` is cleared, so
262
+ * leaving the dialog open would blank the screen mid-save.
263
+ */
264
+ protected async persistSnapshot(
265
+ next: LlamaServer[],
266
+ onSuccess: () => void,
267
+ ): Promise<void> {
268
+ try {
269
+ await this.options.persist(next);
270
+ } catch (err) {
271
+ this.options.onError(String(err));
272
+ this.options.tui.requestRender();
273
+ return;
274
+ }
275
+ this.options.servers = next;
276
+ onSuccess();
277
+ }
278
+
279
+ /**
280
+ * Handles a field commit from an open row submenu: persists `next` and,
281
+ * on success, refreshes the open submenu UI in place via `refresh`, then
282
+ * defers the full list rebuild of row `deferIndex` until the submenu
283
+ * closes (rebuilding now would discard the submenu and kick the user
284
+ * back to the list — see {@link trackSubmenu}). No-op when
285
+ * `next` is the current list (unchanged, callers compare by reference).
286
+ */
287
+ protected commitFieldChange(
288
+ next: LlamaServer[],
289
+ deferIndex: number | null,
290
+ refresh: () => void,
291
+ ): void {
292
+ if (next === this.options.servers) return;
293
+ void this.persistSnapshot(next, () => {
294
+ refresh();
295
+ if (deferIndex !== null && this.submenuOpen)
296
+ this.deferredRebuild = deferIndex;
297
+ this.options.tui.requestRender();
298
+ });
299
+ }
300
+ }
@@ -0,0 +1,24 @@
1
+ import type { LlamaServer, ModelOverride } from "../../../interfaces/settings";
2
+
3
+ /**
4
+ * One override entry: the pattern key and its override object. Owns the
5
+ * canonical construction from a server's overrides map, so the editor
6
+ * and the mutator share a single entry representation. Row rendering
7
+ * (summary) is registry-driven — see `OverrideSummary`.
8
+ */
9
+ export class OverrideEntry {
10
+ constructor(
11
+ readonly pattern: string,
12
+ readonly override: ModelOverride,
13
+ ) {}
14
+
15
+ /**
16
+ * Entries of a server's overrides map, in map iteration order (the
17
+ * order the entry list renders and mutates by).
18
+ */
19
+ static listFrom(server: LlamaServer | undefined): OverrideEntry[] {
20
+ return Object.entries(server?.overrides ?? {}).map(
21
+ ([pattern, override]) => new OverrideEntry(pattern, override),
22
+ );
23
+ }
24
+ }
@@ -0,0 +1,248 @@
1
+ import type { SettingItem, SettingsList } from "@earendil-works/pi-tui";
2
+ import type { LlamaServer, ModelOverride } from "../../../interfaces/settings";
3
+ import { FIELDS, FieldMessages, HINTS, TITLES } from "../../strings";
4
+ import type { OverrideSettingsListOptions } from "../editorOptions";
5
+ import { ListEditor } from "../listEditor";
6
+ import { SettingsListFactory } from "../settingsListFactory";
7
+ import { OverrideEntry } from "./entry";
8
+ import { OverrideFields, OverrideSummary } from "./fields/index";
9
+ import { OverrideItemBuilder } from "./itemBuilder";
10
+
11
+ /**
12
+ * The override entries of a single server, as a list view with add/delete.
13
+ */
14
+ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOptions> {
15
+ /** Currently open field submenu, so commits can refresh its rows */
16
+ private fieldList: SettingsList | null = null;
17
+
18
+ constructor(
19
+ /** Shared options object — `persistSnapshot` updates `options.servers`
20
+ * in place so later drill-downs (and sibling editors) see the change */
21
+ options: OverrideSettingsListOptions,
22
+ private readonly serverIndex: number,
23
+ /** Closes this editor (called on Esc in the entry list) */
24
+ private readonly done: () => void,
25
+ ) {
26
+ super(options);
27
+ this.settingsList = this.buildSettingsList();
28
+ }
29
+
30
+ // -- hooks ---------------------------------------------------------------
31
+
32
+ /** Esc steps back to the server list, not out of the whole dialog —
33
+ * `options.done` is the top-level close (shared options object). */
34
+ protected close(): void {
35
+ this.done();
36
+ }
37
+
38
+ // -- abstract hooks -------------------------------------------------------
39
+
40
+ protected buildSettingsList(): SettingsList {
41
+ const builder = new OverrideItemBuilder(this.dialogs);
42
+ const items: SettingItem[] = this.entries().map((entry, i) => ({
43
+ id: `entry-${i}`,
44
+ label: entry.pattern,
45
+ description: HINTS.overrideEntryRow,
46
+ currentValue: OverrideSummary.of(entry.override),
47
+ submenu: (_cv, done) => {
48
+ const current = this.entries()[i] ?? entry;
49
+ const fieldItems = builder.buildFieldItems(current);
50
+ const fieldList = SettingsListFactory.create(
51
+ fieldItems,
52
+ () => {
53
+ this.fieldList = null;
54
+ done();
55
+ // Flushes any rebuild deferred by a pattern rename
56
+ // (see commitFieldChange)
57
+ this.trackSubmenu(false);
58
+ },
59
+ (field, value) => void this.handleFieldChange(i, field, value),
60
+ );
61
+ this.fieldList = fieldList;
62
+ this.trackSubmenu(true);
63
+ return fieldList;
64
+ },
65
+ }));
66
+
67
+ return SettingsListFactory.create(items, () => {
68
+ this.done();
69
+ });
70
+ }
71
+
72
+ /** Handle a field commit from a row's submenu. */
73
+ private handleFieldChange(
74
+ entryIndex: number,
75
+ field: string,
76
+ value: string,
77
+ ): void {
78
+ const result = this.applyFieldChange(entryIndex, field, value);
79
+ if (!result) return;
80
+
81
+ this.commitFieldChange(
82
+ result.next,
83
+ // The entry row's label is the pattern, and SettingsList labels
84
+ // can't be patched in place — defer the rebuild until the submenu
85
+ // closes so the user stays inside it (see commitFieldChange)
86
+ field === "pattern" ? entryIndex : null,
87
+ () => {
88
+ // Refresh the entry row's summary in place
89
+ this.settingsList?.updateValue(
90
+ `entry-${entryIndex}`,
91
+ OverrideSummary.of(result.updatedOverride),
92
+ );
93
+ // Refresh the committed row in the open field submenu. Passing
94
+ // `value` as the pattern makes the pattern field display the new
95
+ // pattern; other fields only read the override.
96
+ this.fieldList?.updateValue(
97
+ field,
98
+ OverrideFields.byId(field).displayValue(
99
+ new OverrideEntry(value, result.updatedOverride),
100
+ ),
101
+ );
102
+ },
103
+ );
104
+ }
105
+
106
+ protected beginAdd(): void {
107
+ this.openDialog(
108
+ this.dialogs.input({
109
+ title: TITLES.addOverride,
110
+ message: FieldMessages.of(FIELDS.pattern),
111
+ placeholder: FIELDS.pattern.placeholder,
112
+ validate: OverrideFields.byId("pattern").validate,
113
+ onSubmit: (value) => {
114
+ this.closeDialog();
115
+ void this.saveAdd(value);
116
+ },
117
+ onCancel: () => this.closeDialog(),
118
+ }),
119
+ );
120
+ }
121
+
122
+ protected deleteSelected(): void {
123
+ const idx = this.selectedIndex;
124
+ const next = this.removeEntry(idx);
125
+ void this.persistSnapshot(next, () => {
126
+ this.rebuildList(idx);
127
+ this.options.onChanged?.();
128
+ this.options.tui.requestRender();
129
+ });
130
+ }
131
+
132
+ protected readonly emptyHintKey = "emptyOverrideEntries" as const;
133
+
134
+ protected getCount(): number {
135
+ return this.entries().length;
136
+ }
137
+
138
+ protected getRowId(index: number): string {
139
+ return `entry-${index}`;
140
+ }
141
+
142
+ protected getRowLabel(index: number): string {
143
+ return this.entries()[index]?.pattern ?? "";
144
+ }
145
+
146
+ protected get deleteTitle(): string {
147
+ return TITLES.deleteOverride;
148
+ }
149
+
150
+ // -- helpers ---------------------------------------------------------------
151
+
152
+ /** The server's override entries, in map iteration order. */
153
+ private entries(): OverrideEntry[] {
154
+ return OverrideEntry.listFrom(this.options.servers[this.serverIndex]);
155
+ }
156
+
157
+ /** Handles a field commit from an override entry's submenu. Returns
158
+ * `null` when the entry doesn't exist (stale index — no-op). */
159
+ private applyFieldChange(
160
+ entryIndex: number,
161
+ field: string,
162
+ value: string,
163
+ ): { next: LlamaServer[]; updatedOverride: ModelOverride } | null {
164
+ const entry = this.entries()[entryIndex];
165
+ if (!entry) return null;
166
+
167
+ // Pattern field: rename the pattern key, keep the override object
168
+ if (field === "pattern") {
169
+ return {
170
+ next: this.updateEntry(entryIndex, value, entry.override),
171
+ updatedOverride: entry.override,
172
+ };
173
+ }
174
+
175
+ // Other fields: let the field definition apply itself
176
+ const updatedOverride: ModelOverride = { ...entry.override };
177
+ OverrideFields.byId(field).apply(updatedOverride, value);
178
+
179
+ return {
180
+ next: this.updateEntry(entryIndex, entry.pattern, updatedOverride),
181
+ updatedOverride,
182
+ };
183
+ }
184
+
185
+ /** Adds a new override entry with a uniquified pattern. */
186
+ private addEntry(pattern: string): LlamaServer[] {
187
+ const server = this.options.servers[this.serverIndex];
188
+ if (!server) return this.options.servers;
189
+
190
+ const existing = new Set(Object.keys(server.overrides ?? {}));
191
+ let uniquePattern = pattern;
192
+ let n = 2;
193
+ while (existing.has(uniquePattern)) {
194
+ uniquePattern = `${pattern}-${n++}`;
195
+ }
196
+
197
+ return this.withServer((s) => ({
198
+ ...s,
199
+ overrides: { ...s.overrides, [uniquePattern]: {} },
200
+ }));
201
+ }
202
+
203
+ /** Removes the entry at `entryIndex`. */
204
+ private removeEntry(entryIndex: number): LlamaServer[] {
205
+ return this.withServer((s) => ({
206
+ ...s,
207
+ overrides: Object.fromEntries(
208
+ Object.entries(s.overrides ?? {}).filter((_, j) => j !== entryIndex),
209
+ ),
210
+ }));
211
+ }
212
+
213
+ /** Returns the servers with the target server replaced by
214
+ * `fn(server)`. Immutable. */
215
+ private withServer(fn: (server: LlamaServer) => LlamaServer): LlamaServer[] {
216
+ return this.options.servers.map((server, i) =>
217
+ i === this.serverIndex ? fn(server) : server,
218
+ );
219
+ }
220
+
221
+ /** Replaces the entry at `entryIndex` with `pattern → override` in a
222
+ * single mutation — used both for renaming the pattern and for editing
223
+ * its fields. The entry keeps its position in the map's iteration
224
+ * order. Immutable. */
225
+ private updateEntry(
226
+ entryIndex: number,
227
+ pattern: string,
228
+ override: ModelOverride,
229
+ ): LlamaServer[] {
230
+ return this.withServer((s) => ({
231
+ ...s,
232
+ overrides: Object.fromEntries(
233
+ Object.entries(s.overrides ?? {}).map(([k, v], j) =>
234
+ j === entryIndex ? [pattern, override] : [k, v],
235
+ ),
236
+ ),
237
+ }));
238
+ }
239
+
240
+ private async saveAdd(pattern: string): Promise<void> {
241
+ const next = this.addEntry(pattern);
242
+ await this.persistSnapshot(next, () => {
243
+ this.rebuildList(this.entries().length - 1);
244
+ this.options.onChanged?.();
245
+ this.options.tui.requestRender();
246
+ });
247
+ }
248
+ }
@@ -0,0 +1,53 @@
1
+ import type { ModelOverride } from "../../../../interfaces/settings";
2
+ import { SettingField } from "../../settingField";
3
+ import { OverrideEntry } from "../entry";
4
+
5
+ /** Type of a field: "input" for free-text, "finite" for fixed options */
6
+ export type FieldType = "input" | "finite";
7
+
8
+ /** Validation function for input fields */
9
+ export type InputValidator = (raw: string) => string | null;
10
+
11
+ /**
12
+ * Abstract base class for one editable field of a model override entry
13
+ * (Template Method): each subclass owns its display format, validation
14
+ * and apply logic in a single place, so the parse/format round-trips
15
+ * can't drift. Extends the shared {@link SettingField} base (id, label,
16
+ * description, placeholder from the FIELDS registry).
17
+ */
18
+ export abstract class OverrideField extends SettingField<OverrideEntry> {
19
+ /** Field type: "input" for free-text, "finite" for fixed options. */
20
+ abstract readonly type: FieldType;
21
+
22
+ /**
23
+ * Canonical display label for this field — the single source of truth
24
+ * for both the field rows' `currentValue` and the post-commit refresh
25
+ * of an open field submenu, so the two can't drift.
26
+ */
27
+ abstract displayValue(entry: OverrideEntry): string;
28
+
29
+ /** Current value shown in the row — defaults to `displayValue`. */
30
+ currentValue(entry: OverrideEntry): string {
31
+ return this.displayValue(entry);
32
+ }
33
+
34
+ /** For "input" fields: validation function for committed values. */
35
+ abstract readonly validate: InputValidator;
36
+
37
+ /** For "finite" fields: the fixed options (undefined for "input"). */
38
+ readonly options: readonly string[] | undefined = undefined;
39
+
40
+ /** Applies a committed value to the override object. The pattern key
41
+ * rename is handled separately by the mutator (default: no-op). */
42
+ apply(_override: ModelOverride, _value: string): void {
43
+ // no-op — override in subclasses that need to mutate the override
44
+ }
45
+
46
+ /** One `term: value` part of the entry-row summary (see
47
+ * {@link OverrideSummary.of}), or `null` when the field shouldn't
48
+ * appear (unset/zero values — each field decides). The pattern is the
49
+ * row label, so it never contributes a part; the default is `null`. */
50
+ summaryPart(_override: ModelOverride): string | null {
51
+ return null;
52
+ }
53
+ }
@@ -0,0 +1,36 @@
1
+ import type { ModelOverride } from "../../../../interfaces/settings";
2
+ import { FIELDS } from "../../../strings";
3
+ import { OverrideEntry } from "../entry";
4
+ import type { InputValidator } from "./base";
5
+ import { OverrideField } from "./base";
6
+
7
+ /** The override's `capabilities` list (finite: fixed label options). */
8
+ export class CapabilitiesField extends OverrideField {
9
+ readonly id = "capabilities";
10
+ readonly type = "finite";
11
+ readonly field = FIELDS.capabilities;
12
+ override readonly options = ["text", "text | image"];
13
+
14
+ displayValue(entry: OverrideEntry): string {
15
+ return entry.override.capabilities?.join(" | ") ?? "text";
16
+ }
17
+
18
+ readonly validate: InputValidator = (raw) =>
19
+ this.options?.includes(raw) ? raw : null;
20
+
21
+ apply(override: ModelOverride, value: string): void {
22
+ override.capabilities =
23
+ value === "text | image"
24
+ ? ["text", "image"]
25
+ : value === "text"
26
+ ? ["text"]
27
+ : undefined;
28
+ }
29
+
30
+ /** Appears in the summary only when non-empty. */
31
+ override summaryPart(override: ModelOverride): string | null {
32
+ return override.capabilities?.length
33
+ ? `capabilities: ${override.capabilities.join(",")}`
34
+ : null;
35
+ }
36
+ }