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
@@ -1,54 +1,16 @@
1
- import {
2
- getSettingsListTheme,
3
- type ExtensionAPI,
4
- type ExtensionCommandContext,
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
5
4
  } from "@earendil-works/pi-coding-agent";
6
- import {
7
- AutocompleteItem,
8
- SettingsList,
9
- type SettingItem,
10
- } from "@earendil-works/pi-tui";
5
+ import { AutocompleteItem } from "@earendil-works/pi-tui";
11
6
  import { PROVIDER_NAME } from "../constants";
12
- import { Action } from "../enums/action";
13
- import { Mode } from "../enums/mode";
14
- import { Status } from "../enums/status";
15
- import { LlamaSettings } from "../interfaces/settings";
16
- import { BaseModel } from "../models/baseModel";
17
- import { createOverrideSettingsList } from "../ui/overrideSettingsList";
18
- import { ServerSettingsList } from "../ui/serverSettingsList";
19
- import { errorMessage } from "../utils/errors";
20
- import { EventManager } from "./events";
7
+ import { OverrideSettingsList } from "../ui/editors/override/overrideList";
8
+ import { ServerSettingsList } from "../ui/editors/server/serverEditor";
9
+ import { SettingsEditor } from "../ui/settings";
10
+ import { ModelsMenu } from "./command/models";
21
11
  import { ServerManager } from "./server";
22
12
  import type { LlamaSettingsManager } from "./settings";
23
13
 
