pi-llama-cpp 0.11.0 → 0.13.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.
@@ -1,15 +1,7 @@
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
1
  import { PROVIDER_PREFIX } from "../constants";
2
+ import { ServerStatus } from "../enums/serverStatus";
11
3
  import type { LlamaServer } from "../interfaces/settings";
12
- import { errorMessage } from "../utils/errors";
4
+ import { checkServerHealth } from "../utils/health";
13
5
  import { normalizeUrl } from "../utils/urls";
14
6
 
15
7
  /**
@@ -29,54 +21,6 @@ export const normalizeServerUrl = (raw: string): string | null => {
29
21
  return url;
30
22
  };
31
23
 
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
24
  /**
81
25
  * Formats the dim `(...)` suffix shown after a server's URL:
82
26
  * `(<id> - <name>)`, with the auto-detected URL-based id used when no
@@ -90,392 +34,26 @@ export const formatServerSuffix = (server: LlamaServer): string => {
90
34
  };
91
35
 
92
36
  /**
93
- * Returns a new list without the entry at `index`. Immutable.
37
+ * Emoji indicators for server health status.
94
38
  */
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
- }
39
+ const SERVER_STATUS_ICONS: Record<ServerStatus, string> = {
40
+ [ServerStatus.READY]: "🟢",
41
+ [ServerStatus.TIMEOUT]: "🟡",
42
+ [ServerStatus.UNREACHABLE]: "🔴",
43
+ };
116
44
 
117
45
  /**
118
- * Editor for `llamaSettings.servers`, shown by `/models servers`.
46
+ * Checks the health of a server and returns the corresponding emoji.
119
47
  *
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`.
48
+ * Delegates the probe/classification to the shared `checkServerHealth`
49
+ * (`utils/health`) — this wrapper only maps the status to its icon.
127
50
  *
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.
51
+ * @param url - The server URL to check
52
+ * @param timeout - Maximum time (ms) to wait for the health check
53
+ * @returns The health emoji for the server status
132
54
  */
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
- }
55
+ export const getServerHealthEmoji = async (
56
+ url: string,
57
+ timeout: number,
58
+ ): Promise<string> =>
59
+ SERVER_STATUS_ICONS[await checkServerHealth(url, timeout)];