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,43 @@
1
+ /**
2
+ * Auth and stream plumbing for llama-server's SSE endpoint.
3
+ *
4
+ * llama-server validates the API key from the `Authorization` (or
5
+ * `X-Api-Key`) header only — it no longer accepts it as a query parameter —
6
+ * and `EventSource` cannot send custom headers. The stream is therefore
7
+ * consumed with `fetch` and parsed manually by {@link SSEClient}.
8
+ *
9
+ * `buildAuthHeaders` is shared by {@link SSEClient} and
10
+ * {@link SSEManager.probeSSE} so the two can't drift.
11
+ */
12
+
13
+ /**
14
+ * Builds the auth headers for a llama-server request, including the API key
15
+ * as an `Authorization: Bearer` header when one is set.
16
+ */
17
+ export const buildAuthHeaders = (apiKey?: string): Record<string, string> =>
18
+ apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
19
+
20
+ /**
21
+ * Opens the SSE stream at the given endpoint, authenticating via headers.
22
+ *
23
+ * @returns the response body as a stream of raw bytes
24
+ * @throws if the request fails or the server responds with an error status
25
+ */
26
+ export const openSSEStream = async (
27
+ endpoint: string,
28
+ apiKey?: string,
29
+ signal?: AbortSignal,
30
+ ): Promise<ReadableStream<Uint8Array>> => {
31
+ const response = await fetch(endpoint, {
32
+ method: "GET",
33
+ headers: buildAuthHeaders(apiKey),
34
+ signal,
35
+ });
36
+
37
+ if (!response.ok || !response.body) {
38
+ await response.body?.cancel();
39
+ throw new Error(`SSE connection failed: HTTP ${response.status}`);
40
+ }
41
+
42
+ return response.body;
43
+ };
@@ -1,5 +1,6 @@
1
1
  import type { Server } from "../server";
