pi-llama-cpp 0.14.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 (47) hide show
  1. package/README.md +12 -10
  2. package/package.json +3 -3
  3. package/src/constants.ts +5 -0
  4. package/src/index.ts +2 -1
  5. package/src/interfaces/settings.ts +6 -0
  6. package/src/managers/command/models.ts +9 -2
  7. package/src/managers/command.ts +6 -1
  8. package/src/managers/server.ts +30 -38
  9. package/src/managers/settings.ts +14 -2
  10. package/src/server.ts +6 -9
  11. package/src/sse/manager.ts +2 -2
  12. package/src/ui/editors/editorOptions.ts +8 -1
  13. package/src/ui/editors/itemBuilder.ts +2 -6
  14. package/src/ui/editors/listEditor.ts +24 -15
  15. package/src/ui/editors/override/entryEditor.ts +97 -15
  16. package/src/ui/editors/override/fields/base.ts +53 -0
  17. package/src/ui/editors/override/fields/capabilities.ts +36 -0
  18. package/src/ui/editors/override/fields/cost.ts +64 -0
  19. package/src/ui/editors/override/fields/index.ts +68 -0
  20. package/src/ui/editors/override/fields/numeric.ts +55 -0
  21. package/src/ui/editors/override/fields/pattern.ts +24 -0
  22. package/src/ui/editors/override/fields/reasoning.ts +33 -0
  23. package/src/ui/editors/override/itemBuilder.ts +3 -6
  24. package/src/ui/editors/server/itemBuilder.ts +1 -5
  25. package/src/ui/editors/server/serverEditor.ts +51 -15
  26. package/src/ui/editors/server/utils.ts +50 -0
  27. package/src/ui/settings/index.ts +11 -0
  28. package/src/ui/strings.ts +17 -0
  29. package/src/utils/health.ts +2 -1
  30. package/src/utils/urlResolver.ts +1 -1
  31. package/tests/{commandManager.test.ts → command/commandManager.test.ts} +80 -9
  32. package/tests/{events.test.ts → events/events.test.ts} +6 -6
  33. package/tests/mocks.ts +2 -0
  34. package/tests/{legacyModel.test.ts → models/legacyModel.test.ts} +5 -5
  35. package/tests/{routerModel.test.ts → models/routerModel.test.ts} +5 -5
  36. package/tests/{singleModel.test.ts → models/singleModel.test.ts} +5 -5
  37. package/tests/{health.test.ts → server/health.test.ts} +2 -2
  38. package/tests/{server.test.ts → server/server.test.ts} +5 -5
  39. package/tests/{serverManager.test.ts → server/serverManager.test.ts} +4 -4
  40. package/tests/{settings.test.ts → settings/settings.test.ts} +174 -133
  41. package/tests/{settingsStore.test.ts → settings/settingsStore.test.ts} +1 -1
  42. package/tests/{sseManager.test.ts → sse/sseManager.test.ts} +2 -2
  43. package/tests/{dialog.test.ts → ui/dialog.test.ts} +5 -4
  44. package/tests/{overrides.test.ts → ui/overrides.test.ts} +9 -42
  45. package/src/ui/editors/override/fields.ts +0 -294
  46. package/src/ui/editors/override/handlers.ts +0 -118
  47. package/src/ui/editors/server/handlers.ts +0 -32
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { SettingsStore } from "../src/utils/settingsStore";
2
+ import { SettingsStore } from "../../src/utils/settingsStore";
3
3
 
4
4
  const mockReadFile = vi.hoisted(() => vi.fn());
5
5
  const mockWriteFile = vi.hoisted(() => vi.fn());
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { SSEManager } from "../src/sse/manager";
3
- import { createMockServer } from "./mocks";
2
+ import { SSEManager } from "../../src/sse/manager";
3
+ import { createMockServer } from "../mocks";
4
4
 
