pi-llama-cpp 0.12.0 → 0.14.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 (68) hide show
  1. package/README.md +20 -9
  2. package/package.json +3 -3
  3. package/src/api/client.ts +25 -0
  4. package/src/constants.ts +8 -5
  5. package/src/enums/status.ts +0 -1
  6. package/src/interfaces/endpoints/models.ts +1 -1
  7. package/src/interfaces/settings.ts +3 -2
  8. package/src/interfaces/sortBy.ts +4 -0
  9. package/src/managers/command/models.ts +247 -0
  10. package/src/managers/command.ts +29 -459
  11. package/src/managers/events.ts +2 -2
  12. package/src/managers/server.ts +29 -5
  13. package/src/managers/settings.ts +12 -88
  14. package/src/models/baseModel.ts +27 -10
  15. package/src/models/legacyModel.ts +2 -2
  16. package/src/models/routerModel.ts +2 -2
  17. package/src/models/singleModel.ts +1 -1
  18. package/src/server.ts +35 -48
  19. package/src/sse/client.ts +113 -59
  20. package/src/sse/fetch.ts +43 -0
  21. package/src/sse/manager.ts +9 -27
  22. package/src/sse/types.ts +0 -4
  23. package/src/ui/dialog/base.ts +118 -0
  24. package/src/ui/dialog/confirm.ts +45 -0
  25. package/src/ui/dialog/factory.ts +111 -0
  26. package/src/ui/dialog/input.ts +63 -0
  27. package/src/ui/dialog/options.ts +23 -0
  28. package/src/ui/editors/editorOptions.ts +43 -0
  29. package/src/ui/editors/itemBuilder.ts +47 -0
  30. package/src/ui/editors/listEditor.ts +291 -0
  31. package/src/ui/editors/override/entry.ts +24 -0
  32. package/src/ui/editors/override/entryEditor.ts +166 -0
  33. package/src/ui/editors/override/fields.ts +294 -0
  34. package/src/ui/editors/override/handlers.ts +118 -0
  35. package/src/ui/editors/override/itemBuilder.ts +42 -0
  36. package/src/ui/editors/override/overrideList.ts +127 -0
  37. package/src/ui/editors/server/builder.ts +60 -0
  38. package/src/ui/editors/server/fields.ts +84 -0
  39. package/src/ui/editors/server/handlers.ts +32 -0
  40. package/src/ui/editors/server/itemBuilder.ts +68 -0
  41. package/src/ui/editors/server/serverEditor.ts +161 -0
  42. package/src/ui/editors/server/utils.ts +45 -0
  43. package/src/ui/editors/server/wizard.ts +110 -0
  44. package/src/ui/editors/settingField.ts +37 -0
  45. package/src/ui/editors/settingsListFactory.ts +33 -0
  46. package/src/ui/settings/index.ts +237 -0
  47. package/src/ui/strings.ts +8 -4
  48. package/src/utils/health.ts +48 -0
  49. package/src/utils/serverIds.ts +21 -0
  50. package/src/utils/settingsStore.ts +1 -1
  51. package/src/utils/urlResolver.ts +129 -0
  52. package/src/utils/urls.ts +33 -13
  53. package/tests/commandManager.test.ts +38 -7
  54. package/tests/dialog.test.ts +95 -2
  55. package/tests/health.test.ts +116 -0
  56. package/tests/legacyModel.test.ts +34 -28
  57. package/tests/overrides.test.ts +123 -68
  58. package/tests/routerModel.test.ts +67 -68
  59. package/tests/server.test.ts +47 -16
  60. package/tests/serverManager.test.ts +4 -4
  61. package/tests/settings.test.ts +10 -8
  62. package/tests/singleModel.test.ts +12 -12
  63. package/tests/sseManager.test.ts +6 -24
  64. package/src/ui/dialog.ts +0 -287
  65. package/src/ui/overrideEntryEditor.ts +0 -119
  66. package/src/ui/overrideSettingsList.ts +0 -682
  67. package/src/ui/serverListEditor.ts +0 -32
  68. package/src/ui/serverSettingsList.ts +0 -466
