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.
- package/README.md +66 -5
- package/package.json +3 -3
- package/src/api/client.ts +76 -32
- package/src/constants.ts +15 -0
- package/src/index.ts +3 -5
- package/src/interfaces/events.ts +14 -3
- package/src/interfaces/server.ts +20 -0
- package/src/interfaces/settings.ts +11 -2
- package/src/managers/command.ts +285 -43
- package/src/managers/events.ts +28 -10
- package/src/managers/server.ts +87 -16
- package/src/managers/settings.ts +78 -16
- package/src/models/baseModel.ts +0 -8
- package/src/server.ts +69 -28
- package/src/sse/client.ts +28 -16
- package/src/sse/manager.ts +22 -10
- package/src/ui/serverListEditor.ts +481 -0
- package/src/utils/errors.ts +5 -0
- package/src/utils/settingsStore.ts +60 -0
- package/src/utils/urls.ts +16 -0
- package/tests/commandManager.test.ts +256 -11
- package/tests/events.test.ts +117 -88
- package/tests/mocks.ts +145 -32
- package/tests/server.test.ts +37 -35
- package/tests/serverListEditor.test.ts +637 -0
- package/tests/serverManager.test.ts +282 -39
- package/tests/settings.test.ts +173 -16
- package/tests/settingsStore.test.ts +209 -0
- package/tests/sseManager.test.ts +88 -11
- package/src/interfaces/auth.ts +0 -6
- package/src/utils/cache.ts +0 -39
- package/src/utils/mutex.ts +0 -24
|
@@ -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,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);
|