pi-llama-cpp 0.14.0 → 0.16.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 (62) hide show
  1. package/README.md +52 -13
  2. package/package.json +3 -3
  3. package/src/api/client.ts +59 -22
  4. package/src/constants.ts +5 -0
  5. package/src/enums/mode.ts +1 -0
  6. package/src/index.ts +2 -1
  7. package/src/interfaces/endpoints/models.ts +6 -0
  8. package/src/interfaces/endpoints/props.ts +14 -0
  9. package/src/interfaces/settings.ts +6 -0
  10. package/src/managers/command/models.ts +12 -6
  11. package/src/managers/command.ts +6 -1
  12. package/src/managers/server.ts +30 -38
  13. package/src/managers/settings.ts +23 -3
  14. package/src/models/legacyModel.ts +3 -3
  15. package/src/models/llamaSwapModel.ts +90 -0
  16. package/src/models/routerModel.ts +2 -2
  17. package/src/models/singleModel.ts +2 -2
  18. package/src/server.ts +42 -15
  19. package/src/sse/manager.ts +2 -2
  20. package/src/ui/dialog/confirm.ts +1 -1
  21. package/src/ui/dialog/input.ts +1 -1
  22. package/src/ui/editors/editorOptions.ts +8 -1
  23. package/src/ui/editors/itemBuilder.ts +2 -6
  24. package/src/ui/editors/listEditor.ts +24 -15
  25. package/src/ui/editors/override/entryEditor.ts +105 -23
  26. package/src/ui/editors/override/fields/base.ts +53 -0
  27. package/src/ui/editors/override/fields/capabilities.ts +36 -0
  28. package/src/ui/editors/override/fields/cost.ts +64 -0
  29. package/src/ui/editors/override/fields/index.ts +68 -0
  30. package/src/ui/editors/override/fields/numeric.ts +55 -0
  31. package/src/ui/editors/override/fields/pattern.ts +24 -0
  32. package/src/ui/editors/override/fields/reasoning.ts +33 -0
  33. package/src/ui/editors/override/itemBuilder.ts +4 -4
  34. package/src/ui/editors/server/fields.ts +2 -2
  35. package/src/ui/editors/server/itemBuilder.ts +2 -3
  36. package/src/ui/editors/server/serverEditor.ts +58 -22
  37. package/src/ui/editors/server/utils.ts +50 -0
  38. package/src/ui/settings/index.ts +11 -0
  39. package/src/ui/strings.ts +17 -0
  40. package/src/utils/credentialResolver.ts +110 -0
  41. package/src/utils/health.ts +2 -2
  42. package/src/utils/urlResolver.ts +1 -1
  43. package/tests/{commandManager.test.ts → command/commandManager.test.ts} +80 -9
  44. package/tests/{events.test.ts → events/events.test.ts} +6 -6
  45. package/tests/mocks.ts +4 -0
  46. package/tests/{legacyModel.test.ts → models/legacyModel.test.ts} +5 -5
  47. package/tests/models/llamaSwapModel.test.ts +327 -0
  48. package/tests/{routerModel.test.ts → models/routerModel.test.ts} +5 -5
  49. package/tests/{singleModel.test.ts → models/singleModel.test.ts} +5 -5
  50. package/tests/{health.test.ts → server/health.test.ts} +4 -4
  51. package/tests/{server.test.ts → server/server.test.ts} +57 -7
  52. package/tests/{serverManager.test.ts → server/serverManager.test.ts} +4 -4
  53. package/tests/{settings.test.ts → settings/settings.test.ts} +198 -355
  54. package/tests/{settingsStore.test.ts → settings/settingsStore.test.ts} +1 -1
  55. package/tests/{sseManager.test.ts → sse/sseManager.test.ts} +2 -2
  56. package/tests/{dialog.test.ts → ui/dialog.test.ts} +5 -4
  57. package/tests/{overrides.test.ts → ui/overrides.test.ts} +9 -42
  58. package/tests/utils/credentialResolver.test.ts +66 -0
  59. package/tests/utils/urlResolver.test.ts +116 -0
  60. package/src/ui/editors/override/fields.ts +0 -294
  61. package/src/ui/editors/override/handlers.ts +0 -118
  62. package/src/ui/editors/server/handlers.ts +0 -32