2
- import { SSEClient, buildSSEUrl } from "./client";
2
+ import { SSEClient } from "./client";
3
+ import { buildAuthHeaders } from "./fetch";
3
4
  import {
4
5
  DownloadProgressData,
5
6
  ProgressData,
@@ -14,7 +15,7 @@ import {
14
15
  * Manages SSE connections and event routing for a single llama-server instance.
15
16
  *
16
17
  * Handles:
17
- * - Shared EventSource connection
18
+ * - Shared SSE connection
18
19
  * - Model-based event subscription with callback aggregation
19
20
  * - Progress parsing and callback dispatch
20
21
  */
@@ -24,26 +25,7 @@ export class SSEManager {
24
25
  private modelCallbacks: Map<string, SSECallback[]> = new Map();
25
26
  private sseSupported: boolean | null = null;
26
27
 
27
- constructor(
28
- private readonly server: Server,
29
- private readonly apiKey: string,
30
- ) {}
31
-
32
- /**
33
- * Maximum time (ms) for server verification and SSE support probe.
34
- * Delegates to the owning {@link Server}.
35
- */
36
- async getServerTimeout(): Promise<number> {
37
- return this.server.getServerTimeout();
38
- }
39
-
40
- /**
41
- * Maximum time (ms) to wait for model loading before giving up.
42
- * Delegates to the owning {@link Server}.
43
- */
44
- async getPollingTimeout(): Promise<number> {
45
- return this.server.getPollingTimeout();
46
- }
28
+ constructor(private readonly server: Server) {}
47
29
 
48
30
  /**
49
31
  * The SSE endpoint URL.
@@ -62,10 +44,10 @@ export class SSEManager {
62
44
  if (this.sseSupported !== null) return this.sseSupported;
63
45
 
64
46
  try {
65
- const url = buildSSEUrl(this.sseEndpoint, this.apiKey);
66
- const response = await fetch(url, {
47
+ const response = await fetch(this.sseEndpoint, {
67
48
  method: "GET",
68
- signal: AbortSignal.timeout(await this.getServerTimeout()),
49
+ headers: buildAuthHeaders(this.server.getApiKey()),
50
+ signal: AbortSignal.timeout(await this.server.getServerTimeout()),
69
51
  });
70
52
  this.sseSupported =
71
53
  response.ok &&
@@ -96,7 +78,7 @@ export class SSEManager {
96
78
  this.modelCallbacks.set(modelId, callbacks);
97
79
 
98
80
  // Create SSE client if not already created
99
- this.sseClient ??= new SSEClient(this.sseEndpoint, this.apiKey);
81
+ this.sseClient ??= new SSEClient(this.sseEndpoint, this.server.getApiKey());
100
82
 
101
83
  // Subscribe a single dispatching callback to the SSE client
102
84
  if (!this.sseSubscribers.has(modelId)) {
@@ -181,7 +163,7 @@ export class SSEManager {
181
163
  */
182
164
  async subscribeToStatus(modelId: string): Promise<StatusChangeData> {
183
165
  return new Promise(async (resolve, reject) => {
184
- const pollingTimeout = await this.getPollingTimeout();
166
+ const pollingTimeout = await this.server.getPollingTimeout();
185
167
  const timeout = setTimeout(
186
168
  () => reject(new Error(`SSE status timeout for model: ${modelId}`)),
187
169
  pollingTimeout,
package/src/sse/types.ts CHANGED
@@ -8,10 +8,6 @@
8
8
  export const SSEEventType = {
9
9
  status_change: "status_change",
10
10
  download_progress: "download_progress",
11
- download_finished: "download_finished",
12
- download_failed: "download_failed",
13
- models_reload: "models_reload",
14
- model_remove: "model_remove",
15
11
  } as const;
16
12
 
17
13
  export type SSEEventType = (typeof SSEEventType)[keyof typeof SSEEventType];
@@ -0,0 +1,118 @@
1
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ Component,
4
+ Keybinding,
5
+ SelectListTheme,
6
+ TUI,
7
+ } from "@earendil-works/pi-tui";
8
+ import {
9
+ Container,
10
+ getKeybindings,
11
+ Spacer,
12
+ Text,
13
+ } from "@earendil-works/pi-tui";
14
+
15
+ /**
16
+ * Base dialog class encapsulating the shared Component plumbing (framed
17
+ * container, input delegation, themes) so concrete dialogs only declare
18
+ * their body and their input target — they don't repeat the
19
+ * `invalidate`/`render`/`handleInput` delegation code.
20
+ */
21
+ export abstract class BaseDialog {
22
+ protected theme: Theme;
23
+ protected tui: TUI;
24
+ /** The framed container every concrete dialog renders into. */
25
+ private readonly frame = new Container();
26
+
27
+ constructor(theme: Theme, tui: TUI) {
28
+ this.theme = theme;
29
+ this.tui = tui;
30
+ }
31
+
32
+ // -- Component -------------------------------------------------------------
33
+
34
+ invalidate(): void {
35
+ this.frame.invalidate();
36
+ }
37
+
38
+ /** Routes input to the concrete dialog's interactive child. */
39
+ handleInput(data: string): void {
40
+ this.inputTarget.handleInput(data);
41
+ this.tui.requestRender();
42
+ }
43
+
44
+ render(width: number): string[] {
45
+ return this.frame.render(width);
46
+ }
47
+
48
+ // -- Focusable -------------------------------------------------------------
49
+
50
+ private isFocused = false;
51
+
52
+ get focused(): boolean {
53
+ return this.isFocused;
54
+ }
55
+
56
+ set focused(value: boolean) {
57
+ this.isFocused = value;
58
+ // Forward to the interactive child when it manages focus (e.g. the
59
+ // text `Input`); plain select lists don't have a `focused` flag.
60
+ const target = this.inputTarget as { focused?: boolean };
61
+ if ("focused" in target) target.focused = value;
62
+ }
63
+
64
+ // -- shared utilities ------------------------------------------------------
65
+
66
+ /** The component keystrokes are routed to (e.g. the text input or the
67
+ * option list). */
68
+ protected abstract get inputTarget(): {
69
+ handleInput(data: string): void;
70
+ };
71
+
72
+ /**
73
+ * Builds the framed chrome (borders, title, footer) around `body`.
74
+ * Concrete dialogs call this once from their constructor, after
75
+ * assembling the body.
76
+ */
77
+ protected setFrame(title: string, body: Component[], footer?: string): void {
78
+ this.frame.addChild(
79
+ new DynamicBorder((text) => this.theme.fg("accent", text)),
80
+ );
81
+ this.frame.addChild(
82
+ new Text(this.theme.fg("accent", this.theme.bold(title)), 1, 0),
83
+ );
84
+ for (const child of body) this.frame.addChild(child);
85
+ if (footer) {
86
+ this.frame.addChild(new Spacer(1));
87
+ this.frame.addChild(new Text(this.theme.fg("dim", footer), 1, 0));
88
+ }
89
+ this.frame.addChild(
90
+ new DynamicBorder((text) => this.theme.fg("accent", text)),
91
+ );
92
+ }
93
+
94
+ protected selectListTheme(): SelectListTheme {
95
+ return {
96
+ selectedPrefix: (text) => this.theme.fg("accent", text),
97
+ selectedText: (text) => this.theme.fg("accent", text),
98
+ description: (text) => this.theme.fg("muted", text),
99
+ scrollInfo: (text) => this.theme.fg("dim", text),
100
+ noMatch: (text) => this.theme.fg("warning", text),
101
+ };
102
+ }
103
+
104
+ protected hint(action: Keybinding, description: string): string {
105
+ const keys = getKeybindings().getKeys(action).join("/");
106
+ return (
107
+ this.theme.fg("dim", keys) + this.theme.fg("muted", ` ${description}`)
108
+ );
109
+ }
110
+
111
+ protected inputFooter(): string {
112
+ return `${this.hint("tui.select.confirm", "save")} • ${this.hint("tui.select.cancel", "cancel")}`;
113
+ }
114
+
115
+ protected selectFooter(): string {
116
+ return `${this.hint("tui.select.confirm", "select")} • ${this.hint("tui.select.cancel", "cancel")}`;
117
+ }
118
+ }
@@ -0,0 +1,45 @@
1
+ import { SelectList, Spacer, Text } from "@earendil-works/pi-tui";
2
+ import { BaseDialog } from "./base";
3
+ import type { ConfirmDialogOptions } from "./options";
4
+
5
+ /**
6
+ * A framed confirmation dialog with a two-option `SelectList`
7
+ * (Delete / Cancel), matching the confirm pattern of pi's `/llama` view.
8
+ */
9
+ export class ConfirmDialog extends BaseDialog {
10
+ private readonly list: SelectList;
11
+
12
+ constructor(options: ConfirmDialogOptions) {
13
+ super(options.theme, options.tui);
14
+
15
+ this.list = new SelectList(
16
+ [
17
+ { value: "confirm", label: "Delete" },
18
+ { value: "cancel", label: "Cancel" },
19
+ ],
20
+ 2,
21
+ this.selectListTheme(),
22
+ );
23
+ this.list.onSelect = (item) => {
24
+ if (item.value === "confirm") options.onConfirm();
25
+ else options.onCancel();
26
+ };
27
+ this.list.onCancel = () => options.onCancel();
28
+
29
+ this.setFrame(
30
+ options.title,
31
+ [
32
+ new Text(this.theme.fg("text", options.message), 1, 0),
33
+ new Spacer(1),
34
+ this.list,
35
+ ],
36
+ this.selectFooter(),
37
+ );
38
+ }
39
+
40
+ // -- helpers -------------------------------------------------------------------
41
+
42
+ protected get inputTarget() {
43
+ return this.list;
44
+ }
45
+ }
@@ -0,0 +1,111 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { SettingItem, TUI } from "@earendil-works/pi-tui";
3
+ import { ConfirmDialog } from "./confirm";
4
+ import { InputDialog } from "./input";
5
+ import type { ConfirmDialogOptions, InputDialogOptions } from "./options";
6
+
7
+ /** `InputDialog` options minus the deps the factory injects. */
8
+ type InputOptions = Omit<InputDialogOptions, "theme" | "tui">;
9
+
10
+ /** `ConfirmDialog` options minus the deps the factory injects. */
11
+ type ConfirmOptions = Omit<ConfirmDialogOptions, "theme" | "tui">;
12
+
13
+ /**
14
+ * Single factory for constructing dialogs. Holds `theme` and `tui` once
15
+ * in the constructor so callers never thread the `{ theme, tui }` deps
16
+ * tuple by hand, and so all dialog construction idioms (raw constructors,
17
+ * static factories, per-field submenu builders) live in one place.
18
+ *
19
+ * Usage:
20
+ * ```ts
21
+ * const dialogs = new DialogFactory(theme, tui);
22
+ * dialogs.input({ title, message, onSubmit, onCancel });
23
+ * dialogs.confirmDelete(TITLES.deleteServer, name, onConfirm, onCancel);
24
+ * dialogs.inputSubmenu(TITLES.edit(label), message, placeholder, validate);
25
+ * ```
26
+ */
27
+ export class DialogFactory {
28
+ constructor(
29
+ private readonly theme: Theme,
30
+ /** Used internally to request re-renders after async dialog commits
31
+ * (e.g. the wizard). */
32
+ private readonly tui: TUI,
33
+ ) {}
34
+
35
+ /** Requests a UI re-render. Used by callers that need to update the
36
+ * display asynchronously after a dialog commit. */
37
+ requestRender(): void {
38
+ this.tui.requestRender();
39
+ }
40
+
41
+ /** Creates a framed text-input dialog: Enter commits, Esc cancels. */
42
+ input(options: InputOptions): InputDialog {
43
+ return new InputDialog({ ...options, theme: this.theme, tui: this.tui });
44
+ }
45
+
46
+ /** Creates a framed confirmation dialog with a two-option select list. */
47
+ confirm(options: ConfirmOptions): ConfirmDialog {
48
+ return new ConfirmDialog({
49
+ ...options,
50
+ theme: this.theme,
51
+ tui: this.tui,
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Creates a delete confirmation dialog.
57
+ *
58
+ * @param title — Dialog title (e.g. `TITLES.deleteServer` or `TITLES.deleteOverride`).
59
+ * @param name — The name of the entry being deleted.
60
+ * @param onConfirm — Callback when the user confirms.
61
+ * @param onCancel — Callback when the user cancels.
62
+ */
63
+ confirmDelete(
64
+ title: string,
65
+ name: string,
66
+ onConfirm: () => void,
67
+ onCancel: () => void,
68
+ ): ConfirmDialog {
69
+ return this.confirm({
70
+ title,
71
+ message: `Delete "${name}"?`,
72
+ onConfirm,
73
+ onCancel,
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Builds a `SettingItem.submenu` factory that opens an `InputDialog`
79
+ * (absorbs the former `InputSubmenuFactory`).
80
+ *
81
+ * The produced submenu prefills from the `currentValue` the containing
82
+ * `SettingsList` passes at activation time — not a value captured at
83
+ * build time — so re-entering a field after a commit shows the
84
+ * just-saved value.
85
+ *
86
+ * @param title — Dialog title (e.g. `TITLES.edit(field.label)`).
87
+ * @param message — Prompt shown inside the dialog.
88
+ * @param placeholder — Optional example value shown dim in the dialog.
89
+ * @param validate — Optional validation function for committed values.
90
+ */
91
+ inputSubmenu(
92
+ title: string,
93
+ message: string,
94
+ placeholder?: string,
95
+ validate?: (raw: string) => string | null,
96
+ ): SettingItem["submenu"] {
97
+ return (currentValue, done) => {
98
+ const dialog = this.input({
99
+ title,
100
+ message,
101
+ placeholder,
102
+ initialValue: currentValue,
103
+ validate,
104
+ onSubmit: (value) => done(value),
105
+ onCancel: () => done(undefined),
106
+ });
107
+ dialog.focused = true;
108
+ return dialog;
109
+ };
110
+ }
111
+ }
@@ -0,0 +1,63 @@
1
+ import { Container, Input, Text } from "@earendil-works/pi-tui";
2
+ import { BaseDialog } from "./base";
3
+ import type { InputDialogOptions } from "./options";
4
+
5
+ /**
6
+ * Framed text-input dialog: Enter commits the validated value, Esc cancels.
7
+ */
8
+ export class InputDialog extends BaseDialog {
9
+ private readonly options: InputDialogOptions;
10
+ private readonly body = new Container();
11
+ private readonly input = new Input();
12
+ private errorText: Text | undefined;
13
+
14
+ constructor(options: InputDialogOptions) {
15
+ super(options.theme, options.tui);
16
+ this.options = options;
17
+
18
+ const initial = options.initialValue ?? "";
19
+ this.input.setValue(initial);
20
+ for (let i = 0; i < [...initial].length; i++) {
21
+ this.input.handleInput("\x1b[C");
22
+ }
23
+ this.input.onSubmit = (value) => this.submit(value);
24
+ this.input.onEscape = () => options.onCancel();
25
+
26
+ this.body.addChild(new Text(this.theme.fg("text", options.message), 1, 0));
27
+ if (options.placeholder) {
28
+ this.body.addChild(
29
+ new Text(this.theme.fg("dim", `e.g., ${options.placeholder}`), 1, 0),
30
+ );
31
+ }
32
+ this.body.addChild(this.input);
33
+
34
+ this.setFrame(options.title, [this.body], this.inputFooter());
35
+ }
36
+
37
+ // -- helpers -------------------------------------------------------------------
38
+
39
+ protected get inputTarget() {
40
+ return this.input;
41
+ }
42
+
43
+ private submit(raw: string): void {
44
+ const validated = this.options.validate ? this.options.validate(raw) : raw;
45
+ if (validated === null) {
46
+ this.setError(`Invalid value "${raw}"`);
47
+ return;
48
+ }
49
+ this.options.onSubmit(validated);
50
+ }
51
+
52
+ private setError(message: string | undefined): void {
53
+ if (this.errorText) {
54
+ this.body.removeChild(this.errorText);
55
+ this.errorText = undefined;
56
+ }
57
+ if (message) {
58
+ this.errorText = new Text(this.theme.fg("error", message), 1, 0);
59
+ this.body.addChild(this.errorText);
60
+ }
61
+ this.tui.requestRender();
62
+ }
63
+ }
@@ -0,0 +1,23 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { TUI } from "@earendil-works/pi-tui";
3
+
4
+ export interface ConfirmDialogOptions {
5
+ theme: Theme;
6
+ tui: TUI;
7
+ title: string;
8
+ message: string;
9
+ onConfirm: () => void;
10
+ onCancel: () => void;
11
+ }
12
+
13
+ export interface InputDialogOptions {
14
+ theme: Theme;
15
+ tui: TUI;
16
+ title: string;
17
+ message: string;
18
+ placeholder?: string;
19
+ initialValue?: string;
20
+ validate?: (raw: string) => string | null;
21
+ onSubmit: (value: string) => void;
22
+ onCancel: () => void;
23
+ }
@@ -0,0 +1,43 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { KeybindingsManager, TUI } from "@earendil-works/pi-tui";
3
+ import type { LlamaServer } from "../../interfaces/settings";
4
+
5
+ /**
6
+ * Shared options for SettingsList-based editors that operate on
7
+ * `LlamaServer[]`. Subtypes add editor-specific fields.
8
+ */
9
+ export interface SettingsListEditorOptions {
10
+ /** TUI instance, used to request re-renders after async persists */
11
+ tui: TUI;
12
+ /** Theme for dialogs (from the ctx.ui.custom factory) */
13
+ theme: Theme;
14
+ /** App keybindings manager (injected by ctx.ui.custom) */
15
+ keybindings: KeybindingsManager;
16
+ /**
17
+ * Snapshot of the merged `llamaSettings.servers` to edit. Replaced
18
+ * in place (`options.servers = next`) after every successful persist.
19
+ */
20
+ servers: LlamaServer[];
21
+ /** Persists a new server list; a rejection keeps the current list */
22
+ persist: (next: LlamaServer[]) => Promise<void>;
23
+ /** Closes the editor (called on Esc in the server list) */
24
+ done: () => void;
25
+ /** Notifies about persistence errors */
26
+ onError: (message: string) => void;
27
+ }
28
+
29
+ /**
30
+ * Options for the SettingsList-based server editor.
31
+ */
32
+ export interface ServerSettingsListOptions extends SettingsListEditorOptions {
33
+ /** Timeout (ms) for health checks. Defaults to 1000ms if not provided. */
34
+ serverTimeout?: number;
35
+ }
36
+
37
+ /**
38
+ * Options for the SettingsList-based overrides editor.
39
+ */
40
+ export interface OverrideSettingsListOptions extends SettingsListEditorOptions {
41
+ /** Called after a successful add/edit/delete */
42
+ onChanged: () => void;
43
+ }
@@ -0,0 +1,47 @@
1
+ import type { SettingItem } from "@earendil-works/pi-tui";
2
+ import type { DialogFactory } from "../dialog/factory";
3
+ import type { SettingField } from "./settingField";
4
+
5
+ /**
6
+ * Base class for building `SettingItem` arrays from a declarative field
7
+ * registry (Template Method): {@link buildFieldItems} maps the ordered
8
+ * {@link fields} registry over {@link buildItem}, which fills the common
9
+ * columns from the field and delegates field-type-specific columns (e.g.
10
+ * submenu or fixed values) to {@link decorate}.
11
+ *
12
+ * Injects a {@link DialogFactory} once in the constructor so subclasses
13
+ * can build input submenus without threading `theme`/`tui` around.
14
+ */
15
+ export abstract class ItemBuilder<TEntry, TField extends SettingField<TEntry>> {
16
+ constructor(protected readonly dialogs: DialogFactory) {}
17
+
18
+ /** The ordered field registry; order defines the row order in the list. */
19
+ protected abstract get fields(): readonly TField[];
20
+
21
+ /**
22
+ * Builds the `SettingItem` for one field of an entry: fills the common
23
+ * columns from the field, then hands the partial item to
24
+ * {@link decorate}.
25
+ */
26
+ private buildItem(def: TField, entry: TEntry): SettingItem {
27
+ const base: SettingItem = {
28
+ id: def.id,
29
+ label: def.label,
30
+ description: def.description,
31
+ currentValue: def.currentValue(entry),
32
+ };
33
+ return this.decorate(def, entry, base);
34
+ }
35
+
36
+ /** Builds the `SettingItem` array for one entry's editable fields. */
37
+ buildFieldItems(entry: TEntry): SettingItem[] {
38
+ return this.fields.map((def) => this.buildItem(def, entry));
39
+ }
40
+
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;
47
+ }