@@ -0,0 +1,294 @@
1
+ import type { ModelCost, ModelCostRates } from "@earendil-works/pi-ai";
2
+ import type { ModelOverride } from "../../../interfaces/settings";
3
+ import { FIELDS, type Field } from "../../strings";
4
+ import { SettingField } from "../settingField";
5
+ import { OverrideEntry } from "./entry";
6
+
7
+ /** Type of a field: "input" for free-text, "finite" for fixed options */
8
+ export type FieldType = "input" | "finite";
9
+
10
+ /** Validation function for input fields */
11
+ export type InputValidator = (raw: string) => string | null;
12
+
13
+ /**
14
+ * Abstract base class for one editable field of a model override entry
15
+ * (Template Method): each subclass owns its display format, validation
16
+ * and apply logic in a single place, so the parse/format round-trips
17
+ * can't drift. Extends the shared {@link SettingField} base (id, label,
18
+ * description, placeholder from the FIELDS registry).
19
+ */
20
+ export abstract class OverrideField extends SettingField<OverrideEntry> {
21
+ /** Field type: "input" for free-text, "finite" for fixed options. */
22
+ abstract readonly type: FieldType;
23
+
24
+ /**
25
+ * Canonical display label for this field — the single source of truth
26
+ * for both the field rows' `currentValue` and the post-commit refresh
27
+ * of an open field submenu, so the two can't drift.
28
+ */
29
+ abstract displayValue(entry: OverrideEntry): string;
30
+
31
+ /** Current value shown in the row — same rendering as the post-commit
32
+ * refresh of an open field submenu, so the two can't drift. */
33
+ currentValue(entry: OverrideEntry): string {
34
+ return this.displayValue(entry);
35
+ }
36
+
37
+ /** For "input" fields: validation function for committed values. */
38
+ abstract readonly validate: InputValidator;
39
+
40
+ /** For "finite" fields: the fixed options (undefined for "input"). */
41
+ readonly options: readonly string[] | undefined = undefined;
42
+
43
+ /** Applies a committed value to the override object. The pattern key
44
+ * rename is handled separately by the mutator. */
45
+ abstract apply(override: ModelOverride, value: string): void;
46
+
47
+ /** One `term: value` part of the entry-row summary (see
48
+ * {@link OverrideSummary.of}), or `null` when the field shouldn't
49
+ * appear (unset/zero values — each field decides). The pattern is the
50
+ * row label, so it never contributes a part; the default is `null`. */
51
+ summaryPart(_override: ModelOverride): string | null {
52
+ return null;
53
+ }
54
+
55
+ /** Parses a non-negative number; blank input means zero (unset fields
56
+ * default to zero at read time). Returns `null` when invalid. Shared
57
+ * by the cost and numeric fields so their validation can't drift. */
58
+ protected static parseNonNegative(raw: string): number | null {
59
+ const n = Number(raw.trim());
60
+ return isFinite(n) && n >= 0 ? n : null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The entry's pattern key. The rename itself is handled by the mutator
66
+ * (`OverrideEntryMutator`); {@link apply} is a no-op.
67
+ */
68
+ class PatternField extends OverrideField {
69
+ readonly id = "pattern";
70
+ readonly type = "input";
71
+ readonly field = FIELDS.pattern;
72
+
73
+ displayValue(entry: OverrideEntry): string {
74
+ return entry.pattern;
75
+ }
76
+
77
+ readonly validate: InputValidator = (raw) => {
78
+ const trimmed = raw.trim();
79
+ return trimmed.length > 0 ? trimmed : null;
80
+ };
81
+
82
+ apply(): void {
83
+ // The pattern IS the map key — renames go through the mutator.
84
+ }
85
+ }
86
+
87
+ /**
88
+ * One `ModelCostRates` key of the override's `cost` object (absorbs the
89
+ * former `CostFieldDefFactory` and the standalone `parseCostValue` /
90
+ * `applyCostFieldValue` helpers): owns the cost parse and immutable
91
+ * apply in the same place as its display format.
92
+ */
93
+ class CostField extends OverrideField {
94
+ readonly type = "input";
95
+ override readonly field: Field;
96
+
97
+ constructor(
98
+ readonly id: string,
99
+ private readonly key: keyof ModelCostRates,
100
+ field: Field,
101
+ /** Short term used in the entry-row summary (e.g. `cache read`). */
102
+ private readonly summaryTerm: string,
103
+ ) {
104
+ super();
105
+ this.field = field;
106
+ }
107
+
108
+ displayValue(entry: OverrideEntry): string {
109
+ return String(entry.override.cost?.[this.key] ?? 0);
110
+ }
111
+
112
+ readonly validate: InputValidator = (raw) => {
113
+ const parsed = OverrideField.parseNonNegative(raw);
114
+ return parsed === null ? null : String(parsed);
115
+ };
116
+
117
+ apply(override: ModelOverride, value: string): void {
118
+ const nextCost = this.applyCost(override.cost, Number(value));
119
+ if (nextCost) override.cost = nextCost;
120
+ else delete override.cost;
121
+ }
122
+
123
+ /** Returns a copy of `cost` with this field set to `value`. A `0` (or
124
+ * non-finite) value removes the field — unset cost fields default to
125
+ * zero at read time — and when no fields remain, returns `undefined`
126
+ * so the whole `cost` object can be dropped. Immutable. */
127
+ private applyCost(
128
+ cost: ModelOverride["cost"],
129
+ value: number,
130
+ ): Partial<ModelCost> | undefined {
131
+ const next = { ...cost };
132
+ if (isFinite(value) && value > 0) next[this.key] = value;
133
+ else delete next[this.key];
134
+ return Object.keys(next).length > 0 ? next : undefined;
135
+ }
136
+
137
+ /** Appears in the summary only when a non-zero cost is set. */
138
+ override summaryPart(override: ModelOverride): string | null {
139
+ const value = override.cost?.[this.key];
140
+ return value ? `${this.summaryTerm}: $${value}` : null;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Non-negative numeric field (`contextSize`, `maxTokens`): keeps the
146
+ * display format, validator and apply logic in a single place so the
147
+ * three can't drift (e.g. the validator accepting `0` while the apply
148
+ * drops it — unset fields default to zero at read time, so all render
149
+ * as `0`).
150
+ */
151
+ class NumericField extends OverrideField {
152
+ readonly type = "input";
153
+ override readonly field: Field;
154
+
155
+ constructor(
156
+ readonly id: "maxTokens" | "contextSize",
157
+ field: Field,
158
+ ) {
159
+ super();
160
+ this.field = field;
161
+ }
162
+
163
+ displayValue(entry: OverrideEntry): string {
164
+ return String(entry.override[this.id] ?? 0);
165
+ }
166
+
167
+ /** Accepts finite, non-negative numbers (blank means zero). */
168
+ readonly validate: InputValidator = (raw) => {
169
+ const n = OverrideField.parseNonNegative(raw);
170
+ return n === null ? null : String(n);
171
+ };
172
+
173
+ /** Applies the committed value; non-positive values remove the key. */
174
+ apply(override: ModelOverride, value: string): void {
175
+ const n = Number(value);
176
+ if (isFinite(n) && n > 0) override[this.id] = n;
177
+ else delete override[this.id];
178
+ }
179
+
180
+ /** Appears in the summary only when set. */
181
+ override summaryPart(override: ModelOverride): string | null {
182
+ const value = override[this.id];
183
+ return value === undefined ? null : `${this.id}: ${value}`;
184
+ }
185
+ }
186
+
187
+ /** The override's `capabilities` list (finite: fixed label options). */
188
+ class CapabilitiesField extends OverrideField {
189
+ readonly id = "capabilities";
190
+ readonly type = "finite";
191
+ readonly field = FIELDS.capabilities;
192
+ override readonly options = ["text", "text | image"];
193
+
194
+ displayValue(entry: OverrideEntry): string {
195
+ return entry.override.capabilities?.join(" | ") ?? "text";
196
+ }
197
+
198
+ readonly validate: InputValidator = (raw) =>
199
+ this.options?.includes(raw) ? raw : null;
200
+
201
+ apply(override: ModelOverride, value: string): void {
202
+ override.capabilities =
203
+ value === "text | image"
204
+ ? ["text", "image"]
205
+ : value === "text"
206
+ ? ["text"]
207
+ : undefined;
208
+ }
209
+
210
+ /** Appears in the summary only when non-empty. */
211
+ override summaryPart(override: ModelOverride): string | null {
212
+ return override.capabilities?.length
213
+ ? `capabilities: ${override.capabilities.join(",")}`
214
+ : null;
215
+ }
216
+ }
217
+
218
+ /** The override's `reasoning` flag (finite: true/false, default true). */
219
+ class ReasoningField extends OverrideField {
220
+ readonly id = "reasoning";
221
+ readonly type = "finite";
222
+ readonly field = FIELDS.reasoning;
223
+ override readonly options = ["true", "false"];
224
+
225
+ displayValue(entry: OverrideEntry): string {
226
+ return entry.override.reasoning === false ? "false" : "true";
227
+ }
228
+
229
+ readonly validate: InputValidator = (raw) =>
230
+ this.options?.includes(raw) ? raw : null;
231
+
232
+ apply(override: ModelOverride, value: string): void {
233
+ if (value === "true") override.reasoning = true;
234
+ else if (value === "false") override.reasoning = false;
235
+ else delete override.reasoning;
236
+ }
237
+
238
+ /** Appears in the summary only when explicitly set. */
239
+ override summaryPart(override: ModelOverride): string | null {
240
+ return override.reasoning === undefined
241
+ ? null
242
+ : `reasoning: ${override.reasoning}`;
243
+ }
244
+ }
245
+
246
+ /** Cost field specs: [ModelCostRates key, FIELDS entry, summary term] */
247
+ const COST_FIELDS: [keyof ModelCostRates, Field, string][] = [
248
+ ["input", FIELDS.inputCost, "input"],
249
+ ["output", FIELDS.outputCost, "output"],
250
+ ["cacheRead", FIELDS.cacheReadCost, "cache read"],
251
+ ["cacheWrite", FIELDS.cacheWriteCost, "cache write"],
252
+ ];
253
+
254
+ /**
255
+ * The field registry for model override entries: the complete list of
256
+ * editable fields (order defines the row order in the settings list)
257
+ * plus id-based lookup. Throws on an unknown id — the registry is
258
+ * static, so a miss is a programming error.
259
+ */
260
+ export class OverrideFields {
261
+ static readonly all: OverrideField[] = [
262
+ new PatternField(),
263
+ ...COST_FIELDS.map(
264
+ ([key, field, term]) => new CostField(`cost.${key}`, key, field, term),
265
+ ),
266
+ new CapabilitiesField(),
267
+ new ReasoningField(),
268
+ new NumericField("contextSize", FIELDS.contextSize),
269
+ new NumericField("maxTokens", FIELDS.maxTokens),
270
+ ];
271
+
272
+ static byId(id: string): OverrideField {
273
+ const field = this.all.find((f) => f.id === id);
274
+ if (!field) throw new Error(`Unknown override field: ${id}`);
275
+ return field;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Builds the entry-row summary shown in the override entry list
281
+ * (`input: $0.2, output: $0.6, cache read: $0.01, …`), composed from the
282
+ * {@link OverrideFields} registry — adding a field to the registry makes
283
+ * it show up here too, so the summary can't lag the edit menu. Fields
284
+ * with unset/zero values are omitted for brevity (each field decides via
285
+ * {@link OverrideField.summaryPart}); `—` when nothing is set.
286
+ */
287
+ export class OverrideSummary {
288
+ static of(override: ModelOverride): string {
289
+ const parts = OverrideFields.all
290
+ .map((field) => field.summaryPart(override))
291
+ .filter((part): part is string => part !== null);
292
+ return parts.length > 0 ? parts.join(", ") : "—";
293
+ }
294
+ }
@@ -0,0 +1,118 @@
1
+ import type { LlamaServer, ModelOverride } from "../../../interfaces/settings";
2
+ import { OverrideEntry } from "./entry";
3
+ import { OverrideFields } from "./fields";
4
+
5
+ /** Result of applying a field change: the next server list and the
6
+ * updated override for UI refresh. `null` when the entry doesn't exist
7
+ * (stale index — a no-op). */
8
+ export interface MutationResult {
9
+ next: LlamaServer[];
10
+ updatedOverride: ModelOverride;
11
+ }
12
+
13
+ /**
14
+ * Applies edits (field commits, entry adds/renames/removals) to the
15
+ * override entries of a single server, immutably. Binds the server list
16
+ * and server index once in the constructor so call sites don't juggle
17
+ * long positional lists.
18
+ *
19
+ * Per-field commit behavior lives in the {@link OverrideFields} registry
20
+ * (`apply` on each field instance) — this class only resolves the field
21
+ * and the entry, and handles the pattern rename specially.
22
+ */
23
+ export class OverrideEntryMutator {
24
+ constructor(
25
+ private readonly servers: LlamaServer[],
26
+ private readonly serverIndex: number,
27
+ ) {}
28
+
29
+ /** The server's override entries, in map iteration order. */
30
+ entries(): OverrideEntry[] {
31
+ return OverrideEntry.listFrom(this.servers[this.serverIndex]);
32
+ }
33
+
34
+ /** Handles a field commit from an override entry's submenu. Returns
35
+ * `null` when the entry doesn't exist (stale index — no-op). */
36
+ applyFieldChange(
37
+ entryIndex: number,
38
+ field: string,
39
+ value: string,
40
+ ): MutationResult | null {
41
+ const entry = this.entries()[entryIndex];
42
+ if (!entry) return null;
43
+
44
+ // Pattern field: rename the pattern key, keep the override object
45
+ if (field === "pattern") {
46
+ return {
47
+ next: this.updateEntry(entryIndex, value, entry.override),
48
+ updatedOverride: entry.override,
49
+ };
50
+ }
51
+
52
+ // Other fields: let the field definition apply itself
53
+ const updatedOverride: ModelOverride = { ...entry.override };
54
+ OverrideFields.byId(field).apply(updatedOverride, value);
55
+
56
+ return {
57
+ next: this.updateEntry(entryIndex, entry.pattern, updatedOverride),
58
+ updatedOverride,
59
+ };
60
+ }
61
+
62
+ /** Adds a new override entry with a uniquified pattern. */
63
+ addEntry(pattern: string, override: ModelOverride = {}): LlamaServer[] {
64
+ const server = this.servers[this.serverIndex];
65
+ if (!server) return this.servers;
66
+
67
+ const existing = new Set(Object.keys(server.overrides ?? {}));
68
+ let uniquePattern = pattern;
69
+ let n = 2;
70
+ while (existing.has(uniquePattern)) {
71
+ uniquePattern = `${pattern}-${n++}`;
72
+ }
73
+
74
+ return this.withServer((s) => ({
75
+ ...s,
76
+ overrides: { ...s.overrides, [uniquePattern]: override },
77
+ }));
78
+ }
79
+
80
+ /** Removes the entry at `entryIndex`. */
81
+ removeEntry(entryIndex: number): LlamaServer[] {
82
+ return this.withServer((s) => ({
83
+ ...s,
84
+ overrides: Object.fromEntries(
85
+ Object.entries(s.overrides ?? {}).filter((_, j) => j !== entryIndex),
86
+ ),
87
+ }));
88
+ }
89
+
90
+ // -- internal helpers ------------------------------------------------------
91
+
92
+ /** Returns the servers with the target server replaced by
93
+ * `fn(server)`. Immutable. */
94
+ private withServer(fn: (server: LlamaServer) => LlamaServer): LlamaServer[] {
95
+ return this.servers.map((server, i) =>
96
+ i === this.serverIndex ? fn(server) : server,
97
+ );
98
+ }
99
+
100
+ /** Replaces the entry at `entryIndex` with `pattern → override` in a
101
+ * single mutation — used both for renaming the pattern and for editing
102
+ * its fields. The entry keeps its position in the map's iteration
103
+ * order. Immutable. */
104
+ private updateEntry(
105
+ entryIndex: number,
106
+ pattern: string,
107
+ override: ModelOverride,
108
+ ): LlamaServer[] {
109
+ return this.withServer((s) => ({
110
+ ...s,
111
+ overrides: Object.fromEntries(
112
+ Object.entries(s.overrides ?? {}).map(([k, v], j) =>
113
+ j === entryIndex ? [pattern, override] : [k, v],
114
+ ),
115
+ ),
116
+ }));
117
+ }
118
+ }
@@ -0,0 +1,42 @@
1
+ import type { SettingItem } from "@earendil-works/pi-tui";
2
+ import { FieldMessages, TITLES } from "../../strings";
3
+ import { ItemBuilder } from "../itemBuilder";
4
+ import { OverrideEntry } from "./entry";
5
+ import { OverrideFields, type OverrideField } from "./fields";
6
+
7
+ /**
8
+ * Builds `SettingItem` objects for override entry field submenus.
9
+ */
10
+ export class OverrideItemBuilder extends ItemBuilder<
11
+ OverrideEntry,
12
+ OverrideField
13
+ > {
14
+ protected get fields(): readonly OverrideField[] {
15
+ return OverrideFields.all;
16
+ }
17
+
18
+ /**
19
+ * Adds field-type-specific columns: fixed `values` for "finite" fields,
20
+ * an `InputDialog` submenu (validating against the field definition)
21
+ * for "input" fields.
22
+ */
23
+ protected decorate(
24
+ def: OverrideField,
25
+ entry: OverrideEntry,
26
+ base: SettingItem,
27
+ ): SettingItem {
28
+ if (def.type === "finite") {
29
+ return { ...base, values: [...(def.options ?? [])] };
30
+ }
31
+
32
+ return {
33
+ ...base,
34
+ submenu: this.dialogs.inputSubmenu(
35
+ TITLES.edit(def.label),
36
+ FieldMessages.of(def.field),
37
+ def.placeholder,
38
+ def.validate,
39
+ ),
40
+ };
41
+ }
42
+ }
@@ -0,0 +1,127 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ Component,
4
+ Focusable,
5
+ SettingItem,
6
+ SettingsList,
7
+ } from "@earendil-works/pi-tui";
8
+ import type { LlamaServer } from "../../../interfaces/settings";
9
+ import type { LlamaSettingsManager } from "../../../managers/settings";
10
+ import { HINTS } from "../../strings";
11
+ import type { OverrideSettingsListOptions } from "../editorOptions";
12
+ import { SettingsListFactory } from "../settingsListFactory";
13
+ import { OverrideEntryListEditor } from "./entryEditor";
14
+
15
+ /**
16
+ * Top-level overrides editor: one row per server, Enter drills into
17
+ * override entries.
18
+ *
19
+ * Unlike `ServerSettingsList`, this has no add/delete — just drill-down.
20
+ *
21
+ * Within a server's entry list: Enter drills into the field-edit submenu;
22
+ * a adds, d deletes (after confirmation). Fields use a mix of finite
23
+ * (Enter to cycle) and infinite (Enter to type) editing:
24
+ *
25
+ * - Pattern / costs (input, output, cacheRead, cacheWrite): infinite —
26
+ * Enter opens an Input for typing.
27
+ * - Capabilities: finite — Enter cycles between `text` and `text | image`.
28
+ * - Reasoning: finite — Enter cycles between `true` and `false`.
29
+ *
30
+ * Servers themselves are not managed here — use `/models servers`.
31
+ */
32
+ export class OverrideSettingsList implements Component, Focusable {
33
+ private settingsList: SettingsList | null = null;
34
+ private isFocused = false;
35
+
36
+ constructor(private readonly options: OverrideSettingsListOptions) {
37
+ this.settingsList = this.buildSettingsList();
38
+ }
39
+
40
+ /**
41
+ * Opens the editor in a modal `ui.custom` dialog seeded with the
42
+ * current `llamaSettings.servers` and resolves when the user closes
43
+ * it (Esc). Writes go through `settings.setLlamaSetting()`; write
44
+ * errors are notified via `ui` and the editor stays open with the
45
+ * pre-mutation list.
46
+ */
47
+ static async show(
48
+ ui: ExtensionUIContext,
49
+ settings: LlamaSettingsManager,
50
+ ): Promise<void> {
51
+ const servers = await settings.getLlamaServers();
52
+
53
+ await ui.custom<void>(
54
+ (tui, theme, keybindings, done) =>
55
+ new OverrideSettingsList({
56
+ tui,
57
+ theme,
58
+ keybindings,
59
+ servers,
60
+ persist: (next) => settings.setLlamaSetting("servers", next),
61
+ done: () => done(undefined),
62
+ onError: (message) => ui.notify(message, "error"),
63
+ onChanged: () => {}, // no per-change notification needed
64
+ }),
65
+ );
66
+ }
67
+
68
+ // -- Component -------------------------------------------------------------
69
+
70
+ invalidate(): void {
71
+ this.settingsList?.invalidate();
72
+ }
73
+
74
+ handleInput(data: string): void {
75
+ this.settingsList?.handleInput(data);
76
+ }
77
+
78
+ render(width: number): string[] {
79
+ if (!this.settingsList) return ["Loading..."];
80
+ return this.settingsList.render(width);
81
+ }
82
+
83
+ // -- Focusable -------------------------------------------------------------
84
+
85
+ get focused(): boolean {
86
+ return this.isFocused;
87
+ }
88
+
89
+ set focused(value: boolean) {
90
+ // `SettingsList` has no `focused` flag — the flag is ours alone
91
+ this.isFocused = value;
92
+ }
93
+
94
+ // -- internal --------------------------------------------------------------
95
+
96
+ private buildSettingsList(): SettingsList {
97
+ const serverItems: SettingItem[] = this.options.servers.map(
98
+ (server, i) => ({
99
+ id: `server-${i}`,
100
+ label: server.url,
101
+ description: HINTS.overrideServerRow,
102
+ currentValue: OverrideSettingsList.entriesLabel(server),
103
+ submenu: (_cv, done) =>
104
+ new OverrideEntryListEditor(this.options, i, () => {
105
+ // Entry count may have changed (add/delete/pattern rename)
106
+ const current = this.options.servers[i];
107
+ serverList?.updateValue(
108
+ `server-${i}`,
109
+ OverrideSettingsList.entriesLabel(current),
110
+ );
111
+ done();
112
+ }),
113
+ }),
114
+ );
115
+
116
+ let serverList: SettingsList | null = null;
117
+ serverList = SettingsListFactory.create(serverItems, () => {
118
+ this.options.done();
119
+ });
120
+ return serverList;
121
+ }
122
+
123
+ /** Row summary for a server in the list: its override entry count. */
124
+ private static entriesLabel(server: LlamaServer | undefined): string {
125
+ return `${Object.keys(server?.overrides ?? {}).length} entries`;
126
+ }
127
+ }
@@ -0,0 +1,60 @@
1
+ import type { LlamaServer } from "../../../interfaces/settings";
2
+
3
+ /**
4
+ * Builder for `LlamaServer` objects, centralizing the "empty string means
5
+ * omit the key" rule for the optional `id` and `name` fields — the single
6
+ * place where that invariant lives, shared by the add-server wizard's
7
+ * final step and by field commits on existing servers.
8
+ *
9
+ * Start from an existing server with {@link from}, chain the setters,
10
+ * then call {@link build}. Immutable with respect to the input: the
11
+ * source server is never mutated.
12
+ */
13
+ export class LlamaServerBuilder {
14
+ private constructor(private data: LlamaServer) {}
15
+
16
+ /** Starts a builder from an existing server (also used to start a
17
+ * fresh `{ url }` server in the wizard flow). */
18
+ static from(server: LlamaServer): LlamaServerBuilder {
19
+ return new LlamaServerBuilder({ ...server });
20
+ }
21
+
22
+ /** Sets the URL (always present, never omitted). */
23
+ url(url: string): this {
24
+ this.data = { ...this.data, url };
25
+ return this;
26
+ }
27
+
28
+ /**
29
+ * Sets the custom provider ID. An empty value omits the key — when
30
+ * editing an existing server this removes it, restoring the
31
+ * URL-based auto-detected ID.
32
+ */
33
+ optionalId(id: string): this {
34
+ this.data = this.withOptional("id", id);
35
+ return this;
36
+ }
37
+
38
+ /**
39
+ * Sets the display name. An empty value omits the key — when editing
40
+ * an existing server this removes it.
41
+ */
42
+ optionalName(name: string): this {
43
+ this.data = this.withOptional("name", name);
44
+ return this;
45
+ }
46
+
47
+ /** Returns the built server. */
48
+ build(): LlamaServer {
49
+ return this.data;
50
+ }
51
+
52
+ /** Copies `data`, setting `key` to `value` when non-empty and
53
+ * removing the key otherwise. */
54
+ private withOptional(key: "id" | "name", value: string): LlamaServer {
55
+ const next = { ...this.data };
56
+ if (value.length > 0) next[key] = value;
57
+ else delete next[key];
58
+ return next;
59
+ }
60
+ }