5
5
  /**
6
6
  * Builds an SSEManager bound to a stub server, mirroring how
@@ -5,10 +5,10 @@ import {
5
5
  type TUI,
6
6
  } from "@earendil-works/pi-tui";
7
7
  import { beforeEach, describe, expect, it, vi } from "vitest";
8
- import { ConfirmDialog } from "../src/ui/dialog/confirm";
9
- import { InputDialog } from "../src/ui/dialog/input";
10
- import { OverrideSettingsList } from "../src/ui/editors/override/overrideList";
11
- import { ServerSettingsList } from "../src/ui/editors/server/serverEditor";
8
+ import { ConfirmDialog } from "../../src/ui/dialog/confirm";
9
+ import { InputDialog } from "../../src/ui/dialog/input";
10
+ import { OverrideSettingsList } from "../../src/ui/editors/override/overrideList";
11
+ import { ServerSettingsList } from "../../src/ui/editors/server/serverEditor";
12
12
 
13
13
  beforeEach(() => {
14
14
  initTheme();
@@ -139,6 +139,7 @@ describe("ServerSettingsList add wizard", () => {
139
139
  persist,
140
140
  done,
141
141
  onError,
142
+ authResolver: () => Promise.resolve("sk-placeholder"),
142
143
  });
143
144
  return { editor, persist, done, onError };
144
145
  };
@@ -1,15 +1,14 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
- import { FALLBACK_CTX } from "../src/constants";
3
- import type { LlamaServer, ModelOverride } from "../src/interfaces/settings";
4
- import { SingleModel } from "../src/models/singleModel";
5
- import { Server } from "../src/server";
6
- import { OverrideEntry } from "../src/ui/editors/override/entry";
2
+ import { FALLBACK_CTX } from "../../src/constants";
3
+ import type { ModelOverride } from "../../src/interfaces/settings";
4
+ import { SingleModel } from "../../src/models/singleModel";
5
+ import { Server } from "../../src/server";
6
+ import { OverrideEntry } from "../../src/ui/editors/override/entry";
7
7
  import {
8
8
  OverrideFields,
9
9
  OverrideSummary,
10
- } from "../src/ui/editors/override/fields";
11
- import { OverrideEntryMutator } from "../src/ui/editors/override/handlers";
12
- import { createMockServer, mockRpc } from "./mocks";
10
+ } from "../../src/ui/editors/override/fields";
11
+ import { createMockServer, mockRpc } from "../mocks";
13
12
 
14
13
  beforeEach(() => {
15
14
  mockRpc.mockReset();
@@ -293,41 +292,9 @@ describe("OverrideSummary.of", () => {
293
292
  });
294
293
  });
295
294
 
296
- // ─── override entry list helpers (overrideEntryEditor) ────────────────────
297
-
298
- describe("override entry list helpers", () => {
299
- const servers = [
300
- { url: "http://a", overrides: { "a-1": { reasoning: false } } },
301
- { url: "http://b" },
302
- ] as LlamaServer[];
303
-
304
- it("addEntry should append a pattern → override entry", () => {
305
- const next = new OverrideEntryMutator(servers, 0).addEntry("a-2", {
306
- contextSize: 4096,
307
- });
308
- expect(next[0].overrides).toEqual({
309
- "a-1": { reasoning: false },
310
- "a-2": { contextSize: 4096 },
311
- });
312
- });
313
-
314
- it("pattern renames should replace in place without reordering", () => {
315
- const mutator = new OverrideEntryMutator(servers, 0);
316
- const result = mutator.applyFieldChange(0, "pattern", "a-renamed");
317
- expect(result).not.toBeNull();
318
- expect(Object.keys(result!.next[0].overrides!)).toEqual(["a-renamed"]);
319
- expect(result!.next[0].overrides!["a-renamed"]).toEqual({
320
- reasoning: false,
321
- });
322
- expect(result!.next[1]).toBe(servers[1]);
323
- });
324
-
325
- it("removeEntry should drop only the targeted entry", () => {
326
- const withTwo = new OverrideEntryMutator(servers, 0).addEntry("a-2");
327
- const next = new OverrideEntryMutator(withTwo, 0).removeEntry(0);
328
- expect(Object.keys(next[0].overrides!)).toEqual(["a-2"]);
329
- });
295
+ // ─── CostField tests ──────────────────────────────────────────────────────
330
296
 
297
+ describe("CostField validation and apply", () => {
331
298
  it("CostField.validate should accept blank, non-negative numbers; reject the rest", () => {
332
299
  const cost = OverrideFields.byId("cost.input");
333
300
  expect(cost.validate("")).toBe("0");
@@ -1,294 +0,0 @@
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
- }
@@ -1,118 +0,0 @@
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
- }
@@ -1,32 +0,0 @@
1
- import type { LlamaServer } from "../../../interfaces/settings";
2
- import { ServerFields } from "./fields";
3
-
4
- /**
5
- * Applies field commits to a single server, immutably. Binds the server
6
- * list and server index once in the constructor so call sites don't
7
- * juggle long positional lists. Mirrors the override side's
8
- * `OverrideEntryMutator`.
9
- *
10
- * Per-field commit behavior lives in the `ServerFields` registry
11
- * (`apply` on each field class, backed by `LlamaServerBuilder`) — this
12
- * class only resolves the field definition and produces the next list.
13
- */
14
- export class ServerEntryMutator {
15
- constructor(
16
- private readonly servers: LlamaServer[],
17
- private readonly serverIndex: number,
18
- ) {}
19
-
20
- /**
21
- * Handles a field commit from a server row's submenu. Returns the
22
- * next server list, or the same list when the server is missing
23
- * (no-op — callers can compare by reference).
24
- */
25
- applyFieldChange(field: string, value: string): LlamaServer[] {
26
- const server = this.servers[this.serverIndex];
27
- if (!server) return this.servers;
28
-
29
- const updated = ServerFields.byId(field).apply(server, value);
30
- return this.servers.map((s, i) => (i === this.serverIndex ? updated : s));
31
- }
32
- }