@@ -0,0 +1,90 @@
1
+ import { FALLBACK_CTX } from "../constants";
2
+ import { Mode } from "../enums/mode";
3
+ import { Status } from "../enums/status";
4
+ import { BaseModel } from "./baseModel";
5
+
6
+ export class LlamaSwapModel extends BaseModel {
7
+ get mode(): Mode {
8
+ return Mode.LLAMASWAP;
9
+ }
10
+
11
+ override get name(): string {
12
+ // llama-swap adds a `llamaswap` key inside `meta` with aliases;
13
+ // fall back to the standard aliases array, then to id
14
+ return (
15
+ this.model.meta?.llamaswap?.aliases?.[0] ??
16
+ this.model.aliases?.[0] ??
17
+ this.model.id
18
+ );
19
+ }
20
+
21
+ /**
22
+ * Retrieves the context size of the model.
23
+ * An override's `contextSize` takes precedence; otherwise reads
24
+ * `meta.n_ctx` from the llama-swap `/v1/models` response,
25
+ * falling back to `FALLBACK_CTX`.
26
+ *
27
+ * @returns The context size
28
+ */
29
+ protected override async getContextSize(): Promise<number> {
30
+ const overridden = this.server.findOverrideForModel(this.id)?.contextSize;
31
+ if (overridden && overridden > 0) return overridden;
32
+
33
+ const { data } = await this.server.fetchModels();
34
+ const model = data.find((m) => m.id === this.id);
35
+
36
+ return model?.meta?.n_ctx ?? FALLBACK_CTX;
37
+ }
38
+
39
+ /**
40
+ * Detects the capabilities of the model.
41
+ * An override's `capabilities` fully replaces detection.
42
+ *
43
+ * @returns An array of capabilities, as expected by Pi
44
+ */
45
+ override async getCapabilities(): Promise<("text" | "image")[]> {
46
+ const overridden = this.server.findOverrideForModel(this.id)?.capabilities;
47
+ if (overridden) return overridden;
48
+
49
+ const { data } = await this.server.fetchModels();
50
+ const model = data.find((m) => m.id === this.id);
51
+
52
+ const inputModalities = model?.architecture?.input_modalities ?? [];
53
+ return inputModalities.includes("image") ? ["text", "image"] : ["text"];
54
+ }
55
+
56
+ /**
57
+ * Detects the load status of a model.
58
+ * For simplicity, we'll only handle loaded/unloaded
59
+ *
60
+ * @returns The current {@link Status}
61
+ */
62
+ override async getStatus(): Promise<Status> {
63
+ const { data } = await this.server.fetchModels();
64
+ const model = data.find((m) => m.id === this.id);
65
+
66
+ return model?.status?.value === "loaded" ? Status.LOADED : Status.UNLOADED;
67
+ }
68
+
69
+ /**
70
+ * Loads the model in the llama-swap server
71
+ */
72
+ override async load(): Promise<void> {
73
+ const status = await this.getStatus();
74
+ if (status === Status.LOADED) return;
75
+
76
+ try {
77
+ await this.server.llamaSwapLoad(this.id);
78
+ } catch (err) {
79
+ console.warn({ err });
80
+ throw new Error(`Model loading failed: ${this.id}`);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Unloads the model in the llama-swap server
86
+ */
87
+ override async unload(): Promise<void> {
88
+ await this.server.llamaSwapUnload(this.id);
89
+ }
90
+ }
@@ -26,7 +26,7 @@ export class RouterModel extends BaseModel {
26
26
  *
27
27
  * In exchange, it will allow unloaded models to be correctly shown as "unloaded".
28
28
  */
29
- protected async pollStatus(startTime = Date.now()): Promise<void> {
29
+ protected override async pollStatus(startTime = Date.now()): Promise<void> {
30
30
  let elapsed = 0;
31
31
  const limit = 5000;
32
32
 
@@ -52,7 +52,7 @@ export class RouterModel extends BaseModel {
52
52
  *
53
53
  * @returns The context size in tokens
54
54
  */
55
- protected async getContextSize(): Promise<number> {
55
+ protected override async getContextSize(): Promise<number> {
56
56
  // We can get a more accurate context size if the model is already loaded
57
57
  if ((await this.getStatus()) === Status.LOADED) {
58
58
  return super.getContextSize();
@@ -2,11 +2,11 @@ import { Mode } from "../enums/mode";
2
2
  import { BaseModel } from "./baseModel";
3
3
 
4
4
  export class SingleModel extends BaseModel {
5
- get mode(): Mode {
5
+ override get mode(): Mode {
6
6
  return Mode.SINGLE;
7
7
  }
8
8
 
9
- protected async getCapabilities(): Promise<("text" | "image")[]> {
9
+ protected override async getCapabilities(): Promise<("text" | "image")[]> {
10
10
  try {
11
11
  return await super.getCapabilities();
12
12
  } catch {
package/src/server.ts CHANGED
@@ -8,6 +8,7 @@ import { Mode } from "./enums/mode";
8
8
  import { ServerStatus } from "./enums/serverStatus";
9
9
  import { ModelsEndpoint } from "./interfaces/endpoints/models";
10
10
  import {
11
+ LlamaSwapPropsError,
11
12
  PropsEndpoint,
12
13
  PropsModelEndpoint,
13
14
  } from "./interfaces/endpoints/props";
@@ -16,6 +17,7 @@ import type { ModelOverride } from "./interfaces/settings";
16
17
  import type { LlamaSettingsManager } from "./managers/settings";
17
18
  import { BaseModel } from "./models/baseModel";
18
19
  import { LegacyModel } from "./models/legacyModel";
20
+ import { LlamaSwapModel } from "./models/llamaSwapModel";
19
21
  import { RouterModel } from "./models/routerModel";
20
22
  import { SingleModel } from "./models/singleModel";
21
23
  import { SSEManager } from "./sse/manager";
@@ -52,8 +54,8 @@ export class Server {
52
54
  // in ServerManager), so no lazy fallback is needed. initialize()
53
55
  // rebuilds the client to re-resolve the API key.
54
56
  this.apiClient =
55
- deps.createApiClient?.(this.getApiKey()) ??
56
- new ApiClient(options.baseUrl, this.getApiKey());
57
+ deps.createApiClient?.(this.apiKey) ??
58
+ new ApiClient(options.baseUrl, this.apiKey);
57
59
  }
58
60
 
59
61
  /** Base URL of this server endpoint. */
@@ -119,10 +121,8 @@ export class Server {
119
121
  /**
120
122
  * Retrieves the API key from the config resolver.
121
123
  * Tries custom ID first, then falls back to URL-based ID.
122
- *
123
- * @returns The API key
124
124
  */
125
- getApiKey(): string {
125
+ get apiKey(): string {
126
126
  // Try custom ID first
127
127
  if (this.options.customId) {
128
128
  const key = this.settings.resolveApiKey(this.options.customId);
@@ -137,10 +137,9 @@ export class Server {
137
137
  * Clears the cache first so we always fetch fresh data.
138
138
  */
139
139
  async initialize() {
140
- const apiKey = this.getApiKey();
141
140
  this.apiClient =
142
- this.deps.createApiClient?.(apiKey) ??
143
- new ApiClient(this.baseUrl, apiKey);
141
+ this.deps.createApiClient?.(this.apiKey) ??
142
+ new ApiClient(this.baseUrl, this.apiKey);
144
143
  this.sse = this.deps.createSSEManager?.(this) ?? new SSEManager(this);
145
144
  const { data } = await this.fetchModels();
146
145
  const mode = await this.detectServerMode(data);
@@ -150,6 +149,7 @@ export class Server {
150
149
  [Mode.ROUTER]: RouterModel,
151
150
  [Mode.LEGACY]: LegacyModel,
152
151
  [Mode.SINGLE]: SingleModel,
152
+ [Mode.LLAMASWAP]: LlamaSwapModel,
153
153
  }[mode];
154
154
 
155
155
  const models: BaseModel[] = data.map((m) => new modelCtor(m, this));
@@ -166,9 +166,12 @@ export class Server {
166
166
  * @returns The detected mode
167
167
  */
168
168
  private async detectServerMode(data: ModelsEndpoint["data"]): Promise<Mode> {
169
- const { role } = await this.fetchServerProps();
169
+ const serverProps = await this.fetchServerProps();
170
170
 
171
- if (role === "router") return Mode.ROUTER;
171
+ if ("src" in serverProps && serverProps.src === "llama-swap")
172
+ return Mode.LLAMASWAP;
173
+ if ("role" in serverProps && serverProps.role === "router")
174
+ return Mode.ROUTER;
172
175
  if ("max_model_len" in data[0]) return Mode.LEGACY;
173
176
  return Mode.SINGLE;
174
177
  }
@@ -185,7 +188,7 @@ export class Server {
185
188
  * @returns The server status
186
189
  */
187
190
  async isReady(timeout: number): Promise<ServerStatus> {
188
- return checkServerHealth(this.baseUrl, timeout, this.getApiKey());
191
+ return checkServerHealth(this.baseUrl, timeout, this.apiKey);
189
192
  }
190
193
 
191
194
  /**
@@ -200,12 +203,16 @@ export class Server {
200
203
  }
201
204
 
202
205
  /**
203
- * Fetches general properties of the server
206
+ * Fetches general properties of the server.
207
+ * llama-server returns {@link PropsEndpoint}; llama-swap returns
208
+ * {@link LlamaSwapPropsError} (an error response with a `src` discriminator).
204
209
  *
205
- * @return The properties of the server
210
+ * @return The server properties
206
211
  */
207
- async fetchServerProps(): Promise<PropsEndpoint> {
208
- return await this.apiClient.get<PropsEndpoint>("/props?autoload=false");
212
+ async fetchServerProps(): Promise<PropsEndpoint | LlamaSwapPropsError> {
213
+ return await this.apiClient.get<PropsEndpoint | LlamaSwapPropsError>(
214
+ "/props?autoload=false",
215
+ );
209
216
  }
210
217
 
211
218
  /**
@@ -261,4 +268,24 @@ export class Server {
261
268
  model,
262
269
  });
263
270
  }
271
+
272
+ /**
273
+ * Loads a model on a llama-swap server (GET /upstream/{id}).
274
+ *
275
+ * @param model The model ID to load
276
+ */
277
+ async llamaSwapLoad(model: string): Promise<void> {
278
+ this.apiClient.clearCache();
279
+ await this.apiClient.rawGet(`/upstream/${model}`);
280
+ }
281
+
282
+ /**
283
+ * Unloads a model on a llama-swap server (POST /api/models/unload/{id}).
284
+ *
285
+ * @param model The model ID to unload
286
+ */
287
+ async llamaSwapUnload(model: string): Promise<void> {
288
+ this.apiClient.clearCache();
289
+ await this.apiClient.rawPost(`/api/models/unload/${model}`);
290
+ }
264
291
  }
@@ -46,7 +46,7 @@ export class SSEManager {
46
46
  try {
47
47
  const response = await fetch(this.sseEndpoint, {
48
48
  method: "GET",
49
- headers: buildAuthHeaders(this.server.getApiKey()),
49
+ headers: buildAuthHeaders(this.server.apiKey),
50
50
  signal: AbortSignal.timeout(await this.server.getServerTimeout()),
51
51
  });
52
52
  this.sseSupported =
@@ -78,7 +78,7 @@ export class SSEManager {
78
78
  this.modelCallbacks.set(modelId, callbacks);
79
79
 
80
80
  // Create SSE client if not already created
81
- this.sseClient ??= new SSEClient(this.sseEndpoint, this.server.getApiKey());
81
+ this.sseClient ??= new SSEClient(this.sseEndpoint, this.server.apiKey);
82
82
 
83
83
  // Subscribe a single dispatching callback to the SSE client
84
84
  if (!this.sseSubscribers.has(modelId)) {
@@ -39,7 +39,7 @@ export class ConfirmDialog extends BaseDialog {
39
39
 
40
40
  // -- helpers -------------------------------------------------------------------
41
41
 
42
- protected get inputTarget() {
42
+ protected override get inputTarget() {
43
43
  return this.list;
44
44
  }
45
45
  }
@@ -36,7 +36,7 @@ export class InputDialog extends BaseDialog {
36
36
 
37
37
  // -- helpers -------------------------------------------------------------------
38
38
 
39
- protected get inputTarget() {
39
+ protected override get inputTarget() {
40
40
  return this.input;
41
41
  }
42
42
 
@@ -1,4 +1,7 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionUIContext,
3
+ Theme,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import type { KeybindingsManager, TUI } from "@earendil-works/pi-tui";
3
6
  import type { LlamaServer } from "../../interfaces/settings";
4
7
 
@@ -32,6 +35,10 @@ export interface SettingsListEditorOptions {
32
35
  export interface ServerSettingsListOptions extends SettingsListEditorOptions {
33
36
  /** Timeout (ms) for health checks. Defaults to 1000ms if not provided. */
34
37
  serverTimeout?: number;
38
+ /** Resolves the API key for a server (used for auth probing). */
39
+ authResolver?: (server: LlamaServer) => Promise<string>;
40
+ /** UI context for notifications (used to warn about auth-required servers). */
41
+ ui?: ExtensionUIContext;
35
42
  }
36
43
 
37
44
  /**
@@ -30,7 +30,7 @@ export abstract class ItemBuilder<TEntry, TField extends SettingField<TEntry>> {
30
30
  description: def.description,
31
31
  currentValue: def.currentValue(entry),
32
32
  };
33
- return this.decorate(def, entry, base);
33
+ return this.decorate(def, base);
34
34
  }
35
35
 
36
36
  /** Builds the `SettingItem` array for one entry's editable fields. */
@@ -39,9 +39,5 @@ export abstract class ItemBuilder<TEntry, TField extends SettingField<TEntry>> {
39
39
  }
40
40
 
41
41
  /** Adds field-type-specific columns (e.g. submenu or fixed values). */
42
- protected abstract decorate(
43
- def: TField,
44
- entry: TEntry,
45
- base: SettingItem,
46
- ): SettingItem;
42
+ protected abstract decorate(def: TField, base: SettingItem): SettingItem;
47
43
  }
@@ -66,9 +66,6 @@ export abstract class ListEditor<
66
66
  | "emptyServers"
67
67
  | "emptyOverrideEntries";
68
68
 
69
- /** Total item count (entries or servers) for selection clamping. */
70
- protected abstract getCurrentCount(): number;
71
-
72
69
  /** Returns the id for a row at the given index. */
73
70
  protected abstract getRowId(index: number): string;
74
71
 
@@ -78,6 +75,12 @@ export abstract class ListEditor<
78
75
  /** Title for the delete confirmation dialog (e.g. "Delete server"). */
79
76
  protected abstract get deleteTitle(): string;
80
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
+
81
84
  // -- hooks -----------------------------------------------------------------
82
85
 
83
86
  /**
@@ -111,10 +114,7 @@ export abstract class ListEditor<
111
114
 
112
115
  handleInput(data: string): void {
113
116
  // Delegate to the open modal dialog, if any
114
- if (this.activeDialog) {
115
- this.activeDialog.handleInput(data);
116
- return;
117
- }
117
+ if (this.delegateToDialogInput(data)) return;
118
118
 
119
119
  // Delegate to settingsList if a submenu is open
120
120
  if (this.submenuOpen && this.settingsList) {
@@ -134,15 +134,11 @@ export abstract class ListEditor<
134
134
  }
135
135
  if (kb.matches(data, "tui.select.up")) {
136
136
  this.selectedIndex =
137
- this.selectedIndex === 0
138
- ? this.getCurrentCount() - 1
139
- : this.selectedIndex - 1;
137
+ this.selectedIndex === 0 ? this.getCount() - 1 : this.selectedIndex - 1;
140
138
  }
141
139
  if (kb.matches(data, "tui.select.down")) {
142
140
  this.selectedIndex =
143
- this.selectedIndex === this.getCurrentCount() - 1
144
- ? 0
145
- : this.selectedIndex + 1;
141
+ this.selectedIndex === this.getCount() - 1 ? 0 : this.selectedIndex + 1;
146
142
  }
147
143
  if (data === "a") {
148
144
  this.beginAdd();
@@ -164,7 +160,7 @@ export abstract class ListEditor<
164
160
  // Empty list hint
165
161
  if (!this.settingsList) return ["Loading..."];
166
162
  const lines = this.settingsList.render(width);
167
- if (this.getCurrentCount() === 0) {
163
+ if (this.getCount() === 0) {
168
164
  lines[lines.length - 1] = getSettingsListTheme().hint(
169
165
  HINTS[this.emptyHintKey],
170
166
  );
@@ -174,6 +170,18 @@ export abstract class ListEditor<
174
170
 
175
171
  // -- helpers ---------------------------------------------------------------
176
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
+
177
185
  /**
178
186
  * Rebuilds the list and places the cursor on `targetIndex`.
179
187
  * Subclasses call this after add/delete/persist to refresh the UI.
@@ -181,9 +189,10 @@ export abstract class ListEditor<
181
189
  protected async rebuildList(targetIndex: number): Promise<void> {
182
190
  const list = await this.buildSettingsList();
183
191
  this.settingsList = list;
184
- const count = this.getCurrentCount();
192
+ const count = this.getCount();
185
193
  if (count === 0) {
186
194
  this.selectedIndex = 0;
195
+ this.options.tui.requestRender();
187
196
  return;
188
197
  }
189
198
  this.selectedIndex = Math.min(targetIndex, count - 1);
@@ -1,11 +1,11 @@
1
1
  import type { SettingItem, SettingsList } from "@earendil-works/pi-tui";
2
+ import type { LlamaServer, ModelOverride } from "../../../interfaces/settings";
2
3
  import { FIELDS, FieldMessages, HINTS, TITLES } from "../../strings";
3
4
  import type { OverrideSettingsListOptions } from "../editorOptions";
4
5
  import { ListEditor } from "../listEditor";
5
6
  import { SettingsListFactory } from "../settingsListFactory";
6
7
  import { OverrideEntry } from "./entry";
7
- import { OverrideFields, OverrideSummary } from "./fields";
8
- import { OverrideEntryMutator } from "./handlers";
8
+ import { OverrideFields, OverrideSummary } from "./fields/index";
9
9
  import { OverrideItemBuilder } from "./itemBuilder";
10
10
 
11
11
  /**
@@ -31,21 +31,21 @@ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOpti
31
31
 
32
32
  /** Esc steps back to the server list, not out of the whole dialog —
33
33
  * `options.done` is the top-level close (shared options object). */
34
- protected close(): void {
34
+ protected override close(): void {
35
35
  this.done();
36
36
  }
37
37
 
38
38
  // -- abstract hooks -------------------------------------------------------
39
39
 
40
- protected buildSettingsList(): SettingsList {
40
+ protected override buildSettingsList(): SettingsList {
41
41
  const builder = new OverrideItemBuilder(this.dialogs);
42
- const items: SettingItem[] = this.mutator.entries().map((entry, i) => ({
42
+ const items: SettingItem[] = this.entries().map((entry, i) => ({
43
43
  id: `entry-${i}`,
44
44
  label: entry.pattern,
45
45
  description: HINTS.overrideEntryRow,
46
46
  currentValue: OverrideSummary.of(entry.override),
47
47
  submenu: (_cv, done) => {
48
- const current = this.mutator.entries()[i] ?? entry;
48
+ const current = this.entries()[i] ?? entry;
49
49
  const fieldItems = builder.buildFieldItems(current);
50
50
  const fieldList = SettingsListFactory.create(
51
51
  fieldItems,
@@ -75,7 +75,7 @@ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOpti
75
75
  field: string,
76
76
  value: string,
77
77
  ): void {
78
- const result = this.mutator.applyFieldChange(entryIndex, field, value);
78
+ const result = this.applyFieldChange(entryIndex, field, value);
79
79
  if (!result) return;
80
80
 
81
81
  this.commitFieldChange(
@@ -103,7 +103,7 @@ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOpti
103
103
  );
104
104
  }
105
105
 
106
- protected beginAdd(): void {
106
+ protected override beginAdd(): void {
107
107
  this.openDialog(
108
108
  this.dialogs.input({
109
109
  title: TITLES.addOverride,
@@ -119,9 +119,9 @@ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOpti
119
119
  );
120
120
  }
121
121
 
122
- protected deleteSelected(): void {
122
+ protected override deleteSelected(): void {
123
123
  const idx = this.selectedIndex;
124
- const next = this.mutator.removeEntry(idx);
124
+ const next = this.removeEntry(idx);
125
125
  void this.persistSnapshot(next, () => {
126
126
  this.rebuildList(idx);
127
127
  this.options.onChanged?.();
@@ -129,36 +129,118 @@ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOpti
129
129
  });
130
130
  }
131
131
 
132
- protected readonly emptyHintKey = "emptyOverrideEntries" as const;
132
+ protected override readonly emptyHintKey = "emptyOverrideEntries" as const;
133
133
 
134
- protected getCurrentCount(): number {
135
- return this.mutator.entries().length;
134
+ protected override getCount(): number {
135
+ return this.entries().length;
136
136
  }
137
137
 
138
- protected getRowId(index: number): string {
138
+ protected override getRowId(index: number): string {
139
139
  return `entry-${index}`;
140
140
  }
141
141
 
142
- protected getRowLabel(index: number): string {
143
- return this.mutator.entries()[index]?.pattern ?? "";
142
+ protected override getRowLabel(index: number): string {
143
+ return this.entries()[index]?.pattern ?? "";
144
144
  }
145
145
 
146
- protected get deleteTitle(): string {
146
+ protected override get deleteTitle(): string {
147
147
  return TITLES.deleteOverride;
148
148
  }
149
149
 
150
150
  // -- helpers ---------------------------------------------------------------
151
151
 
152
- /** Fresh mutator over the current servers snapshot — `persistSnapshot`
153
- * replaces `options.servers` in place, so instances can't be cached. */
154
- private get mutator(): OverrideEntryMutator {
155
- return new OverrideEntryMutator(this.options.servers, this.serverIndex);
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
+ }));
156
238
  }
157
239
 
158
240
  private async saveAdd(pattern: string): Promise<void> {
159
- const next = this.mutator.addEntry(pattern);
241
+ const next = this.addEntry(pattern);
160
242
  await this.persistSnapshot(next, () => {
161
- this.rebuildList(this.mutator.entries().length - 1);
243
+ this.rebuildList(this.entries().length - 1);
162
244
  this.options.onChanged?.();
163
245
  this.options.tui.requestRender();
164
246
  });
@@ -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
+ }