24
- /**
25
- * Identifiers of the editable fields shown in `/models settings`.
26
- * Values match the scalar `LlamaSettings` keys.
27
- */
28
- export enum Options {
29
- REACT_TO_MODEL_SELECT = "reactToModelSelect",
30
- AUTOLOAD_ON_MESSAGE = "autoloadOnMessage",
31
- SORT_BY = "sortBy",
32
- POLLING_TIMEOUT = "pollingTimeout",
33
- SERVER_TIMEOUT = "serverTimeout",
34
- }
35
-
36
- type SortByValue = NonNullable<LlamaSettings["sortBy"]>;
37
-
38
- const SORT_VALUES: SortByValue[] = [
39
- "asc",
40
- "desc",
41
- "asc-name",
42
- "desc-name",
43
- "api",
44
- ];
45
-
46
- /** Presets (ms) for `pollingTimeout` */
47
- const POLLING_PRESETS = [15000, 30000, 60000, 120000, 300000];
48
-
49
- /** Presets (ms) for `serverTimeout` */
50
- const SERVER_PRESETS = [500, 1000, 2000, 5000, 10000];
51
-
52
14
  /**
53
15
  * `/models` subcommand completions. Module-level so
54
16
  * {@link CommandManager.getArgumentCompletions} doesn't rebuild the
@@ -82,105 +44,15 @@ const ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
82
44
  },
83
45
  ];
84
46
 
85
- /**
86
- * Formats milliseconds compactly for display (e.g. `500 -> "500ms"`,
87
- * `60000 -> "60s"`).
88
- */
89
- export const formatMs = (ms: number): string =>
90
- ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`;
91
-
92
- /**
93
- * Parses a value produced by `formatMs()` back to milliseconds.
94
- * Only ever called with values from the preset lists.
95
- */
96
- const parseMs = (value: string): number =>
97
- value.endsWith("ms")
98
- ? Number(value.slice(0, -2))
99
- : Number(value.slice(0, -1)) * 1000;
100
-
101
- /**
102
- * Builds the `SettingsList` items for `/models settings` from the current
103
- * (merged) values of the scalar `llamaSettings` fields.
104
- */
105
- export const buildSettingsItems = async (
106
- settings: LlamaSettingsManager,
107
- ): Promise<SettingItem[]> => {
108
- const { pollingTimeout, serverTimeout } = await settings.resolveTimeouts();
109
-
110
- return [
111
- {
112
- id: Options.REACT_TO_MODEL_SELECT,
113
- label: "React to model selection",
114
- description: "Load the model when you pick it in Pi (immediate)",
115
- currentValue: (await settings.resolveReactToModelSelect()) ? "on" : "off",
116
- values: ["on", "off"],
117
- },
118
- {
119
- id: Options.AUTOLOAD_ON_MESSAGE,
120
- label: "Autoload on message",
121
- description:
122
- "Auto-load the selected model when you send a message (immediate)",
123
- currentValue: (await settings.resolveAutoloadOnMessage()) ? "on" : "off",
124
- values: ["on", "off"],
125
- },
126
- {
127
- id: Options.SORT_BY,
128
- label: "Sort models by",
129
- description: "Order of models in /models (next open)",
130
- currentValue: await settings.resolveSortBy(),
131
- values: [...SORT_VALUES],
132
- },
133
- {
134
- id: Options.POLLING_TIMEOUT,
135
- label: "Polling timeout",
136
- description: "Max model-load wait (next model load)",
137
- currentValue: formatMs(pollingTimeout),
138
- values: POLLING_PRESETS.map(formatMs),
139
- },
140
- {
141
- id: Options.SERVER_TIMEOUT,
142
- label: "Server timeout",
143
- description: "Health check / SSE probe timeout (next model load)",
144
- currentValue: formatMs(serverTimeout),
145
- values: SERVER_PRESETS.map(formatMs),
146
- },
147
- ];
148
- };
149
-
150
- /**
151
- * Persists a change made in the settings menu.
152
- * Maps the `SettingsList` id/value pair to the matching `llamaSettings`
153
- * key and writes it via `LlamaSettingsManager.setLlamaSetting()`.
154
- */
155
- export const applySettingChange = async (
156
- id: string,
157
- newValue: string,
158
- settings: LlamaSettingsManager,
159
- ): Promise<void> => {
160
- switch (id) {
161
- case Options.REACT_TO_MODEL_SELECT:
162
- await settings.setLlamaSetting("reactToModelSelect", newValue === "on");
163
- return;
164
- case Options.AUTOLOAD_ON_MESSAGE:
165
- await settings.setLlamaSetting("autoloadOnMessage", newValue === "on");
166
- return;
167
- case Options.SORT_BY:
168
- await settings.setLlamaSetting("sortBy", newValue as SortByValue);
169
- return;
170
- case Options.POLLING_TIMEOUT:
171
- await settings.setLlamaSetting("pollingTimeout", parseMs(newValue));
172
- return;
173
- case Options.SERVER_TIMEOUT:
174
- await settings.setLlamaSetting("serverTimeout", parseMs(newValue));
175
- return;
176
- }
177
- };
178
-
179
47
  export class CommandManager {
48
+ private readonly modelsMenu: ModelsMenu;
49
+
180
50
  constructor(
181
51
  private readonly serverManager: ServerManager,
182
52
  private readonly settings: LlamaSettingsManager,
183
- ) {}
53
+ ) {
54
+ this.modelsMenu = new ModelsMenu(serverManager, settings);
55
+ }
184
56
 
185
57
  /**
186
58
  * Sets up the argument completions for the `/models` command
@@ -207,8 +79,8 @@ export class CommandManager {
207
79
  ctx: ExtensionCommandContext,
208
80
  pi: ExtensionAPI,
209
81
  ) {
210
- // Settings menu: no network round-trip needed, handle before any
211
- // server updates / unreachable-server notifications
82
+ // Settings menu: no provider re-registration needed (sortBy, timeouts,
83
+ // etc. don't affect the model registry)
212
84
  if (args === "settings") {
213
85
  await this.runSettingsMenu(ctx);
214
86
  return;
@@ -236,6 +108,11 @@ export class CommandManager {
236
108
  this.notifyNotFound(ctx, url);
237
109
  }
238
110
 
111
+ // Notify about other warnings (e.g. unauthorized servers)
112
+ for (const warning of this.serverManager.getWarnings()) {
113
+ ctx.ui.notify(warning, "warning");
114
+ }
115
+
239
116
  if (args === "unload") {
240
117
  const models = await this.serverManager.getAllModels();
241
118
  await Promise.all(models.map((model) => model.unload()));
@@ -251,18 +128,12 @@ export class CommandManager {
251
128
  }
252
129
 
253
130
  // Interactive menu: show <name> (<server_url>)
254
- await this.runModelsMenu(ctx, pi);
131
+ await this.modelsMenu.show(ctx, pi);
255
132
  }
256
133
 
257
134
  /**
258
135
  * Runs the interactive settings menu for the scalar `llamaSettings`
259
136
  * fields. Enter/Space cycles the value under the cursor; Esc closes.
260
- *
261
- * Writes go to the global `~/.pi/agent/settings.json` via
262
- * `LlamaSettingsManager.setLlamaSetting()`; write errors are notified
263
- * and leave the dialog open with values unchanged. These settings
264
- * (reactToModelSelect, autoloadOnMessage, sortBy, timeouts) do not
265
- * require provider re-registration.
266
137
  */
267
138
  private async runSettingsMenu(ctx: ExtensionCommandContext): Promise<void> {
268
139
  if (ctx.mode !== "tui") {
@@ -273,36 +144,12 @@ export class CommandManager {
273
144
  return;
274
145
  }
275
146
 
276
- const items = await buildSettingsItems(this.settings);
277
-
278
- await ctx.ui.custom<void>(
279
- (_tui, _theme, _kb, done) =>
280
- new SettingsList(
281
- items,
282
- Math.min(items.length + 2, 15),
283
- getSettingsListTheme(),
284
- (id, newValue) => {
285
- applySettingChange(id, newValue, this.settings).catch(
286
- (err: unknown) => {
287
- const message = errorMessage(err);
288
- ctx.ui.notify(message, "error");
289
- },
290
- );
291
- },
292
- () => done(undefined),
293
- ),
294
- );
147
+ await SettingsEditor.show(ctx.ui, this.settings);
295
148
  }
296
149
 
297
150
  /**
298
- * Runs the interactive servers editor for `llamaSettings.servers`.
299
- * Enter on a server row drills into its field-edit submenu (URL/id/name);
300
- * a adds a new server (inline Input), d deletes (after confirmation);
301
- * Esc closes.
302
- *
303
- * Writes go to the global `~/.pi/agent/settings.json` via
304
- * `LlamaSettingsManager.setLlamaSetting()`; write errors are notified and
305
- * the editor stays open with the pre-mutation list. After closing,
151
+ * Runs the interactive servers editor for `llamaSettings.servers`
152
+ * (see `ServerSettingsList` for the editing semantics). After closing,
306
153
  * providers are re-registered so server changes apply immediately.
307
154
  */
308
155
  private async runServersEditor(
@@ -317,49 +164,17 @@ export class CommandManager {
317
164
  return;
318
165
  }
319
166
 
320
- const servers = await this.settings.getLlamaServers();
321
- const { serverTimeout } = await this.settings.resolveTimeouts();
167
+ await ServerSettingsList.show(ctx.ui, this.settings);
322
168
 
323
- await ctx.ui.custom<void>(
324
- (tui, theme, keybindings, done) =>
325
- new ServerSettingsList({
326
- tui,
327
- theme,
328
- keybindings,
329
- servers,
330
- persist: (next) => this.settings.setLlamaSetting("servers", next),
331
- done: () => {
332
- done(undefined);
333
- // Re-register providers so the updated server list takes effect
334
- this.serverManager.update(pi);
335
- },
336
- onError: (message) => ctx.ui.notify(message, "error"),
337
- serverTimeout,
338
- }),
339
- );
169
+ // Re-register providers so the updated server list takes effect
170
+ await this.serverManager.update(pi);
340
171
  }
341
172
 
342
173
  /**
343
174
  * Runs the interactive overrides editor for
344
- * `llamaSettings.servers[].overrides`: a SettingsList of servers drilling
345
- * down into each server's override entries (one row per pattern, with
346
- * add/delete support). Within a server's entry list: Enter drills into
347
- * the field-edit submenu; a adds, d deletes (after confirmation).
348
- *
349
- * Fields use a mix of finite (Enter to cycle) and infinite (Enter to
350
- * type) editing:
351
- *
352
- * - Pattern / costs (input, output, cacheRead, cacheWrite): infinite —
353
- * Enter opens an Input for typing.
354
- * - Capabilities: finite — Enter cycles between `text` and `text | image`.
355
- * - Reasoning: finite — Enter cycles between `true` and `false`.
356
- *
357
- * Servers themselves are not managed here — use `/models servers`.
358
- *
359
- * Writes go to the global `~/.pi/agent/settings.json` via
360
- * `LlamaSettingsManager.setLlamaSetting()`; write errors are notified and
361
- * leave the values unchanged. After closing, providers are
362
- * re-registered so new overrides take effect on the next request.
175
+ * `llamaSettings.servers[].overrides` (see `OverrideSettingsList` for
176
+ * the editing semantics). After closing, providers are re-registered
177
+ * so new overrides take effect on the next request.
363
178
  */
364
179
  private async runOverridesEditor(
365
180
  ctx: ExtensionCommandContext,
@@ -373,23 +188,10 @@ export class CommandManager {
373
188
  return;
374
189
  }
375
190
 
376
- const servers = await this.settings.getLlamaServers();
377
- await ctx.ui.custom<void>((tui, theme, keybindings, done) =>
378
- createOverrideSettingsList({
379
- tui,
380
- theme,
381
- keybindings,
382
- servers,
383
- persist: (next) => this.settings.setLlamaSetting("servers", next),
384
- done: () => {
385
- done(undefined);
386
- // Re-register providers so the updated overrides take effect
387
- this.serverManager.update(pi);
388
- },
389
- onError: (message) => ctx.ui.notify(message, "error"),
390
- onChanged: () => {}, // no per-change notification needed
391
- }),
392
- );
191
+ await OverrideSettingsList.show(ctx.ui, this.settings);
192
+
193
+ // Re-register providers so the updated overrides take effect
194
+ await this.serverManager.update(pi);
393
195
  }
394
196
 
395
197
  /**
@@ -398,233 +200,4 @@ export class CommandManager {
398
200
  private notifyNotFound(ctx: ExtensionCommandContext, url: string): void {
399
201
  ctx.ui.notify(`${PROVIDER_NAME} unreachable at ${url}`, "error");
400
202
  }
401
-
402
- /**
403
- * Runs the interactive model selection menu.
404
- */
405
- private async runModelsMenu(
406
- ctx: ExtensionCommandContext,
407
- pi: ExtensionAPI,
408
- ): Promise<void> {
409
- const event = await this.modelSelectionHandler(
410
- ctx,
411
- await this.serverManager.getAllModels(),
412
- );
413
-
414
- if (!event) return;
415
- const { action, model } = event;
416
-
417
- // Action: Cancel
418
- if (!action || action === Action.CANCEL) return;
419
-
420
- // Action: Info
421
- if (action === Action.INFO) {
422
- const info = await model.getInfo();
423
- ctx.ui.notify(`${info}`, "info");
424
- return;
425
- }
426
-
427
- // Action: Unload
428
- if (action === Action.UNLOAD) {
429
- await model.unload();
430
- ctx.ui.notify(`Unloaded ${model.name}`, "info");
431
- return;
432
- }
433
-
434
- // Action: Switch
435
- if (action === Action.SWITCH) {
436
- const { serverId } = model;
437
- const piModel = ctx.modelRegistry.find(serverId, model.id);
438
- if (!piModel)
439
- throw new Error(`Cannot find model ${model.name} in pi registry`);
440
-
441
- await pi.setModel(piModel);
442
- ctx.ui.notify(`Model ${model.name} ready`, "info");
443
- return;
444
- }
445
-
446
- // Actions: Load / Load & Switch / Retry
447
- const loadActions = [Action.LOAD, Action.LOAD_AND_SWITCH, Action.RETRY];
448
- if (loadActions.includes(action)) {
449
- ctx.ui.notify(`Loading ${model.name}...`, "info");
450
- // Mark the load as in-flight so session_before_switch can warn about
451
- // it (see EventManager.inflightModel for the coupling rationale)
452
- EventManager.inflightModel = model;
453
-
454
- // Subscribe to progress events; skip when the server is gone
455
- // (removed/edited away mid-load → getServer returns undefined)
456
- const server = this.serverManager.getServer(model);
457
- const cleanupProgress =
458
- server?.sseManager.subscribeToProgress(
459
- model.id,
460
- (percentage, stage) => {
461
- const stageText = stage ? ` (${stage})` : "";
462
- ctx.ui.notify(
463
- `Loading ${model.name}... [${percentage}%${stageText}]`,
464
- "info",
465
- );
466
- },
467
- ) ?? (() => {});
468
-
469
- const onSuccess = async () => {
470
- const { serverId } = model;
471
- const piModel = ctx.modelRegistry.find(serverId, model.id);
472
- if (!piModel)
473
- throw new Error(`Cannot find model ${model.name} in pi registry`);
474
-
475
- // Verify auth
476
- if ((await model.getStatus()) === Status.UNAUTHORIZED)
477
- throw new Error(
478
- `Unauthorized for ${model.name}. Use /login and add your API key.`,
479
- );
480
-
481
- // Verify failure
482
- if ((await model.getStatus()) === Status.FAILED)
483
- throw new Error(`Failed to load model ${model.name}`);
484
-
485
- // Select the model if asked
486
- if (action === Action.LOAD_AND_SWITCH) await pi.setModel(piModel);
487
-
488
- ctx.ui.notify(`Model ${model.name} ready`, "info");
489
- };
490
-
491
- const onFailure = (err: any) => {
492
- const message = errorMessage(err);
493
-
494
- try {
495
- ctx.ui.notify(message, "error");
496
- } catch {
497
- // ctx went stale between error and notification
498
- }
499
- };
500
-
501
- const onFinished = async () => {
502
- cleanupProgress();
503
- EventManager.resetInflightModel();
504
-
505
- // Re-scan providers to ensure accuracy of loaded models
506
- await this.serverManager.update(pi);
507
-
508
- // Force TUI refresh so Pi picks up the updated model states
509
- ctx.ui.setStatus(PROVIDER_NAME, " ");
510
- ctx.ui.setStatus(PROVIDER_NAME, undefined);
511
- };
512
-
513
- // Load the model without blocking the UI
514
- model.load().then(onSuccess).catch(onFailure).finally(onFinished);
515
- }
516
- }
517
-
518
- /**
519
- * Handles the menu for model selection.
520
- * Loops: select model → select action → handle action.
521
- *
522
- * Escape on actions menu goes back to model selection.
523
- * Escape on model selection exits.
524
- *
525
- * @returns The selected action and model
526
- */
527
- private async modelSelectionHandler(
528
- ctx: ExtensionCommandContext,
529
- models: BaseModel[],
530
- ): Promise<{ action: Action; model: BaseModel } | null> {
531
- while (true) {
532
- // Select the model
533
- const model = await this.selectModel(ctx, models);
534
- if (!model) return null;
535
-
536
- // Select the action
537
- const actions = await this.getActionsForModel(model);
538
- const action = await this.selectAction(ctx, model, actions);
539
- if (action === null) {
540
- // Escape key pressed => back to model selection
541
- continue;
542
- }
543
-
544
- // Return the selected action and model
545
- return { action, model };
546
- }
547
- }
548
-
549
- /**
550
- * Select a model from the list. Returns null if user cancels.
551
- *
552
- * @returns The model selected by the user
553
- */
554
- private async selectModel(
555
- ctx: ExtensionCommandContext,
556
- models: BaseModel[],
557
- ): Promise<BaseModel | null> {
558
- const labels = await Promise.all(
559
- models.map(async (model) => ({
560
- label: (await model.getLabel()).trim(),
561
- serverUrl: model.serverUrl,
562
- })),
563
- );
564
-
565
- // Count grapheme clusters (not UTF-16 code units) so emoji padding aligns visually
566
- const graphemeLength = (str: string) =>
567
- [...new Intl.Segmenter().segment(str)].length;
568
-
569
- // Decorate the label so the spacing makes it seem more like a table
570
- const maxLength = Math.max(
571
- ...labels.map(({ label }) => graphemeLength(label)),
572
- );
573
- const choices = labels.map(({ label, serverUrl }) => {
574
- const extraPadding = 2;
575
- const padLen = maxLength - graphemeLength(label) + extraPadding;
576
- return `${label}${" ".repeat(padLen)} [Server: ${serverUrl}]`;
577
- });
578
-
579
- const choice = await ctx.ui.select(`${PROVIDER_NAME} models:`, choices);
580
- if (!choice) return null;
581
- const idx = choices.indexOf(choice);
582
-
583
- return models[idx];
584
- }
585
-
586
- /**
587
- * Get available actions for a model based on its mode and status.
588
- *
589
- * @returns A mapping of actions for each status
590
- */
591
- private async getActionsForModel(model: BaseModel): Promise<Array<Action>> {
592
- const base = [Action.INFO, Action.CANCEL];
593
-
594
- const actions: Record<Status, Array<Action>> = {
595
- [Status.LOADED]:
596
- model.mode === Mode.ROUTER
597
- ? [Action.SWITCH, Action.UNLOAD, ...base]
598
- : [Action.SWITCH, ...base],
599
- [Status.LOADING]: [...base],
600
- [Status.FAILED]: [Action.RETRY, ...base],
601
- [Status.SLEEPING]:
602
- model.mode === Mode.ROUTER
603
- ? [Action.SWITCH, Action.UNLOAD, ...base]
604
- : [Action.SWITCH, ...base],
605
- [Status.UNLOADED]: [Action.LOAD_AND_SWITCH, Action.LOAD, ...base],
606
- [Status.UNAUTHORIZED]: [...base],
607
- };
608
-
609
- const status = await model.getStatus();
610
- return actions[status];
611
- }
612
-
613
- /**
614
- * Selects an action for a model.
615
- *
616
- * @returns The selected action
617
- */
618
- private async selectAction(
619
- ctx: ExtensionCommandContext,
620
- model: BaseModel,
621
- actions: Array<Action>,
622
- ): Promise<Action | null> {
623
- const labels = actions.map((a) => String(a));
624
- const choice = await ctx.ui.select(`${model.name}`, labels);
625
- if (!choice) return null;
626
-
627
- const idx = labels.indexOf(choice);
628
- return actions[idx];
629
- }
630
203
  }
@@ -11,8 +11,8 @@ import { ServerManager } from "./server";
11
11
 
12
12
  export class EventManager {
13
13
  /**
14
- * Model with a load currently in flight. Deliberately a class static
15
- * (REFACTOR.md §3.3): the load is started by CommandManager
14
+ * Model with a load currently in flight. Deliberately a class static:
15
+ * the load is started by CommandManager
16
16
  * (fire-and-forget from the /models editor) while the "session switched
17
17
  * mid-load" warning must be emitted here, from the session_before_switch
18
18
  * hook — a shared static is the least-plumbing bridge between the two
@@ -1,8 +1,14 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { API_TYPE, PROVIDER_NAME, type SortBy } from "../constants";
1
+ import type {
2
+ ExtensionAPI,
3
+ ProviderModelConfig,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { ApiError } from "../api/client";
6
+ import { API_TYPE, PROVIDER_NAME } from "../constants";
3
7
  import { ServerStatus } from "../enums/serverStatus";
8
+ import type { SortBy } from "../interfaces/sortBy";
4
9
  import { BaseModel } from "../models/baseModel";
5
10
  import { Server } from "../server";
11
+ import { authRequiredMessage } from "../ui/strings";
6
12
  import type { LlamaSettingsManager } from "./settings";
7
13
 
8
14
  /** Model-list comparator: negative if a sorts first, positive if b does. */
@@ -43,6 +49,7 @@ export class ServerManager {
43
49
  */
44
50
  async update(pi: ExtensionAPI, timeout?: number) {
45
51
  this.failedUrls.length = 0;
52
+ this.warnings.length = 0;
46
53
 
47
54
  // Surface warnings from strict URL parsing (dropped invalid entries)
48
55
  this.warnings.push(...this.settings.takeWarnings());
@@ -58,9 +65,9 @@ export class ServerManager {
58
65
  }
59
66
 
60
67
  // Unregister providers that disappeared (removed or edited away);
61
- // no-op for providers that were never registered
68
+ // `seen` tracks all kept providerIds, so we skip those still present.
62
69
  for (const old of this.servers) {
63
- if (fresh.some((f) => f.providerId === old.providerId)) continue;
70
+ if (seen.has(old.providerId)) continue;
64
71
  pi.unregisterProvider(old.providerId);
65
72
  // Optional chain is intentional despite the non-optional type: `sse`
66
73
  // is undefined until initialize() runs (async-constructor hack — see Server)
@@ -77,13 +84,7 @@ export class ServerManager {
77
84
 
78
85
  // Initialization and registration
79
86
  for (const server of registrableServers) {
80
- try {
81
- await server.initialize();
82
- await this.registerProvider(server, pi);
83
- } catch {
84
- this.failedUrls.push(server.baseUrl);
85
- continue;
86
- }
87
+ await this.registerProvider(server, pi);
87
88
  }
88
89
  }
89
90
 
@@ -128,22 +129,37 @@ export class ServerManager {
128
129
  }
129
130
 
130
131
  /**
131
- * Creates a Pi provider for the given server
132
+ * Initializes the server and creates a Pi provider.
133
+ * Handles auth errors by registering with an empty model list so the user
134
+ * can still configure the API key via `/login` or `auth.json`.
132
135
  *
133
136
  * @param server The server
137
+ * @param pi The Pi API
134
138
  */
135
139
  private async registerProvider(server: Server, pi: ExtensionAPI) {
136
- const { apiBaseUrl, models, providerId, providerName } = server;
137
- const apiKey = server.getApiKey();
138
- const modelConfigs = await Promise.all(
139
- models.map((m) => m.toProviderConfig()),
140
- );
140
+ const { apiBaseUrl, apiKey, baseUrl, models, providerId, providerName } =
141
+ server;
142
+ let modelConfigs: ProviderModelConfig[] = [];
143
+
144
+ try {
145
+ await server.initialize();
146
+ modelConfigs = await Promise.all(models.map((m) => m.toProviderConfig()));
147
+ } catch (err) {
148
+ if (err instanceof ApiError && err.type === "authentication") {
149
+ // Don't add to `failedUrls` — the server IS reachable, auth just
150
+ // isn't configured yet, so the health indicator should stay green.
151
+ this.warnings.push(authRequiredMessage(baseUrl, providerId));
152
+ } else {
153
+ this.failedUrls.push(baseUrl);
154
+ return;
155
+ }
156
+ }
141
157
 
142
158
  pi.registerProvider(providerId, {
143
159
  name: providerName,
144
160
  baseUrl: apiBaseUrl,
145
161
  api: API_TYPE,
146
- apiKey: apiKey,
162
+ apiKey,
147
163
  models: modelConfigs,
148
164
  });
149
165
  }