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.
- package/README.md +186 -58
- package/package.json +5 -5
- package/src/constants.ts +8 -0
- package/src/interfaces/server.ts +7 -0
- package/src/interfaces/settings.ts +67 -1
- package/src/managers/command.ts +110 -31
- package/src/managers/events.ts +2 -2
- package/src/managers/server.ts +14 -11
- package/src/managers/settings.ts +120 -45
- package/src/models/baseModel.ts +37 -7
- package/src/models/routerModel.ts +2 -1
- package/src/server.ts +62 -23
- package/src/sse/manager.ts +9 -8
- package/src/ui/dialog.ts +290 -0
- package/src/ui/overrideEntryEditor.ts +119 -0
- package/src/ui/overrideSettingsList.ts +710 -0
- package/src/ui/serverListEditor.ts +19 -441
- package/src/ui/serverSettingsList.ts +513 -0
- package/src/ui/strings.ts +127 -0
- package/src/utils/health.ts +48 -0
- package/src/utils/settingsStore.ts +2 -6
- package/tests/commandManager.test.ts +132 -16
- package/tests/dialog.test.ts +228 -0
- package/tests/events.test.ts +12 -9
- package/tests/health.test.ts +116 -0
- package/tests/legacyModel.test.ts +4 -19
- package/tests/mocks.ts +12 -8
- package/tests/overrides.test.ts +384 -0
- package/tests/server.test.ts +59 -16
- package/tests/serverManager.test.ts +28 -62
- package/tests/settings.test.ts +489 -43
- package/tests/settingsStore.test.ts +0 -19
- package/tests/singleModel.test.ts +32 -0
- package/tests/sseManager.test.ts +4 -4
- package/tests/serverListEditor.test.ts +0 -637
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSettingsListTheme,
|
|
3
|
+
type Theme,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type {
|
|
6
|
+
Component,
|
|
7
|
+
Focusable,
|
|
8
|
+
KeybindingsManager,
|
|
9
|
+
TUI,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import { SettingsList, type SettingItem } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { LlamaServer } from "../interfaces/settings";
|
|
13
|
+
import { ConfirmDialog, InputDialog } from "./dialog";
|
|
14
|
+
import {
|
|
15
|
+
formatServerSuffix,
|
|
16
|
+
getServerHealthEmoji,
|
|
17
|
+
normalizeServerUrl,
|
|
18
|
+
} from "./serverListEditor";
|
|
19
|
+
import { fieldMessage, FIELDS, HINTS, TITLES } from "./strings";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Options for the SettingsList-based server editor.
|
|
23
|
+
*/
|
|
24
|
+
export interface ServerSettingsListOptions {
|
|
25
|
+
/** TUI instance, used to request re-renders after async persists */
|
|
26
|
+
tui: TUI;
|
|
27
|
+
/** Theme for dialogs (from the ctx.ui.custom factory) */
|
|
28
|
+
theme: Theme;
|
|
29
|
+
/** App keybindings manager (injected by ctx.ui.custom) */
|
|
30
|
+
keybindings: KeybindingsManager;
|
|
31
|
+
/**
|
|
32
|
+
* Snapshot of the merged `llamaSettings.servers` to edit. Replaced
|
|
33
|
+
* in place (`options.servers = next`) after every successful persist
|
|
34
|
+
* so the rebuilt rows reflect the adopted list.
|
|
35
|
+
*/
|
|
36
|
+
servers: LlamaServer[];
|
|
37
|
+
/** Persists a new server list; a rejection keeps the current list */
|
|
38
|
+
persist: (next: LlamaServer[]) => Promise<void>;
|
|
39
|
+
/** Closes the editor (called on Esc in the server list) */
|
|
40
|
+
done: () => void;
|
|
41
|
+
/** Notifies about persistence errors */
|
|
42
|
+
onError: (message: string) => void;
|
|
43
|
+
/** Timeout (ms) for health checks. Defaults to 1000ms if not provided. */
|
|
44
|
+
serverTimeout?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Builds the SettingItem for one server's editable fields (URL, id, name).
|
|
49
|
+
* Each field is infinite → opens an `InputDialog` submenu; commits are
|
|
50
|
+
* reported by the containing `SettingsList`'s `onChange` (field id → value).
|
|
51
|
+
*/
|
|
52
|
+
const buildServerFieldItems = (
|
|
53
|
+
server: LlamaServer,
|
|
54
|
+
theme: Theme,
|
|
55
|
+
tui: TUI,
|
|
56
|
+
): SettingItem[] => [
|
|
57
|
+
{
|
|
58
|
+
id: "url",
|
|
59
|
+
label: FIELDS.serverUrl.label,
|
|
60
|
+
description: FIELDS.serverUrl.description,
|
|
61
|
+
currentValue: server.url,
|
|
62
|
+
submenu: InputDialog.inputSubmenu(
|
|
63
|
+
theme,
|
|
64
|
+
tui,
|
|
65
|
+
TITLES.edit(FIELDS.serverUrl.label),
|
|
66
|
+
fieldMessage(FIELDS.serverUrl),
|
|
67
|
+
FIELDS.serverUrl.placeholder,
|
|
68
|
+
normalizeServerUrl,
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "id",
|
|
73
|
+
label: FIELDS.providerId.label,
|
|
74
|
+
description: FIELDS.providerId.description,
|
|
75
|
+
currentValue: server.id ?? "",
|
|
76
|
+
submenu: InputDialog.inputSubmenu(
|
|
77
|
+
theme,
|
|
78
|
+
tui,
|
|
79
|
+
TITLES.edit(FIELDS.providerId.label),
|
|
80
|
+
fieldMessage(FIELDS.providerId),
|
|
81
|
+
FIELDS.providerId.placeholder,
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "name",
|
|
86
|
+
label: FIELDS.displayName.label,
|
|
87
|
+
description: FIELDS.displayName.description,
|
|
88
|
+
currentValue: server.name ?? "",
|
|
89
|
+
submenu: InputDialog.inputSubmenu(
|
|
90
|
+
theme,
|
|
91
|
+
tui,
|
|
92
|
+
TITLES.edit(FIELDS.displayName.label),
|
|
93
|
+
fieldMessage(FIELDS.displayName),
|
|
94
|
+
FIELDS.displayName.placeholder,
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Builds the SettingItem for one server row in the top-level list.
|
|
101
|
+
* Enter drills into the server's field-edit submenu.
|
|
102
|
+
*/
|
|
103
|
+
const buildServerRow = (
|
|
104
|
+
server: LlamaServer,
|
|
105
|
+
index: number,
|
|
106
|
+
theme: Theme,
|
|
107
|
+
tui: TUI,
|
|
108
|
+
onChange: (field: string, value: string) => void,
|
|
109
|
+
healthEmoji: string,
|
|
110
|
+
onSubmenuChange: (open: boolean) => void,
|
|
111
|
+
): SettingItem => ({
|
|
112
|
+
id: `server-${index}`,
|
|
113
|
+
label: `${healthEmoji} ${server.url}`,
|
|
114
|
+
description: HINTS.serverRow,
|
|
115
|
+
currentValue: formatServerSuffix(server),
|
|
116
|
+
submenu: (_cv, done) => {
|
|
117
|
+
// Rebuild the field items on open so they prefill with current values
|
|
118
|
+
const items = buildServerFieldItems(server, theme, tui);
|
|
119
|
+
onSubmenuChange(true);
|
|
120
|
+
return new SettingsList(
|
|
121
|
+
items,
|
|
122
|
+
Math.min(items.length + 2, 15),
|
|
123
|
+
getSettingsListTheme(),
|
|
124
|
+
// Field commits arrive here: done(value) from the input submenus
|
|
125
|
+
(field, value) => onChange(field, value),
|
|
126
|
+
() => {
|
|
127
|
+
onSubmenuChange(false);
|
|
128
|
+
done();
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Wrapper around a `SettingsList` of servers that adds `a` (add) and `d`
|
|
136
|
+
* (delete) support at the list level.
|
|
137
|
+
*
|
|
138
|
+
* - **a** opens the add wizard — a sequence of framed `InputDialog`s
|
|
139
|
+
* (URL → optional ID → optional name, mirroring `/login`'s sequential
|
|
140
|
+
* prompts). Esc at any step aborts without persisting; the server is
|
|
141
|
+
* saved only after the final step.
|
|
142
|
+
* - **d** opens a `ConfirmDialog` (Delete/Cancel select list).
|
|
143
|
+
* - **Enter** on a server row drills into its field-edit submenu.
|
|
144
|
+
* - **Esc** closes the editor.
|
|
145
|
+
*/
|
|
146
|
+
export class ServerSettingsList implements Component, Focusable {
|
|
147
|
+
private settingsList!: SettingsList;
|
|
148
|
+
private mode: "list" | "wizard" | "confirm" = "list";
|
|
149
|
+
private wizardStep = 0;
|
|
150
|
+
private wizardUrl = "";
|
|
151
|
+
private wizardId = "";
|
|
152
|
+
private wizardDialog: InputDialog | undefined;
|
|
153
|
+
private confirmDialog: ConfirmDialog | undefined;
|
|
154
|
+
private isFocused = false;
|
|
155
|
+
/** True while a server row's field-edit submenu is open */
|
|
156
|
+
private submenuOpen = false;
|
|
157
|
+
|
|
158
|
+
constructor(private readonly options: ServerSettingsListOptions) {
|
|
159
|
+
this.buildSettingsList().then((list) => {
|
|
160
|
+
this.settingsList = list;
|
|
161
|
+
this.options.tui.requestRender();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Builds the top-level `SettingsList` from the current
|
|
167
|
+
* `options.servers`. Called on construction and rebuilt after every
|
|
168
|
+
* successful persist so added/deleted/edited servers are reflected
|
|
169
|
+
* immediately (the initial snapshot is not mutated by `persist`).
|
|
170
|
+
*/
|
|
171
|
+
private async buildSettingsList(): Promise<SettingsList> {
|
|
172
|
+
const { theme, tui } = this.options;
|
|
173
|
+
const serverTimeout = this.options.serverTimeout ?? 1000;
|
|
174
|
+
// Check health for each server in parallel
|
|
175
|
+
const healthEmojis = await Promise.all(
|
|
176
|
+
this.options.servers.map((server) =>
|
|
177
|
+
getServerHealthEmoji(server.url, serverTimeout),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// A fresh list never has a submenu open (also covers the rebuild
|
|
182
|
+
// paths, which discard any open field submenu)
|
|
183
|
+
this.submenuOpen = false;
|
|
184
|
+
const items: SettingItem[] = this.options.servers.map((server, i) =>
|
|
185
|
+
buildServerRow(
|
|
186
|
+
server,
|
|
187
|
+
i,
|
|
188
|
+
theme,
|
|
189
|
+
tui,
|
|
190
|
+
(field, value) => this.handleFieldChange(field, value),
|
|
191
|
+
healthEmojis[i],
|
|
192
|
+
(open) => {
|
|
193
|
+
this.submenuOpen = open;
|
|
194
|
+
},
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
return new SettingsList(
|
|
199
|
+
items,
|
|
200
|
+
Math.min(items.length + 2, 15),
|
|
201
|
+
getSettingsListTheme(),
|
|
202
|
+
() => {
|
|
203
|
+
// Top-level onChange is a no-op (servers don't change at this level)
|
|
204
|
+
},
|
|
205
|
+
() => {
|
|
206
|
+
this.options.done();
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// -- Focusable -----------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
get focused(): boolean {
|
|
214
|
+
return this.isFocused;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
set focused(value: boolean) {
|
|
218
|
+
this.isFocused = value;
|
|
219
|
+
if (this.wizardDialog) this.wizardDialog.focused = value;
|
|
220
|
+
if (this.confirmDialog) this.confirmDialog.focused = value;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// -- Component -----------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
invalidate(): void {
|
|
226
|
+
this.settingsList?.invalidate();
|
|
227
|
+
this.wizardDialog?.invalidate();
|
|
228
|
+
this.confirmDialog?.invalidate();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
handleInput(data: string): void {
|
|
232
|
+
if (this.mode === "wizard") {
|
|
233
|
+
this.wizardDialog?.handleInput(data);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (this.mode === "confirm") {
|
|
238
|
+
this.confirmDialog?.handleInput(data);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A server row's field submenu is open: delegate everything (Esc,
|
|
243
|
+
// a/d, arrows) to the containing SettingsList, which forwards input
|
|
244
|
+
// to the submenu. Intercepting here would close the whole editor on
|
|
245
|
+
// Esc and trigger add/delete while typing in a field.
|
|
246
|
+
if (this.submenuOpen) {
|
|
247
|
+
this.settingsList?.handleInput(data);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const kb = this.options.keybindings;
|
|
252
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
253
|
+
this.options.done();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Track selection for add/delete
|
|
257
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
258
|
+
this._selectedIndex =
|
|
259
|
+
this._selectedIndex === 0
|
|
260
|
+
? this.options.servers.length - 1
|
|
261
|
+
: this._selectedIndex - 1;
|
|
262
|
+
}
|
|
263
|
+
if (kb.matches(data, "tui.select.down")) {
|
|
264
|
+
this._selectedIndex =
|
|
265
|
+
this._selectedIndex === this.options.servers.length - 1
|
|
266
|
+
? 0
|
|
267
|
+
: this._selectedIndex + 1;
|
|
268
|
+
}
|
|
269
|
+
if (data === "a") {
|
|
270
|
+
this.beginWizard();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (data === "d") {
|
|
274
|
+
this.beginConfirm();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// Delegate to SettingsList (it handles up/down internally for rendering)
|
|
278
|
+
this.settingsList?.handleInput(data);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
render(width: number): string[] {
|
|
282
|
+
if (this.mode === "wizard") {
|
|
283
|
+
return this.wizardDialog?.render(width) ?? [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (this.mode === "confirm") {
|
|
287
|
+
return this.confirmDialog?.render(width) ?? [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// settingsList may not be initialized yet if buildSettingsList is still
|
|
291
|
+
// resolving health checks (async in constructor). Return empty until ready.
|
|
292
|
+
if (!this.settingsList) return [];
|
|
293
|
+
|
|
294
|
+
// List mode: the shortcuts live in the rows' description; only when the
|
|
295
|
+
// list is empty (no rows → no description) show them as the hint line
|
|
296
|
+
const settingsLines = this.settingsList.render(width);
|
|
297
|
+
if (this.options.servers.length === 0) {
|
|
298
|
+
settingsLines[settingsLines.length - 1] = getSettingsListTheme().hint(
|
|
299
|
+
HINTS.emptyServers,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return settingsLines;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// -- helpers ---------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
private selectedIndex(): number {
|
|
308
|
+
// Manual tracker for add/delete targeting, kept in sync with the
|
|
309
|
+
// SettingsList's cursor by rebuildList (SettingsList doesn't expose
|
|
310
|
+
// a getter)
|
|
311
|
+
return this._selectedIndex;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private _selectedIndex = 0;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Rebuilds the server list and places the cursor on `targetIndex`
|
|
318
|
+
* (clamped to the last row). A fresh SettingsList starts its cursor
|
|
319
|
+
* at index 0, so `selectItem` is used to restore the position.
|
|
320
|
+
*/
|
|
321
|
+
private async rebuildList(targetIndex: number): Promise<void> {
|
|
322
|
+
const list = await this.buildSettingsList();
|
|
323
|
+
this.settingsList = list;
|
|
324
|
+
const count = this.options.servers.length;
|
|
325
|
+
if (count === 0) {
|
|
326
|
+
this._selectedIndex = 0;
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const target = Math.min(targetIndex, count - 1);
|
|
330
|
+
this._selectedIndex = target;
|
|
331
|
+
this.settingsList.selectItem(`server-${target}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// -- add wizard ---------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Opens the add wizard at step 1/3 (URL). Esc at any step cancels the
|
|
338
|
+
* whole wizard; the server is persisted only after the final step.
|
|
339
|
+
*/
|
|
340
|
+
private beginWizard(): void {
|
|
341
|
+
this.mode = "wizard";
|
|
342
|
+
this.wizardStep = 0;
|
|
343
|
+
this.wizardUrl = "";
|
|
344
|
+
this.wizardId = "";
|
|
345
|
+
this.showWizardStep();
|
|
346
|
+
this.options.tui.requestRender();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private showWizardStep(): void {
|
|
350
|
+
const { theme, tui } = this.options;
|
|
351
|
+
const step = this.wizardStep;
|
|
352
|
+
|
|
353
|
+
const dialog =
|
|
354
|
+
step === 0
|
|
355
|
+
? new InputDialog({
|
|
356
|
+
theme,
|
|
357
|
+
tui,
|
|
358
|
+
title: TITLES.addServerStep(1),
|
|
359
|
+
message: fieldMessage(FIELDS.serverUrl),
|
|
360
|
+
placeholder: FIELDS.serverUrl.placeholder,
|
|
361
|
+
validate: normalizeServerUrl,
|
|
362
|
+
onSubmit: (value) => this.wizardSubmit(value),
|
|
363
|
+
onCancel: () => this.cancelWizard(),
|
|
364
|
+
})
|
|
365
|
+
: step === 1
|
|
366
|
+
? new InputDialog({
|
|
367
|
+
theme,
|
|
368
|
+
tui,
|
|
369
|
+
title: TITLES.addServerStep(2),
|
|
370
|
+
message: fieldMessage(FIELDS.providerId, "(optional)"),
|
|
371
|
+
placeholder: FIELDS.providerId.placeholder,
|
|
372
|
+
onSubmit: (value) => this.wizardSubmit(value),
|
|
373
|
+
onCancel: () => this.cancelWizard(),
|
|
374
|
+
})
|
|
375
|
+
: new InputDialog({
|
|
376
|
+
theme,
|
|
377
|
+
tui,
|
|
378
|
+
title: TITLES.addServerStep(3),
|
|
379
|
+
message: fieldMessage(FIELDS.displayName, "(optional)"),
|
|
380
|
+
placeholder: FIELDS.displayName.placeholder,
|
|
381
|
+
onSubmit: (value) => this.wizardSubmit(value),
|
|
382
|
+
onCancel: () => this.cancelWizard(),
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
this.wizardDialog = dialog;
|
|
386
|
+
dialog.focused = this.isFocused;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private wizardSubmit(value: string): void {
|
|
390
|
+
if (this.wizardStep === 0) {
|
|
391
|
+
this.wizardUrl = value;
|
|
392
|
+
this.wizardStep = 1;
|
|
393
|
+
this.showWizardStep();
|
|
394
|
+
} else if (this.wizardStep === 1) {
|
|
395
|
+
this.wizardId = value;
|
|
396
|
+
this.wizardStep = 2;
|
|
397
|
+
this.showWizardStep();
|
|
398
|
+
} else {
|
|
399
|
+
// Final step (display name): commit the collected fields
|
|
400
|
+
this.wizardDialog = undefined;
|
|
401
|
+
void this.saveNewServer(this.wizardUrl, this.wizardId, value);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
this.options.tui.requestRender();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private cancelWizard(): void {
|
|
408
|
+
this.wizardDialog = undefined;
|
|
409
|
+
this.mode = "list";
|
|
410
|
+
this.options.tui.requestRender();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private async saveNewServer(
|
|
414
|
+
url: string,
|
|
415
|
+
id: string,
|
|
416
|
+
name: string,
|
|
417
|
+
): Promise<void> {
|
|
418
|
+
const next = [
|
|
419
|
+
...this.options.servers,
|
|
420
|
+
{
|
|
421
|
+
url,
|
|
422
|
+
...(id.length > 0 ? { id } : {}),
|
|
423
|
+
...(name.length > 0 ? { name } : {}),
|
|
424
|
+
},
|
|
425
|
+
];
|
|
426
|
+
try {
|
|
427
|
+
await this.options.persist(next);
|
|
428
|
+
// Adopt the persisted list and rebuild so the new row appears;
|
|
429
|
+
// the new server is appended last — put the cursor on it
|
|
430
|
+
this.options.servers = next;
|
|
431
|
+
await this.rebuildList(next.length - 1);
|
|
432
|
+
this.mode = "list";
|
|
433
|
+
} catch (err) {
|
|
434
|
+
this.options.onError(String(err));
|
|
435
|
+
this.mode = "list";
|
|
436
|
+
}
|
|
437
|
+
this.options.tui.requestRender();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// -- delete ------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
private beginConfirm(): void {
|
|
443
|
+
const server = this.options.servers[this.selectedIndex()];
|
|
444
|
+
if (!server) return;
|
|
445
|
+
|
|
446
|
+
this.mode = "confirm";
|
|
447
|
+
this.confirmDialog = new ConfirmDialog({
|
|
448
|
+
theme: this.options.theme,
|
|
449
|
+
tui: this.options.tui,
|
|
450
|
+
title: TITLES.deleteServer,
|
|
451
|
+
message: `Delete "${server.url}"?`,
|
|
452
|
+
onConfirm: () => {
|
|
453
|
+
this.confirmDialog = undefined;
|
|
454
|
+
void this.deleteSelected();
|
|
455
|
+
},
|
|
456
|
+
onCancel: () => {
|
|
457
|
+
this.confirmDialog = undefined;
|
|
458
|
+
this.mode = "list";
|
|
459
|
+
this.options.tui.requestRender();
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
this.confirmDialog.focused = this.isFocused;
|
|
463
|
+
this.options.tui.requestRender();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private async deleteSelected(): Promise<void> {
|
|
467
|
+
const idx = this.selectedIndex();
|
|
468
|
+
const servers = this.options.servers;
|
|
469
|
+
const next = servers.filter((_, i) => i !== idx);
|
|
470
|
+
try {
|
|
471
|
+
await this.options.persist(next);
|
|
472
|
+
// Adopt the persisted list and rebuild so the row disappears;
|
|
473
|
+
// the server that followed the deleted one now sits at the same
|
|
474
|
+
// index (rebuildList clamps when the last row was deleted)
|
|
475
|
+
this.options.servers = next;
|
|
476
|
+
await this.rebuildList(idx);
|
|
477
|
+
this.mode = "list";
|
|
478
|
+
} catch (err) {
|
|
479
|
+
this.options.onError(String(err));
|
|
480
|
+
this.mode = "list";
|
|
481
|
+
}
|
|
482
|
+
this.options.tui.requestRender();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private async handleFieldChange(field: string, value: string): Promise<void> {
|
|
486
|
+
const idx = this._selectedIndex;
|
|
487
|
+
const server = this.options.servers[idx];
|
|
488
|
+
if (!server) return;
|
|
489
|
+
|
|
490
|
+
const next = this.options.servers.map((s, i) => {
|
|
491
|
+
if (i !== idx) return s;
|
|
492
|
+
if (field === "url") return { ...s, url: value };
|
|
493
|
+
if (field === "id") {
|
|
494
|
+
const { id, ...rest } = s;
|
|
495
|
+
return value.length === 0 ? rest : { ...rest, id: value };
|
|
496
|
+
}
|
|
497
|
+
const { name, ...rest } = s;
|
|
498
|
+
return value.length === 0 ? rest : { ...rest, name: value };
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
await this.options.persist(next);
|
|
503
|
+
// URL/id/name changes alter row labels → rebuild the list;
|
|
504
|
+
// keep the cursor on the edited server
|
|
505
|
+
this.options.servers = next;
|
|
506
|
+
await this.rebuildList(idx);
|
|
507
|
+
this.options.tui.requestRender();
|
|
508
|
+
} catch (err) {
|
|
509
|
+
this.options.onError(String(err));
|
|
510
|
+
this.options.tui.requestRender();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { LLAMA_SERVER_URL } from "../constants";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared user-facing wording for the UI editors. Centralizing these keeps
|
|
5
|
+
* every concept named consistently across row labels, dialog titles,
|
|
6
|
+
* prompts, descriptions and hints — e.g. `Pattern` always carries the
|
|
7
|
+
* `'startsWith'` matching note, so the field is never ambiguous.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Definition of one editable field, as shown to the user. */
|
|
11
|
+
export interface Field {
|
|
12
|
+
/** Term for the field, used as `SettingItem` label and composed into
|
|
13
|
+
* dialog titles (`Edit ${field.label}`). */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Parenthetical appended to the field's dialog prompt. */
|
|
16
|
+
note?: string;
|
|
17
|
+
/** Row description in the containing `SettingsList`. */
|
|
18
|
+
description: string;
|
|
19
|
+
/** Example value shown dim as `e.g., <placeholder>` in input dialogs. */
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Every editable field, as shown to the user. The single source of
|
|
24
|
+
* truth for a field's label, row description, dialog prompt and
|
|
25
|
+
* placeholder.
|
|
26
|
+
*/
|
|
27
|
+
export const FIELDS = {
|
|
28
|
+
pattern: {
|
|
29
|
+
label: "Pattern",
|
|
30
|
+
note: "(using 'startsWith')",
|
|
31
|
+
description: "Model id prefix (longest match wins - uses 'startsWith')",
|
|
32
|
+
placeholder: "qwen-3",
|
|
33
|
+
},
|
|
34
|
+
serverUrl: {
|
|
35
|
+
label: "Server URL",
|
|
36
|
+
description: "Server URL (http://host or http://host:port)",
|
|
37
|
+
placeholder: LLAMA_SERVER_URL,
|
|
38
|
+
},
|
|
39
|
+
providerId: {
|
|
40
|
+
label: "Provider ID",
|
|
41
|
+
note: "(empty uses auto-detected)",
|
|
42
|
+
description: "Provider ID (empty uses auto-detected)",
|
|
43
|
+
placeholder: "llama-local",
|
|
44
|
+
},
|
|
45
|
+
displayName: {
|
|
46
|
+
label: "Display name",
|
|
47
|
+
description: "Custom display name",
|
|
48
|
+
placeholder: "Local workstation",
|
|
49
|
+
},
|
|
50
|
+
inputCost: {
|
|
51
|
+
label: "Input cost",
|
|
52
|
+
note: "(per 1M tokens)",
|
|
53
|
+
description: "Token cost per 1M input tokens",
|
|
54
|
+
placeholder: "0.15",
|
|
55
|
+
},
|
|
56
|
+
outputCost: {
|
|
57
|
+
label: "Output cost",
|
|
58
|
+
note: "(per 1M tokens)",
|
|
59
|
+
description: "Token cost per 1M output tokens",
|
|
60
|
+
placeholder: "0.6",
|
|
61
|
+
},
|
|
62
|
+
cacheReadCost: {
|
|
63
|
+
label: "Cache read cost",
|
|
64
|
+
note: "(per 1M tokens)",
|
|
65
|
+
description: "Token cost per 1M cached tokens (read)",
|
|
66
|
+
placeholder: "0.01",
|
|
67
|
+
},
|
|
68
|
+
cacheWriteCost: {
|
|
69
|
+
label: "Cache write cost",
|
|
70
|
+
note: "(per 1M tokens)",
|
|
71
|
+
description: "Token cost per 1M cached tokens (write)",
|
|
72
|
+
placeholder: "0.02",
|
|
73
|
+
},
|
|
74
|
+
capabilities: {
|
|
75
|
+
label: "Capabilities",
|
|
76
|
+
description: "Model capabilities (replaces detected)",
|
|
77
|
+
},
|
|
78
|
+
reasoning: {
|
|
79
|
+
label: "Reasoning",
|
|
80
|
+
description: "Is this a reasoning model? (true = default)",
|
|
81
|
+
},
|
|
82
|
+
contextSize: {
|
|
83
|
+
label: "Context size",
|
|
84
|
+
note: "(0 = autodetect)",
|
|
85
|
+
description: "Override detected context size (0 = autodetect)",
|
|
86
|
+
placeholder: "32768",
|
|
87
|
+
},
|
|
88
|
+
maxTokens: {
|
|
89
|
+
label: "Max tokens",
|
|
90
|
+
note: "(0 = autodetect)",
|
|
91
|
+
description: "Override max generation tokens (0 = autodetect)",
|
|
92
|
+
placeholder: "4096",
|
|
93
|
+
},
|
|
94
|
+
} as const satisfies Record<string, Field>;
|
|
95
|
+
|
|
96
|
+
/** Dialog prompt for a field: the `label` plus its parenthetical `note`
|
|
97
|
+
* (or an overriding note, e.g. the add-server wizard's "(optional)").
|
|
98
|
+
*/
|
|
99
|
+
export const fieldMessage = (field: Field, note = field.note): string =>
|
|
100
|
+
note ? `${field.label} ${note}` : field.label;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Dialog titles. `edit` composes the shared `Edit <term>` title so the
|
|
104
|
+
* phrasing stays uniform across all field submenus; `addServerStep`
|
|
105
|
+
* formats the add-server wizard's step counter.
|
|
106
|
+
*/
|
|
107
|
+
export const TITLES = {
|
|
108
|
+
edit: (term: string) => `Edit ${term}`,
|
|
109
|
+
addServerStep: (step: 1 | 2 | 3) => `Add server · ${step}/3`,
|
|
110
|
+
addOverride: "Add override",
|
|
111
|
+
deleteServer: "Delete server",
|
|
112
|
+
deleteOverride: "Delete override",
|
|
113
|
+
} as const;
|
|
114
|
+
|
|
115
|
+
/** Keybinding hint lines shared by the editors' lists */
|
|
116
|
+
export const HINTS = {
|
|
117
|
+
/** Row description for an editable server in the `/models servers` list */
|
|
118
|
+
serverRow:
|
|
119
|
+
"Enter: edit URL/id/name · (a) add server · (d) delete · Esc: done",
|
|
120
|
+
/** Row description for an override entry in the per-server entry list */
|
|
121
|
+
overrideEntryRow:
|
|
122
|
+
"Enter: edit fields · (a) add override · (d) delete · Esc back",
|
|
123
|
+
/** Hint line when the servers list is empty (no rows → no description) */
|
|
124
|
+
emptyServers: " (a) add server · Esc done",
|
|
125
|
+
/** Hint line when the override entry list is empty */
|
|
126
|
+
emptyOverrideEntries: " (a) add override · Esc back",
|
|
127
|
+
} as const;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ServerStatus } from "../enums/serverStatus";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Probes a llama-server's `/health` endpoint and classifies the outcome.
|
|
5
|
+
*
|
|
6
|
+
* The single source of truth for health classification — both
|
|
7
|
+
* `Server.isReady` and the UI health indicators delegate here.
|
|
8
|
+
*
|
|
9
|
+
* - HTTP success with `{ status: "ok" }` → `READY`
|
|
10
|
+
* - The request is still in flight when the deadline passes
|
|
11
|
+
* (`AbortSignal.timeout` aborts it) → `TIMEOUT`
|
|
12
|
+
* - Anything else (network error, non-JSON or non-ok payload)
|
|
13
|
+
* → `UNREACHABLE`
|
|
14
|
+
*
|
|
15
|
+
* The HTTP status code itself is not inspected: a llama-server answers
|
|
16
|
+
* `/health` with the payload's `status` field, and both classification
|
|
17
|
+
* branches key off it (matching the behavior of `ApiClient.get`, which
|
|
18
|
+
* also ignores status codes and parses the body).
|
|
19
|
+
*
|
|
20
|
+
* @param url The bare origin of the llama-server (the endpoint is
|
|
21
|
+
* `<url>/health`, without the `/v1` prefix)
|
|
22
|
+
* @param timeout Maximum time (ms) to wait for the health check
|
|
23
|
+
* @param apiKey Optional API key, sent as a Bearer token — servers
|
|
24
|
+
* behind an API-key gate would otherwise report UNREACHABLE
|
|
25
|
+
* @returns The classified server status
|
|
26
|
+
*/
|
|
27
|
+
export const checkServerHealth = async (
|
|
28
|
+
url: string,
|
|
29
|
+
timeout: number,
|
|
30
|
+
apiKey?: string,
|
|
31
|
+
): Promise<ServerStatus> => {
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(`${url}/health`, {
|
|
34
|
+
signal: AbortSignal.timeout(timeout),
|
|
35
|
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
|
|
36
|
+
});
|
|
37
|
+
const data = (await response.json()) as { status?: string };
|
|
38
|
+
return data.status === "ok" ? ServerStatus.READY : ServerStatus.UNREACHABLE;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// `AbortSignal.timeout` rejects `fetch` with a `TimeoutError`
|
|
41
|
+
// DOMException (some runtimes surface it as `AbortError` with a
|
|
42
|
+
// timeout message).
|
|
43
|
+
const isTimeout =
|
|
44
|
+
error instanceof Error &&
|
|
45
|
+
(error.name === "TimeoutError" || error.name === "AbortError");
|
|
46
|
+
return isTimeout ? ServerStatus.TIMEOUT : ServerStatus.UNREACHABLE;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
|
-
* Read/write access to
|
|
4
|
+
* Read/write access to a settings.json file on behalf of this extension.
|
|
7
5
|
* Owns serialization of writes and atomicity; knows nothing about pi's
|
|
8
6
|
* SettingsManager.
|
|
9
7
|
*/
|
|
@@ -11,9 +9,7 @@ export class SettingsStore {
|
|
|
11
9
|
/** Serializes whole-file read-modify-write cycles (last write wins). */
|
|
12
10
|
private writeQueue: Promise<void> = Promise.resolve();
|
|
13
11
|
|
|
14
|
-
constructor(
|
|
15
|
-
private readonly path: string = join(getAgentDir(), "settings.json"),
|
|
16
|
-
) {}
|
|
12
|
+
constructor(private readonly path: string) {}
|
|
17
13
|
|
|
18
14
|
/**
|
|
19
15
|
* Reads and parses the whole settings file.
|