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,64 @@
1
+ import type { ModelCost, ModelCostRates } from "@earendil-works/pi-ai";
2
+ import type { ModelOverride } from "../../../../interfaces/settings";
3
+ import type { Field } from "../../../strings";
4
+ import { OverrideEntry } from "../entry";
5
+ import type { InputValidator } from "./base";
6
+ import { OverrideField } from "./base";
7
+ import { parseNonNegative } from "./numeric";
8
+
9
+ /**
10
+ * One `ModelCostRates` key of the override's `cost` object (absorbs the
11
+ * former `CostFieldDefFactory` and the standalone `parseCostValue` /
12
+ * `applyCostFieldValue` helpers): owns the cost parse and immutable
13
+ * apply in the same place as its display format.
14
+ */
15
+ export class CostField extends OverrideField {
16
+ readonly type = "input";
17
+ override readonly field: Field;
18
+
19
+ constructor(
20
+ readonly id: string,
21
+ private readonly key: keyof ModelCostRates,
22
+ field: Field,
23
+ /** Short term used in the entry-row summary (e.g. `cache read`). */
24
+ private readonly summaryTerm: string,
25
+ ) {
26
+ super();
27
+ this.field = field;
28
+ }
29
+
30
+ displayValue(entry: OverrideEntry): string {
31
+ return String(entry.override.cost?.[this.key] ?? 0);
32
+ }
33
+
34
+ readonly validate: InputValidator = (raw) => {
35
+ const parsed = parseNonNegative(raw);
36
+ return parsed === null ? null : String(parsed);
37
+ };
38
+
39
+ apply(override: ModelOverride, value: string): void {
40
+ const nextCost = this.applyCost(override.cost, Number(value));
41
+ if (nextCost) override.cost = nextCost;
42
+ else delete override.cost;
43
+ }
44
+
45
+ /** Returns a copy of `cost` with this field set to `value`. A `0` (or
46
+ * non-finite) value removes the field — unset cost fields default to
47
+ * zero at read time — and when no fields remain, returns `undefined`
48
+ * so the whole `cost` object can be dropped. Immutable. */
49
+ private applyCost(
50
+ cost: ModelOverride["cost"],
51
+ value: number,
52
+ ): Partial<ModelCost> | undefined {
53
+ const next = { ...cost };
54
+ if (isFinite(value) && value > 0) next[this.key] = value;
55
+ else delete next[this.key];
56
+ return Object.keys(next).length > 0 ? next : undefined;
57
+ }
58
+
59
+ /** Appears in the summary only when a non-zero cost is set. */
60
+ override summaryPart(override: ModelOverride): string | null {
61
+ const value = override.cost?.[this.key];
62
+ return value ? `${this.summaryTerm}: $${value}` : null;
63
+ }
64
+ }
@@ -0,0 +1,68 @@
1
+ import type { ModelCostRates } from "@earendil-works/pi-ai";
2
+ import type { ModelOverride } from "../../../../interfaces/settings";
3
+ import { FIELDS } from "../../../strings";
4
+ import { OverrideField } from "./base";
5
+ import { CapabilitiesField } from "./capabilities";
6
+ import { CostField } from "./cost";
7
+ import { NumericField } from "./numeric";
8
+ import { PatternField } from "./pattern";
9
+ import { ReasoningField } from "./reasoning";
10
+
11
+ /** Cost field specs: [ModelCostRates key, FIELDS entry, summary term] */
12
+ const COST_FIELDS: [
13
+ keyof ModelCostRates,
14
+ (
15
+ | typeof FIELDS.inputCost
16
+ | typeof FIELDS.outputCost
17
+ | typeof FIELDS.cacheReadCost
18
+ | typeof FIELDS.cacheWriteCost
19
+ ),
20
+ string,
21
+ ][] = [
22
+ ["input", FIELDS.inputCost, "input"],
23
+ ["output", FIELDS.outputCost, "output"],
24
+ ["cacheRead", FIELDS.cacheReadCost, "cache read"],
25
+ ["cacheWrite", FIELDS.cacheWriteCost, "cache write"],
26
+ ];
27
+
28
+ /**
29
+ * The field registry for model override entries: the complete list of
30
+ * editable fields (order defines the row order in the settings list)
31
+ * plus id-based lookup. Throws on an unknown id — the registry is
32
+ * static, so a miss is a programming error.
33
+ */
34
+ export class OverrideFields {
35
+ static readonly all: OverrideField[] = [
36
+ new PatternField(),
37
+ ...COST_FIELDS.map(
38
+ ([key, field, term]) => new CostField(`cost.${key}`, key, field, term),
39
+ ),
40
+ new CapabilitiesField(),
41
+ new ReasoningField(),
42
+ new NumericField("contextSize", FIELDS.contextSize),
43
+ new NumericField("maxTokens", FIELDS.maxTokens),
44
+ ];
45
+
46
+ static byId(id: string): OverrideField {
47
+ const field = this.all.find((f) => f.id === id);
48
+ if (!field) throw new Error(`Unknown override field: ${id}`);
49
+ return field;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Builds the entry-row summary shown in the override entry list
55
+ * (`input: $0.2, output: $0.6, cache read: $0.01, …`), composed from the
56
+ * {@link OverrideFields} registry — adding a field to the registry makes
57
+ * it show up here too, so the summary can't lag the edit menu. Fields
58
+ * with unset/zero values are omitted for brevity (each field decides via
59
+ * {@link OverrideField.summaryPart}); `—` when nothing is set.
60
+ */
61
+ export class OverrideSummary {
62
+ static of(override: ModelOverride): string {
63
+ const parts = OverrideFields.all
64
+ .map((field) => field.summaryPart(override))
65
+ .filter((part): part is string => part !== null);
66
+ return parts.length > 0 ? parts.join(", ") : "—";
67
+ }
68
+ }
@@ -0,0 +1,55 @@
1
+ import type { ModelOverride } from "../../../../interfaces/settings";
2
+ import type { Field } from "../../../strings";
3
+ import { OverrideEntry } from "../entry";
4
+ import type { InputValidator } from "./base";
5
+ import { OverrideField } from "./base";
6
+
7
+ /** Parses a non-negative number; blank input means zero (unset fields
8
+ * default to zero at read time). Returns `null` when invalid. */
9
+ export function parseNonNegative(raw: string): number | null {
10
+ const n = Number(raw.trim());
11
+ return isFinite(n) && n >= 0 ? n : null;
12
+ }
13
+
14
+ /**
15
+ * Non-negative numeric field (`contextSize`, `maxTokens`): keeps the
16
+ * display format, validator and apply logic in a single place so the
17
+ * three can't drift (e.g. the validator accepting `0` while the apply
18
+ * drops it — unset fields default to zero at read time, so all render
19
+ * as `0`).
20
+ */
21
+ export class NumericField extends OverrideField {
22
+ readonly type = "input";
23
+ override readonly field: Field;
24
+
25
+ constructor(
26
+ readonly id: "maxTokens" | "contextSize",
27
+ field: Field,
28
+ ) {
29
+ super();
30
+ this.field = field;
31
+ }
32
+
33
+ displayValue(entry: OverrideEntry): string {
34
+ return String(entry.override[this.id] ?? 0);
35
+ }
36
+
37
+ /** Accepts finite, non-negative numbers (blank means zero). */
38
+ readonly validate: InputValidator = (raw) => {
39
+ const n = parseNonNegative(raw);
40
+ return n === null ? null : String(n);
41
+ };
42
+
43
+ /** Applies the committed value; non-positive values remove the key. */
44
+ apply(override: ModelOverride, value: string): void {
45
+ const n = Number(value);
46
+ if (isFinite(n) && n > 0) override[this.id] = n;
47
+ else delete override[this.id];
48
+ }
49
+
50
+ /** Appears in the summary only when set. */
51
+ override summaryPart(override: ModelOverride): string | null {
52
+ const value = override[this.id];
53
+ return value === undefined ? null : `${this.id}: ${value}`;
54
+ }
55
+ }
@@ -0,0 +1,24 @@
1
+ import { FIELDS } from "../../../strings";
2
+ import { OverrideEntry } from "../entry";
3
+ import type { InputValidator } from "./base";
4
+ import { OverrideField } from "./base";
5
+
6
+ /**
7
+ * The entry's pattern key. Renames are handled by the editor
8
+ * (`OverrideEntryListEditor.applyFieldChange`), so {@link apply}
9
+ * inherits the base no-op.
10
+ */
11
+ export class PatternField extends OverrideField {
12
+ readonly id = "pattern";
13
+ readonly type = "input";
14
+ readonly field = FIELDS.pattern;
15
+
16
+ displayValue(entry: OverrideEntry): string {
17
+ return entry.pattern;
18
+ }
19
+
20
+ readonly validate: InputValidator = (raw) => {
21
+ const trimmed = raw.trim();
22
+ return trimmed.length > 0 ? trimmed : null;
23
+ };
24
+ }
@@ -0,0 +1,33 @@
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 `reasoning` flag (finite: true/false, default true). */
8
+ export class ReasoningField extends OverrideField {
9
+ readonly id = "reasoning";
10
+ readonly type = "finite";
11
+ readonly field = FIELDS.reasoning;
12
+ override readonly options = ["true", "false"];
13
+
14
+ displayValue(entry: OverrideEntry): string {
15
+ return entry.override.reasoning === false ? "false" : "true";
16
+ }
17
+
18
+ readonly validate: InputValidator = (raw) =>
19
+ this.options?.includes(raw) ? raw : null;
20
+
21
+ apply(override: ModelOverride, value: string): void {
22
+ if (value === "true") override.reasoning = true;
23
+ else if (value === "false") override.reasoning = false;
24
+ else delete override.reasoning;
25
+ }
26
+
27
+ /** Appears in the summary only when explicitly set. */
28
+ override summaryPart(override: ModelOverride): string | null {
29
+ return override.reasoning === undefined
30
+ ? null
31
+ : `reasoning: ${override.reasoning}`;
32
+ }
33
+ }
@@ -0,0 +1,39 @@
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 type { OverrideField } from "./fields/base";
6
+ import { OverrideFields } from "./fields/index";
7
+
8
+ /**
9
+ * Builds `SettingItem` objects for override entry field submenus.
10
+ */
11
+ export class OverrideItemBuilder extends ItemBuilder<
12
+ OverrideEntry,
13
+ OverrideField
14
+ > {
15
+ protected get fields(): readonly OverrideField[] {
16
+ return OverrideFields.all;
17
+ }
18
+
19
+ /**
20
+ * Adds field-type-specific columns: fixed `values` for "finite" fields,
21
+ * an `InputDialog` submenu (validating against the field definition)
22
+ * for "input" fields.
23
+ */
24
+ protected decorate(def: OverrideField, base: SettingItem): SettingItem {
25
+ if (def.type === "finite") {
26
+ return { ...base, values: [...(def.options ?? [])] };
27
+ }
28
+
29
+ return {
30
+ ...base,
31
+ submenu: this.dialogs.inputSubmenu(
32
+ TITLES.edit(def.label),
33
+ FieldMessages.of(def.field),
34
+ def.placeholder,
35
+ def.validate,
36
+ ),
37
+ };
38
+ }
39
+ }
@@ -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
+ }
@@ -0,0 +1,84 @@
1
+ import type { LlamaServer } from "../../../interfaces/settings";
2
+ import { ServerUrl } from "../../../utils/urls";
3
+ import { FIELDS, type Field } from "../../strings";
4
+ import { SettingField } from "../settingField";
5
+ import { LlamaServerBuilder } from "./builder";
6
+
7
+ /**
8
+ * Base class for one editable field of a server row. `apply` delegates
9
+ * to {@link LlamaServerBuilder} so the unset-on-empty rule for `id`/
10
+ * `name` stays in one place. Mirrors the override side's
11
+ * `OverrideField`.
12
+ */
13
+ export abstract class ServerField extends SettingField<LlamaServer> {
14
+ /** Validation function for committed values (`undefined` = accept
15
+ * any). */
16
+ readonly validate: ((raw: string) => string | null) | undefined = undefined;
17
+
18
+ /** Applies a committed value to the server, immutably (returns the
19
+ * updated copy). */
20
+ abstract apply(server: LlamaServer, value: string): LlamaServer;
21
+ }
22
+
23
+ /** The server URL (required, validated). */
24
+ class UrlField extends ServerField {
25
+ readonly id = "url";
26
+ readonly field = FIELDS.serverUrl;
27
+ readonly validate = ServerUrl.parse;
28
+
29
+ currentValue(server: LlamaServer): string {
30
+ return server.url;
31
+ }
32
+
33
+ apply(server: LlamaServer, value: string): LlamaServer {
34
+ return LlamaServerBuilder.from(server).url(value).build();
35
+ }
36
+ }
37
+
38
+ /**
39
+ * An optional string field (`id`, `name`). Empty input omits the key —
40
+ * when editing an existing server this removes it, restoring the
41
+ * URL-based auto-detected id for `id`.
42
+ */
43
+ class OptionalTextField extends ServerField {
44
+ readonly field: Field;
45
+
46
+ constructor(
47
+ readonly id: "id" | "name",
48
+ field: Field,
49
+ ) {
50
+ super();
51
+ this.field = field;
52
+ }
53
+
54
+ currentValue(server: LlamaServer): string {
55
+ return server[this.id] ?? "";
56
+ }
57
+
58
+ apply(server: LlamaServer, value: string): LlamaServer {
59
+ const builder = LlamaServerBuilder.from(server);
60
+ return this.id === "id"
61
+ ? builder.optionalId(value).build()
62
+ : builder.optionalName(value).build();
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The field registry for servers: the complete list of editable fields
68
+ * (order defines the row order in the settings list) plus id-based
69
+ * lookup. Throws on an unknown id — the registry is static, so a miss is
70
+ * a programming error.
71
+ */
72
+ export class ServerFields {
73
+ static readonly all: readonly ServerField[] = [
74
+ new UrlField(),
75
+ new OptionalTextField("id", FIELDS.providerId),
76
+ new OptionalTextField("name", FIELDS.displayName),
77
+ ];
78
+
79
+ static byId(id: string): ServerField {
80
+ const field = this.all.find((f) => f.id === id);
81
+ if (!field) throw new Error(`Unknown server field: ${id}`);
82
+ return field;
83
+ }
84
+ }
@@ -0,0 +1,64 @@
1
+ import type { SettingItem } from "@earendil-works/pi-tui";
2
+ import type { LlamaServer } from "../../../interfaces/settings";
3
+ import { FieldMessages, HINTS, TITLES } from "../../strings";
4
+ import { ItemBuilder } from "../itemBuilder";
5
+ import { SettingsListFactory } from "../settingsListFactory";
6
+ import { ServerFields, type ServerField } from "./fields";
7
+ import { ServerDisplay } from "./utils";
8
+
9
+ /**
10
+ * Builds `SettingItem` objects for server rows and their field-edit submenus.
11
+ */
12
+ export class ServerItemBuilder extends ItemBuilder<LlamaServer, ServerField> {
13
+ protected get fields(): readonly ServerField[] {
14
+ return ServerFields.all;
15
+ }
16
+
17
+ /**
18
+ * Adds the field-edit submenu: an `InputDialog` validating against the
19
+ * field definition.
20
+ */
21
+ protected decorate(def: ServerField, base: SettingItem): SettingItem {
22
+ return {
23
+ ...base,
24
+ submenu: this.dialogs.inputSubmenu(
25
+ TITLES.edit(def.label),
26
+ FieldMessages.of(def.field),
27
+ def.placeholder,
28
+ def.validate,
29
+ ),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Builds the `SettingItem` for one server row in the top-level list.
35
+ * Enter drills into the server's field-edit submenu.
36
+ */
37
+ buildRow(
38
+ server: LlamaServer,
39
+ index: number,
40
+ onChange: (field: string, value: string) => void,
41
+ healthEmoji: string,
42
+ onSubmenuChange: (open: boolean) => void,
43
+ ): SettingItem {
44
+ return {
45
+ id: `server-${index}`,
46
+ label: `${healthEmoji} ${server.url}`,
47
+ description: HINTS.serverRow,
48
+ currentValue: ServerDisplay.suffix(server),
49
+ submenu: (_cv, done) => {
50
+ // Rebuild the field items on open so they prefill with current values
51
+ const items = this.buildFieldItems(server);
52
+ onSubmenuChange(true);
53
+ return SettingsListFactory.create(
54
+ items,
55
+ () => {
56
+ onSubmenuChange(false);
57
+ done();
58
+ },
59
+ onChange,
60
+ );
61
+ },
62
+ };
63
+ }
64
+ }