pi-llama-cpp 0.10.0 → 0.11.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.
@@ -0,0 +1,481 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Input,
4
+ truncateToWidth,
5
+ type Component,
6
+ type Focusable,
7
+ type KeybindingsManager,
8
+ type TUI,
9
+ } from "@earendil-works/pi-tui";
10
+ import { PROVIDER_PREFIX } from "../constants";
11
+ import type { LlamaServer } from "../interfaces/settings";
12
+ import { errorMessage } from "../utils/errors";
13
+ import { normalizeUrl } from "../utils/urls";
14
+
15
+ /**
16
+ * Validates and normalizes a user-entered server URL: trims whitespace and
17
+ * strips trailing slashes (shared core in `utils/urls` — the same treatment
18
+ * the settings parser applies), then rejects empty strings, semicolons (the
19
+ * settings parser splits on them — use separate entries) and values without
20
+ * an http(s) scheme.
21
+ *
22
+ * @returns The normalized URL, or `null` when the input is invalid
23
+ */
24
+ export const normalizeServerUrl = (raw: string): string | null => {
25
+ const url = normalizeUrl(raw);
26
+ if (url.length === 0 || url.includes(";") || !/^https?:\/\//i.test(url)) {
27
+ return null;
28
+ }
29
+ return url;
30
+ };
31
+
32
+ /**
33
+ * Returns a new list with a `{ url }` entry appended. Immutable.
34
+ */
35
+ export const addServer = (
36
+ servers: LlamaServer[],
37
+ url: string,
38
+ ): LlamaServer[] => [...servers, { url }];
39
+
40
+ /**
41
+ * Fields of a `LlamaServer` editable through the inline Input.
42
+ */
43
+ type EditableField = "url" | "id" | "name";
44
+
45
+ /**
46
+ * Returns a new list with the URL at `index` replaced, preserving any
47
+ * `id`/`name` overrides. Immutable.
48
+ */
49
+ export const updateServerUrl = (
50
+ servers: LlamaServer[],
51
+ index: number,
52
+ url: string,
53
+ ): LlamaServer[] => updateServerField(servers, index, "url", url);
54
+
55
+ /**
56
+ * Returns a new list with `field` at `index` set to `value` (trimmed).
57
+ * An empty `value` removes the `id`/`name` key instead of storing an empty
58
+ * string, so the settings JSON stays clean. Only the edited field is
59
+ * touched — the other override is preserved. Immutable.
60
+ */
61
+ export const updateServerField = (
62
+ servers: LlamaServer[],
63
+ index: number,
64
+ field: EditableField,
65
+ value: string,
66
+ ): LlamaServer[] => {
67
+ const v = value.trim();
68
+ return servers.map((server, i) => {
69
+ if (i !== index) return server;
70
+ if (field === "url") return { ...server, url: v }; // validated upstream
71
+ if (field === "id") {
72
+ const { id, ...rest } = server; // drop the old id override…
73
+ return v.length === 0 ? rest : { ...rest, id: v }; // …clear or replace
74
+ }
75
+ const { name, ...rest } = server; // drop the old name override…
76
+ return v.length === 0 ? rest : { ...rest, name: v }; // …clear or replace
77
+ });
78
+ };
79
+
80
+ /**
81
+ * Formats the dim `(...)` suffix shown after a server's URL:
82
+ * `(<id> - <name>)`, with the auto-detected URL-based id used when no
83
+ * custom id override exists (mirrors `Server.providerId`). Returns the
84
+ * empty string when there is nothing to show.
85
+ */
86
+ export const formatServerSuffix = (server: LlamaServer): string => {
87
+ if (!server.id && !server.name) return "";
88
+ const id = server.id ?? `${PROVIDER_PREFIX}=${server.url}`;
89
+ return server.name ? `(${id} - ${server.name})` : `(${server.id})`;
90
+ };
91
+
92
+ /**
93
+ * Returns a new list without the entry at `index`. Immutable.
94
+ */
95
+ export const removeServer = (
96
+ servers: LlamaServer[],
97
+ index: number,
98
+ ): LlamaServer[] => servers.filter((_, i) => i !== index);
99
+
100
+ export interface ServerListEditorOptions {
101
+ /** TUI instance, used to request re-renders */
102
+ tui: TUI;
103
+ /** Theme for styling */
104
+ theme: Theme;
105
+ /** App keybindings manager (injected by ctx.ui.custom) */
106
+ keybindings: KeybindingsManager;
107
+ /** Snapshot of the merged `llamaSettings.servers` to edit */
108
+ servers: LlamaServer[];
109
+ /** Persists a new server list; a rejection keeps the current list */
110
+ persist: (next: LlamaServer[]) => Promise<void>;
111
+ /** Closes the editor (called on Esc in list mode) */
112
+ done: () => void;
113
+ /** Notifies about persistence errors */
114
+ onError: (message: string) => void;
115
+ }
116
+
117
+ /**
118
+ * Editor for `llamaSettings.servers`, shown by `/models servers`.
119
+ *
120
+ * List mode: up/down move the cursor, Enter/e edits the selected entry's
121
+ * URL, i edits its id, n its name, a adds a new entry, d asks for
122
+ * confirmation before deleting, Esc closes.
123
+ * Confirm mode: y deletes the selected entry, Esc/n aborts (Enter is
124
+ * deliberately ignored).
125
+ * Edit mode: all other input goes to an inline `Input`; Enter saves,
126
+ * Esc reverts. The edited field (url/id/name) is tracked in `field`.
127
+ *
128
+ * Each mutation is persisted immediately through `persist()` (which maps to
129
+ * `LlamaSettingsManager.setLlamaSetting()`); the in-memory list only updates
130
+ * after the write succeeds, so a failed write leaves everything unchanged
131
+ * and the editor open.
132
+ */
133
+ export class ServerListEditor implements Component, Focusable {
134
+ private servers: LlamaServer[];
135
+ private selectedIndex = 0;
136
+ private mode: "list" | "edit" | "add" | "confirm" = "list";
137
+ /** Which `LlamaServer` field the inline Input is editing (edit/add modes) */
138
+ private field: EditableField = "url";
139
+ private editingIndex = -1;
140
+ private error: string | undefined;
141
+ private readonly input = new Input();
142
+ private readonly envOverride: boolean;
143
+ private isFocused = false;
144
+
145
+ constructor(private readonly options: ServerListEditorOptions) {
146
+ this.servers = options.servers;
147
+ this.envOverride = Boolean(process.env.LLAMA_SERVER_URL);
148
+ }
149
+
150
+ /** Focusable: delegates to the inline Input while it is rendered */
151
+ get focused(): boolean {
152
+ return this.isFocused;
153
+ }
154
+
155
+ set focused(value: boolean) {
156
+ this.isFocused = value;
157
+ this.input.focused = value;
158
+ }
159
+
160
+ invalidate(): void {
161
+ // No cached state to invalidate
162
+ }
163
+
164
+ handleInput(data: string): void {
165
+ if (this.mode === "list") {
166
+ this.handleListInput(data);
167
+ } else if (this.mode === "confirm") {
168
+ this.handleConfirmInput(data);
169
+ } else {
170
+ this.handleEditInput(data);
171
+ }
172
+ }
173
+
174
+ render(width: number): string[] {
175
+ const { theme } = this.options;
176
+ const truncate = (line: string) => truncateToWidth(line, width);
177
+ const lines: string[] = [];
178
+
179
+ lines.push(
180
+ truncate(theme.fg("accent", theme.bold("Manage llama.cpp servers"))),
181
+ );
182
+ lines.push("");
183
+
184
+ if (this.servers.length === 0) {
185
+ lines.push(
186
+ truncate(
187
+ theme.fg("dim", "No servers configured — press a to add one."),
188
+ ),
189
+ );
190
+ lines.push(
191
+ truncate(
192
+ theme.fg(
193
+ "dim",
194
+ "With an empty list the default http://127.0.0.1:8080 is used.",
195
+ ),
196
+ ),
197
+ );
198
+ }
199
+
200
+ this.servers.forEach((server, index) => {
201
+ const selected = index === this.selectedIndex;
202
+ const prefix = selected ? theme.fg("accent", "→ ") : " ";
203
+ const suffix = formatServerSuffix(server);
204
+ const dim = suffix ? theme.fg("dim", ` ${suffix}`) : "";
205
+ lines.push(truncate(`${prefix}${server.url}${dim}`));
206
+ });
207
+
208
+ if (this.mode === "confirm") {
209
+ lines.push(
210
+ truncate(
211
+ theme.fg(
212
+ "error",
213
+ `About to delete "${this.servers[this.selectedIndex]?.url}"`,
214
+ ),
215
+ ),
216
+ );
217
+ lines.push(truncate(theme.fg("error", "Are you sure?")));
218
+ } else if (this.mode !== "list") {
219
+ const label =
220
+ this.field === "url" ? "URL:" : this.field === "id" ? "ID:" : "Name:";
221
+ lines.push("");
222
+ lines.push(truncate(theme.fg("dim", label)));
223
+ lines.push(truncate(this.input.render(width)[0] ?? ""));
224
+ if (this.error) {
225
+ lines.push(truncate(theme.fg("error", this.error)));
226
+ }
227
+ }
228
+
229
+ lines.push("");
230
+ if (this.envOverride) {
231
+ lines.push(
232
+ truncate(
233
+ theme.fg("dim", "LLAMA_SERVER_URL env var overrides these servers."),
234
+ ),
235
+ );
236
+ }
237
+ lines.push(
238
+ truncate(
239
+ theme.fg(
240
+ "dim",
241
+ this.mode === "list"
242
+ ? "Enter/e url · i id · n name · a add · d delete · Esc done"
243
+ : this.mode === "confirm"
244
+ ? "y delete · Esc/n cancel"
245
+ : "Enter save · Esc cancel",
246
+ ),
247
+ ),
248
+ );
249
+
250
+ return lines;
251
+ }
252
+
253
+ /**
254
+ * List mode: navigation, edit/add/delete shortcuts and close.
255
+ */
256
+ private handleListInput(data: string): void {
257
+ const kb = this.options.keybindings;
258
+
259
+ if (kb.matches(data, "tui.select.cancel")) {
260
+ this.options.done();
261
+ return;
262
+ }
263
+ if (kb.matches(data, "tui.select.up")) {
264
+ if (this.servers.length > 0) {
265
+ this.selectedIndex =
266
+ this.selectedIndex === 0
267
+ ? this.servers.length - 1
268
+ : this.selectedIndex - 1;
269
+ this.requestRender();
270
+ }
271
+ return;
272
+ }
273
+ if (kb.matches(data, "tui.select.down")) {
274
+ if (this.servers.length > 0) {
275
+ this.selectedIndex =
276
+ this.selectedIndex === this.servers.length - 1
277
+ ? 0
278
+ : this.selectedIndex + 1;
279
+ this.requestRender();
280
+ }
281
+ return;
282
+ }
283
+ if (kb.matches(data, "tui.select.confirm") || data === "e") {
284
+ this.beginEdit();
285
+ return;
286
+ }
287
+ if (data === "i") {
288
+ this.beginEditField("id");
289
+ return;
290
+ }
291
+ if (data === "n") {
292
+ this.beginEditField("name");
293
+ return;
294
+ }
295
+ if (data === "a") {
296
+ this.beginAdd();
297
+ return;
298
+ }
299
+ if (data === "d") {
300
+ this.beginConfirm();
301
+ return;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Confirm mode: only `y` deletes the selected entry, Esc/n returns to the
307
+ * list without changing anything; every other key — Enter included, so a
308
+ * stray keypress can't confirm — is ignored.
309
+ */
310
+ private handleConfirmInput(data: string): void {
311
+ const kb = this.options.keybindings;
312
+
313
+ if (data === "y") {
314
+ this.deleteSelected();
315
+ return;
316
+ }
317
+ if (kb.matches(data, "tui.select.cancel") || data === "n") {
318
+ this.mode = "list";
319
+ this.requestRender();
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Edit mode: Enter saves, Esc reverts, everything else goes to the Input.
325
+ */
326
+ private handleEditInput(data: string): void {
327
+ const kb = this.options.keybindings;
328
+
329
+ if (kb.matches(data, "tui.select.confirm")) {
330
+ this.saveEdit();
331
+ return;
332
+ }
333
+ if (kb.matches(data, "tui.select.cancel")) {
334
+ this.mode = "list";
335
+ this.editingIndex = -1;
336
+ this.error = undefined;
337
+ this.requestRender();
338
+ return;
339
+ }
340
+ this.input.handleInput(data);
341
+ this.requestRender();
342
+ }
343
+
344
+ /**
345
+ * Opens the selected entry for editing, prefilling the Input with its URL
346
+ * and placing the cursor at the end (the common edit: appending/changing
347
+ * the port).
348
+ */
349
+ private beginEdit(): void {
350
+ if (this.servers.length === 0) return;
351
+ this.beginEditField("url");
352
+ }
353
+
354
+ /**
355
+ * Opens the selected entry for editing `field`, prefilling the Input with
356
+ * its current value (empty when unset) and placing the cursor at the end.
357
+ */
358
+ private beginEditField(field: EditableField): void {
359
+ if (this.servers.length === 0) return;
360
+ this.mode = "edit";
361
+ this.field = field;
362
+ this.editingIndex = this.selectedIndex;
363
+ this.error = undefined;
364
+ this.input.setValue(this.servers[this.selectedIndex][field] ?? "");
365
+ this.moveInputCursorToEnd();
366
+ this.requestRender();
367
+ }
368
+
369
+ /**
370
+ * Enters confirm mode for the selected entry: deletion only proceeds
371
+ * after an explicit Enter/y, guarding against accidental presses of d.
372
+ */
373
+ private beginConfirm(): void {
374
+ if (this.servers.length === 0) return;
375
+ this.mode = "confirm";
376
+ this.requestRender();
377
+ }
378
+
379
+ /**
380
+ * Starts adding a new entry with an empty Input.
381
+ */
382
+ private beginAdd(): void {
383
+ this.mode = "add";
384
+ this.field = "url";
385
+ this.editingIndex = -1;
386
+ this.error = undefined;
387
+ this.input.setValue("");
388
+ this.requestRender();
389
+ }
390
+
391
+ /**
392
+ * Saves the edited/added value: validates it per field, persists the new
393
+ * list and returns to list mode. Invalid input shows an inline error and
394
+ * keeps editing; a failed write notifies via `onError` and keeps editing
395
+ * too.
396
+ */
397
+ private async saveEdit(): Promise<void> {
398
+ const raw = this.input.getValue();
399
+ if (this.field === "url") {
400
+ const url = normalizeServerUrl(raw);
401
+ if (!url) {
402
+ this.error = "Invalid URL — use http://host:port (one URL per entry)";
403
+ this.requestRender();
404
+ return;
405
+ }
406
+ await this.applySave((servers) =>
407
+ this.mode === "add"
408
+ ? addServer(servers, url)
409
+ : updateServerUrl(servers, this.editingIndex, url),
410
+ );
411
+ return;
412
+ }
413
+ // id/name: free-form; empty/whitespace clears the override
414
+ await this.applySave((servers) =>
415
+ updateServerField(servers, this.editingIndex, this.field, raw),
416
+ );
417
+ }
418
+
419
+ /**
420
+ * Persists `build(this.servers)`. On success, adopts the new list and
421
+ * returns to list mode; on failure, notifies via `onError` and stays in
422
+ * edit mode with the pre-mutation list.
423
+ */
424
+ private async applySave(
425
+ build: (servers: LlamaServer[]) => LlamaServer[],
426
+ ): Promise<void> {
427
+ const isAdd = this.mode === "add";
428
+ const next = build(this.servers);
429
+
430
+ try {
431
+ await this.options.persist(next);
432
+ this.servers = next;
433
+ if (isAdd) this.selectedIndex = next.length - 1;
434
+ this.mode = "list";
435
+ this.editingIndex = -1;
436
+ this.error = undefined;
437
+ } catch (err) {
438
+ // Write failed: keep the pre-mutation list and stay in edit mode
439
+ this.options.onError(errorMessage(err));
440
+ }
441
+ this.requestRender();
442
+ }
443
+
444
+ /**
445
+ * Deletes the selected entry and persists immediately. Leaves confirm
446
+ * mode synchronously so a second Enter while the write is pending cannot
447
+ * queue a duplicate deletion.
448
+ */
449
+ private async deleteSelected(): Promise<void> {
450
+ if (this.servers.length === 0) return;
451
+ this.mode = "list";
452
+ const next = removeServer(this.servers, this.selectedIndex);
453
+
454
+ try {
455
+ await this.options.persist(next);
456
+ this.servers = next;
457
+ if (this.selectedIndex >= next.length) {
458
+ this.selectedIndex = Math.max(0, next.length - 1);
459
+ }
460
+ } catch (err) {
461
+ // Write failed: keep the pre-mutation list
462
+ this.options.onError(errorMessage(err));
463
+ }
464
+ this.requestRender();
465
+ }
466
+
467
+ /**
468
+ * The Input has no "move to end" API and `setValue()` clamps the cursor;
469
+ * walk it right one grapheme at a time using the standard arrow sequence
470
+ * (resolved by the Input against pi's global keybindings).
471
+ */
472
+ private moveInputCursorToEnd(): void {
473
+ for (let i = 0; i < [...this.input.getValue()].length; i++) {
474
+ this.input.handleInput("\x1b[C");
475
+ }
476
+ }
477
+
478
+ private requestRender(): void {
479
+ this.options.tui.requestRender();
480
+ }
481
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Extracts a human-readable message from an unknown error value.
3
+ */
4
+ export const errorMessage = (err: unknown): string =>
5
+ err instanceof Error ? err.message : String(err);
@@ -0,0 +1,60 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { readFile, rename, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Read/write access to pi's global settings.json on behalf of this extension.
7
+ * Owns serialization of writes and atomicity; knows nothing about pi's
8
+ * SettingsManager.
9
+ */
10
+ export class SettingsStore {
11
+ /** Serializes whole-file read-modify-write cycles (last write wins). */
12
+ private writeQueue: Promise<void> = Promise.resolve();
13
+
14
+ constructor(
15
+ private readonly path: string = join(getAgentDir(), "settings.json"),
16
+ ) {}
17
+
18
+ /**
19
+ * Reads and parses the whole settings file.
20
+ * Missing file → `{}`. Invalid JSON / other IO errors → throws
21
+ * (never swallow: the caller must not overwrite a file it couldn't read).
22
+ */
23
+ async read(): Promise<Record<string, unknown>> {
24
+ let raw: string;
25
+ try {
26
+ raw = await readFile(this.path, "utf-8");
27
+ } catch (err: any) {
28
+ if (err?.code === "ENOENT") return {};
29
+ throw err;
30
+ }
31
+ try {
32
+ return JSON.parse(raw) as Record<string, unknown>;
33
+ } catch (err) {
34
+ throw new Error(`Cannot parse ${this.path}: ${err}`);
35
+ }
36
+ }
37
+
38
+ /** Serializes (2-space indent) and writes atomically via temp file + rename. */
39
+ async write(root: Record<string, unknown>): Promise<void> {
40
+ const tmp = `${this.path}.tmp`;
41
+ await writeFile(tmp, JSON.stringify(root, null, 2), "utf-8");
42
+ await rename(tmp, this.path);
43
+ }
44
+
45
+ /**
46
+ * Read-modify-write of one top-level key, queued so concurrent calls
47
+ * apply in order (last write wins) instead of racing on the whole file.
48
+ * Rejects if read or write fails; the queue itself never gets poisoned.
49
+ */
50
+ updateKey(key: string, update: (current: unknown) => unknown): Promise<void> {
51
+ const run = async () => {
52
+ const root = await this.read();
53
+ root[key] = update(root[key]);
54
+ await this.write(root);
55
+ };
56
+ const result = this.writeQueue.then(run, run);
57
+ this.writeQueue = result.catch(() => {});
58
+ return result;
59
+ }
60
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared URL core: trims surrounding whitespace and strips trailing
3
+ * slashes. Used by the settings parser (`parseUrls`) and the editor
4
+ * validator (`normalizeServerUrl`) so the two can't drift.
5
+ */
6
+ export const normalizeUrl = (raw: string): string =>
7
+ raw.trim().replace(/\/+$/, "");
8
+
9
+ /**
10
+ * True when the URL carries an http(s) scheme — the only schemes
11
+ * llama-server endpoints use. Part of the shared validation applied by
12
+ * both the settings parser (`parseUrls`) and the editor validator
13
+ * (`normalizeServerUrl`).
14
+ */
15
+ export const isValidServerUrl = (url: string): boolean =>
16
+ /^https?:\/\//i.test(url);