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,197 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import type { SettingItem, SettingsList } from "@earendil-works/pi-tui";
3
+ import { SERVER_TIMEOUT } from "../../../constants";
4
+ import type { LlamaServer } from "../../../interfaces/settings";
5
+ import type { LlamaSettingsManager } from "../../../managers/settings";
6
+ import { ServerIds } from "../../../utils/serverIds";
7
+ import { TITLES, authRequiredMessage } from "../../strings";
8
+ import type { ServerSettingsListOptions } from "../editorOptions";
9
+ import { ListEditor } from "../listEditor";
10
+ import { SettingsListFactory } from "../settingsListFactory";
11
+ import { ServerFields } from "./fields";
12
+ import { ServerItemBuilder } from "./itemBuilder";
13
+ import { ServerDisplay } from "./utils";
14
+ import { ServerWizard } from "./wizard";
15
+
16
+ /**
17
+ * Wrapper around a `SettingsList` of servers that adds `a` (add) and `d`
18
+ * (delete) support at the list level.
19
+ *
20
+ * - **a** opens the add wizard — a sequence of framed `InputDialog`s
21
+ * (URL → optional ID → optional name, mirroring `/login`'s sequential
22
+ * prompts). Esc at any step aborts without persisting; the server is
23
+ * saved only after the final step.
24
+ * - **d** opens a `ConfirmDialog` (Delete/Cancel select list).
25
+ * - **Enter** on a server row drills into its field-edit submenu.
26
+ * - **Esc** closes the editor.
27
+ */
28
+ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
29
+ constructor(options: ServerSettingsListOptions) {
30
+ super(options);
31
+ void this.buildSettingsList().then((list) => {
32
+ this.settingsList = list;
33
+ this.options.tui.requestRender();
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Opens the editor in a modal `ui.custom` dialog seeded with the
39
+ * current `llamaSettings.servers` and resolves when the user closes
40
+ * it (Esc). Writes go through `settings.setLlamaSetting()`; write
41
+ * errors are notified via `ui` and the editor stays open with the
42
+ * pre-mutation list.
43
+ */
44
+ static async show(
45
+ ui: ExtensionUIContext,
46
+ settings: LlamaSettingsManager,
47
+ ): Promise<void> {
48
+ const servers = await settings.getLlamaServers();
49
+ const { serverTimeout } = await settings.resolveTimeouts();
50
+
51
+ await ui.custom<void>(
52
+ (tui, theme, keybindings, done) =>
53
+ new ServerSettingsList({
54
+ tui,
55
+ theme,
56
+ keybindings,
57
+ servers,
58
+ persist: (next) => settings.setLlamaSetting("servers", next),
59
+ done: () => done(undefined),
60
+ onError: (message) => ui.notify(message, "error"),
61
+ serverTimeout,
62
+ authResolver: async (server) => {
63
+ // Mirror Server.getApiKey(): use ServerIds.resolve to get
64
+ // the provider ID (customId or llama-server=<url>), then
65
+ // resolve the key from the credential store.
66
+ return settings.resolveApiKey(
67
+ ServerIds.resolve(server.url, server.id),
68
+ );
69
+ },
70
+ ui,
71
+ }),
72
+ );
73
+ }
74
+
75
+ // -- abstract hooks -------------------------------------------------------
76
+
77
+ protected async buildSettingsList(): Promise<SettingsList> {
78
+ const builder = new ServerItemBuilder(this.dialogs);
79
+ const serverTimeout = this.options.serverTimeout ?? SERVER_TIMEOUT;
80
+ // Run auth + health probes in parallel; ⛔ wins if auth fails,
81
+ // otherwise fall back to the health emoji.
82
+ const probes = await Promise.all(
83
+ this.options.servers.map(async (server) => {
84
+ const apiKey = (await this.options.authResolver?.(server)) ?? "";
85
+ const [authEmoji, healthEmoji] = await Promise.all([
86
+ ServerDisplay.authEmoji(server.url, apiKey, serverTimeout),
87
+ ServerDisplay.healthEmoji(server.url, serverTimeout),
88
+ ]);
89
+ return authEmoji || healthEmoji;
90
+ }),
91
+ );
92
+ const items: SettingItem[] = this.options.servers.map((server, i) =>
93
+ builder.buildRow(
94
+ server,
95
+ i,
96
+ (field, value) => this.handleFieldChange(field, value),
97
+ probes[i],
98
+ // Opening delegates input to the submenu; closing flushes a
99
+ // rebuild deferred by a field commit (see commitFieldChange)
100
+ (open) => this.trackSubmenu(open),
101
+ ),
102
+ );
103
+
104
+ return SettingsListFactory.create(items, () => {
105
+ this.options.done();
106
+ });
107
+ }
108
+
109
+ /** Handle a field commit from a server row's submenu. */
110
+ private handleFieldChange(field: string, value: string): void {
111
+ const idx = this.selectedIndex;
112
+ const server = this.options.servers[idx];
113
+ const updated = server
114
+ ? ServerFields.byId(field).apply(server, value)
115
+ : server;
116
+ const next = server
117
+ ? this.options.servers.map((s, i) => (i === idx ? updated : s))
118
+ : this.options.servers;
119
+
120
+ this.commitFieldChange(next, idx, () => {
121
+ // Refresh the row's suffix in place; the full rebuild (row labels
122
+ // with health emoji, fresh server snapshots for the field submenu)
123
+ // is deferred until the submenu closes (see commitFieldChange)
124
+ const updated = next[idx];
125
+ if (updated) {
126
+ this.settingsList?.updateValue(
127
+ `server-${idx}`,
128
+ ServerDisplay.suffix(updated),
129
+ );
130
+ }
131
+ });
132
+ }
133
+
134
+ protected beginAdd(): void {
135
+ const wizard = new ServerWizard(this.dialogs, (dialog) =>
136
+ this.openDialog(dialog),
137
+ );
138
+ wizard.start(
139
+ (server) => {
140
+ this.closeDialog();
141
+ void this.saveNewServer(server);
142
+ },
143
+ () => this.closeDialog(),
144
+ );
145
+ }
146
+
147
+ protected deleteSelected(): void {
148
+ const idx = this.selectedIndex;
149
+ const next = this.options.servers.filter((_, i) => i !== idx);
150
+ void this.persistSnapshot(next, () => {
151
+ void this.rebuildList(idx);
152
+ });
153
+ }
154
+
155
+ protected readonly emptyHintKey = "emptyServers" as const;
156
+
157
+ protected getRowId(index: number): string {
158
+ return `server-${index}`;
159
+ }
160
+
161
+ protected getRowLabel(index: number): string {
162
+ return this.options.servers[index]?.url ?? "";
163
+ }
164
+
165
+ protected get deleteTitle(): string {
166
+ return TITLES.deleteServer;
167
+ }
168
+
169
+ // -- wizard helpers --------------------------------------------------------
170
+
171
+ private async saveNewServer(server: LlamaServer): Promise<void> {
172
+ const next = [...this.options.servers, server];
173
+ await this.persistSnapshot(next, () => {
174
+ void this.rebuildList(next.length - 1);
175
+ });
176
+
177
+ // Warn the user if the newly added server requires an API key
178
+ await this.warnIfAuthRequired(server);
179
+ }
180
+
181
+ /**
182
+ * Probes the server to check if it requires an API key. If so, notifies
183
+ * the user so they know to configure one via `/login` or `auth.json`.
184
+ */
185
+ private async warnIfAuthRequired(server: LlamaServer): Promise<void> {
186
+ const ui = this.options.ui;
187
+ if (!ui) return;
188
+
189
+ const serverTimeout = this.options.serverTimeout ?? SERVER_TIMEOUT;
190
+ const apiKey = (await this.options.authResolver?.(server)) ?? "";
191
+
192
+ if (await ServerDisplay.requiresApiKey(server.url, apiKey, serverTimeout)) {
193
+ const providerId = ServerIds.resolve(server.url, server.id);
194
+ ui.notify(authRequiredMessage(server.url, providerId), "warning");
195
+ }
196
+ }
197
+ }
@@ -0,0 +1,95 @@
1
+ import { ENDPOINT_PREFIX } from "../../../constants";
2
+ import { ServerStatus } from "../../../enums/serverStatus";
3
+ import type { LlamaServer } from "../../../interfaces/settings";
4
+ import { checkServerHealth } from "../../../utils/health";
5
+ import { ServerIds } from "../../../utils/serverIds";
6
+
7
+ /** Emoji shown when a server requires an API key (401 on /v1/models). */
8
+ const UNAUTHORIZED_EMOJI = "⛔";
9
+
10
+ /**
11
+ * Display concerns for a server row in the top-level list: the dim
12
+ * `(...)` suffix after the URL and the health-status icon.
13
+ */
14
+ export class ServerDisplay {
15
+ /**
16
+ * Formats the dim `(...)` suffix shown after a server's URL:
17
+ * `(<id> - <name>)`, with the auto-detected URL-based id used when no
18
+ * custom id override exists (mirrors `Server.providerId`). Returns the
19
+ * empty string when there is nothing to show.
20
+ */
21
+ static suffix(server: LlamaServer): string {
22
+ if (!server.id && !server.name) return "";
23
+ const id = ServerIds.resolve(server.url, server.id);
24
+ return server.name ? `(${id} - ${server.name})` : `(${server.id})`;
25
+ }
26
+
27
+ /**
28
+ * Checks the health of a server and returns the corresponding emoji.
29
+ *
30
+ * Delegates the probe/classification to the shared `checkServerHealth`
31
+ * (`utils/health`) — this wrapper only maps the status to its icon.
32
+ *
33
+ * @param url - The server URL to check
34
+ * @param timeout - Maximum time (ms) to wait for the health check
35
+ * @returns The health emoji for the server status
36
+ */
37
+ static async healthEmoji(url: string, timeout: number): Promise<string> {
38
+ return SERVER_STATUS_ICONS[await checkServerHealth(url, timeout)];
39
+ }
40
+
41
+ /**
42
+ * Checks if the server requires an API key by probing `/v1/models`.
43
+ *
44
+ * @param url - The server URL to check
45
+ * @param apiKey - The API key to send (may be a placeholder)
46
+ * @param timeout - Maximum time (ms) to wait for the request
47
+ * @returns `true` if the server responded with 401, `false` otherwise
48
+ */
49
+ static async requiresApiKey(
50
+ url: string,
51
+ apiKey: string,
52
+ timeout: number,
53
+ ): Promise<boolean> {
54
+ try {
55
+ const response = await fetch(`${url}${ENDPOINT_PREFIX}/models`, {
56
+ signal: AbortSignal.timeout(timeout),
57
+ headers: { Authorization: `Bearer ${apiKey}` },
58
+ });
59
+ return response.status === 401;
60
+ } catch {
61
+ // Timeout or network error — the server is unreachable, so no auth
62
+ // indicator is needed (the health emoji will cover that).
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Probes the server's `/v1/models` endpoint to check if authentication
69
+ * is required. Returns ⛔ when the server responds with 401, empty string
70
+ * otherwise.
71
+ *
72
+ * @param url - The server URL to check
73
+ * @param apiKey - The API key to send (may be a placeholder)
74
+ * @param timeout - Maximum time (ms) to wait for the request
75
+ * @returns The emoji indicator (⛔ if 401, "" otherwise)
76
+ */
77
+ static async authEmoji(
78
+ url: string,
79
+ apiKey: string,
80
+ timeout: number,
81
+ ): Promise<string> {
82
+ return (await this.requiresApiKey(url, apiKey, timeout))
83
+ ? UNAUTHORIZED_EMOJI
84
+ : "";
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Emoji indicators for server health status.
90
+ */
91
+ const SERVER_STATUS_ICONS: Record<ServerStatus, string> = {
92
+ [ServerStatus.READY]: "🟢",
93
+ [ServerStatus.TIMEOUT]: "🟡",
94
+ [ServerStatus.UNREACHABLE]: "🔴",
95
+ };
@@ -0,0 +1,110 @@
1
+ import type { LlamaServer } from "../../../interfaces/settings";
2
+ import type { DialogFactory } from "../../dialog/factory";
3
+ import type { InputDialog } from "../../dialog/input";
4
+ import { FieldMessages, TITLES } from "../../strings";
5
+ import { ServerFields, type ServerField } from "./fields";
6
+
7
+ /** Wizard step spec: which registry field this step prompts for, plus an
8
+ * optional note override for the prompt. */
9
+ interface WizardStepSpec {
10
+ def: ServerField;
11
+ note?: string;
12
+ }
13
+
14
+ /**
15
+ * State machine for the add-server wizard (URL → optional ID → optional name).
16
+ *
17
+ * The wizard state is just a growing `LlamaServer`: each step applies its
18
+ * committed value through the field definition's `apply` (backed by
19
+ * {@link LlamaServerBuilder}, so the unset-on-empty rule for `id`/`name`
20
+ * is shared with field edits).
21
+ *
22
+ * Usage:
23
+ * ```ts
24
+ * const wizard = new ServerWizard(dialogs);
25
+ * wizard.start(onComplete, onCancel);
26
+ * wizard.next(value); // advances through the steps
27
+ * wizard.cancel();
28
+ * ```
29
+ */
30
+ export class ServerWizard {
31
+ /**
32
+ * Ordered pipeline of wizard steps: URL (required, validated) → optional
33
+ * ID → optional name. Steps reference the shared `ServerFields` registry
34
+ * — the single source of truth for each field's label, placeholder,
35
+ * validator and apply logic — so the wizard can't drift from field edits
36
+ * on existing servers.
37
+ */
38
+ private static readonly STEPS: WizardStepSpec[] = [
39
+ { def: ServerFields.byId("url") },
40
+ { def: ServerFields.byId("id"), note: "(optional)" },
41
+ { def: ServerFields.byId("name"), note: "(optional)" },
42
+ ];
43
+
44
+ private server: LlamaServer = { url: "" };
45
+ private stepIndex = 0;
46
+ private dialog: InputDialog | null = null;
47
+ private onComplete: ((server: LlamaServer) => void) | null = null;
48
+ private onCancel: (() => void) | null = null;
49
+
50
+ constructor(
51
+ private readonly dialogs: DialogFactory,
52
+ /** Called each time a step dialog is created — the containing editor
53
+ * re-opens it via `openDialog` so input keeps flowing to the current
54
+ * step. */
55
+ private readonly onDialog: (dialog: InputDialog) => void,
56
+ ) {}
57
+
58
+ /** Start the wizard at the first step (URL). Focus is managed by the
59
+ * caller (the containing editor's `openDialog`). */
60
+ start(onComplete: (server: LlamaServer) => void, onCancel: () => void): void {
61
+ this.onComplete = onComplete;
62
+ this.onCancel = onCancel;
63
+ this.server = { url: "" };
64
+ this.stepIndex = 0;
65
+ this.showStep();
66
+ }
67
+
68
+ /**
69
+ * Commit the current step's value and advance. When the final step
70
+ * completes, hands the built server to the completion callback.
71
+ */
72
+ next(value: string): void {
73
+ const spec = ServerWizard.STEPS[this.stepIndex];
74
+ if (!spec) return;
75
+
76
+ this.server = spec.def.apply(this.server, value);
77
+ this.stepIndex += 1;
78
+
79
+ if (this.stepIndex >= ServerWizard.STEPS.length) {
80
+ this.dialog = null;
81
+ this.onComplete?.(this.server);
82
+ return;
83
+ }
84
+
85
+ this.showStep();
86
+ this.dialogs.requestRender();
87
+ }
88
+
89
+ /** Abort the wizard and call the cancel callback. */
90
+ cancel(): void {
91
+ this.dialog = null;
92
+ this.stepIndex = 0;
93
+ this.onCancel?.();
94
+ }
95
+
96
+ // -- internal helpers ------------------------------------------------------
97
+
98
+ private showStep(): void {
99
+ const spec = ServerWizard.STEPS[this.stepIndex];
100
+ this.dialog = this.dialogs.input({
101
+ title: TITLES.addServerStep((this.stepIndex + 1) as 1 | 2 | 3),
102
+ message: FieldMessages.of(spec.def.field, spec.note),
103
+ placeholder: spec.def.placeholder,
104
+ validate: spec.def.validate,
105
+ onSubmit: (v) => this.next(v),
106
+ onCancel: () => this.cancel(),
107
+ });
108
+ this.onDialog(this.dialog);
109
+ }
110
+ }
@@ -0,0 +1,37 @@
1
+ import type { Field } from "../strings";
2
+
3
+ /**
4
+ * Base class for one editable field of an entry (Template Method): the
5
+ * user-facing strings come from the shared `FIELDS` registry
6
+ * (`strings.ts`), and the `ItemBuilder` template fills the common
7
+ * `SettingItem` columns from this class while domain subclasses own the
8
+ * value/apply behavior.
9
+ *
10
+ * Both field registries (`ServerFields`, `OverrideFields`) extend this
11
+ * class, so the two editors share one field idiom instead of parallel
12
+ * interface/class shapes.
13
+ */
14
+ export abstract class SettingField<TEntry> {
15
+ /** Unique id used as `SettingItem.id`. */
16
+ abstract readonly id: string;
17
+ /** User-facing strings from the shared FIELDS registry. */
18
+ abstract readonly field: Field;
19
+
20
+ /** Row label (from the shared FIELDS registry). */
21
+ get label(): string {
22
+ return this.field.label;
23
+ }
24
+
25
+ /** Row description in the containing `SettingsList`. */
26
+ get description(): string {
27
+ return this.field.description;
28
+ }
29
+
30
+ /** Example value shown dim as `e.g., <placeholder>` in input dialogs. */
31
+ get placeholder(): string | undefined {
32
+ return this.field.placeholder;
33
+ }
34
+
35
+ /** Current value shown in the entry's field row. */
36
+ abstract currentValue(entry: TEntry): string;
37
+ }
@@ -0,0 +1,33 @@
1
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
2
+ import { SettingsList, type SettingItem } from "@earendil-works/pi-tui";
3
+
4
+ /**
5
+ * Factory for creating `SettingsList` instances with the standard
6
+ * geometry formula and theme.
7
+ *
8
+ * All call sites use this factory instead of constructing `SettingsList`
9
+ * directly, so the height formula (`Math.min(n + 2, 15)`) and theme
10
+ * lookup live in a single place and can't drift apart.
11
+ */
12
+ export class SettingsListFactory {
13
+ /**
14
+ * Creates a `SettingsList` with the standard geometry formula and theme.
15
+ *
16
+ * @param items — The list items to display.
17
+ * @param onEscape — Called when the user presses Esc to close the list.
18
+ * @param onSelect — Optional callback for field commits (defaults to a no-op).
19
+ */
20
+ static create(
21
+ items: SettingItem[],
22
+ onEscape: () => void,
23
+ onSelect?: (field: string, value: string) => void,
24
+ ): SettingsList {
25
+ return new SettingsList(
26
+ items,
27
+ Math.min(items.length + 2, 15),
28
+ getSettingsListTheme(),
29
+ onSelect ?? (() => {}),
30
+ onEscape,
31
+ );
32
+ }
33
+ }