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
@@ -0,0 +1,248 @@
1
+ import type {
2
+ ExtensionUIContext,
3
+ Theme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
6
+ import type {
7
+ Component,
8
+ Focusable,
9
+ KeybindingsManager,
10
+ SettingItem,
11
+ TUI,
12
+ } from "@earendil-works/pi-tui";
13
+ import { SettingsList } from "@earendil-works/pi-tui";
14
+ import type { LlamaSettings } from "../../interfaces/settings";
15
+ import type { LlamaSettingsManager } from "../../managers/settings";
16
+
17
+ // ─── Options enum ─────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Identifiers of the editable fields shown in `/models settings`.
21
+ * Values match the scalar `LlamaSettings` keys.
22
+ */
23
+ export enum Options {
24
+ REACT_TO_MODEL_SELECT = "reactToModelSelect",
25
+ AUTOLOAD_ON_MESSAGE = "autoloadOnMessage",
26
+ SORT_BY = "sortBy",
27
+ POLLING_TIMEOUT = "pollingTimeout",
28
+ SERVER_TIMEOUT = "serverTimeout",
29
+ SHOW_SERVER_URLS = "showServerUrls",
30
+ }
31
+
32
+ // ─── Constants ──────────────────────────────────────────────────────────────
33
+
34
+ type SortByValue = NonNullable<LlamaSettings["sortBy"]>;
35
+
36
+ export const SORT_VALUES: SortByValue[] = [
37
+ "asc",
38
+ "desc",
39
+ "asc-name",
40
+ "desc-name",
41
+ "api",
42
+ ];
43
+
44
+ /** Presets (ms) for `pollingTimeout` */
45
+ export const POLLING_PRESETS = [15000, 30000, 60000, 120000, 300000];
46
+
47
+ /** Presets (ms) for `serverTimeout` */
48
+ export const SERVER_PRESETS = [500, 1000, 2000, 5000, 10000];
49
+
50
+ // ─── Helpers ────────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Formats milliseconds compactly for display (e.g. `500 -> "500ms"`,
54
+ * `60000 -> "60s"`).
55
+ */
56
+ export const formatMs = (ms: number): string =>
57
+ ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`;
58
+
59
+ /**
60
+ * Parses a value produced by `formatMs()` back to milliseconds.
61
+ * Only ever called with values from the preset lists.
62
+ */
63
+ const parseMs = (value: string): number =>
64
+ value.endsWith("ms")
65
+ ? Number(value.slice(0, -2))
66
+ : Number(value.slice(0, -1)) * 1000;
67
+
68
+ /**
69
+ * Builds the `SettingItem[]` for `/models settings` from the current
70
+ * (merged) values of the scalar `llamaSettings` fields.
71
+ */
72
+ export const buildSettingsItems = async (
73
+ settings: LlamaSettingsManager,
74
+ ): Promise<SettingItem[]> => {
75
+ const { pollingTimeout, serverTimeout } = await settings.resolveTimeouts();
76
+
77
+ return [
78
+ {
79
+ id: Options.REACT_TO_MODEL_SELECT,
80
+ label: "React to model selection",
81
+ description: "Load the model when you pick it in Pi (immediate)",
82
+ currentValue: (await settings.resolveReactToModelSelect()) ? "on" : "off",
83
+ values: ["on", "off"],
84
+ },
85
+ {
86
+ id: Options.AUTOLOAD_ON_MESSAGE,
87
+ label: "Autoload on message",
88
+ description:
89
+ "Auto-load the selected model when you send a message (immediate)",
90
+ currentValue: (await settings.resolveAutoloadOnMessage()) ? "on" : "off",
91
+ values: ["on", "off"],
92
+ },
93
+ {
94
+ id: Options.SORT_BY,
95
+ label: "Sort models by",
96
+ description: "Order of models in /models (next open)",
97
+ currentValue: await settings.resolveSortBy(),
98
+ values: [...SORT_VALUES],
99
+ },
100
+ {
101
+ id: Options.POLLING_TIMEOUT,
102
+ label: "Polling timeout",
103
+ description: "Max model-load wait (next model load)",
104
+ currentValue: formatMs(pollingTimeout),
105
+ values: POLLING_PRESETS.map(formatMs),
106
+ },
107
+ {
108
+ id: Options.SERVER_TIMEOUT,
109
+ label: "Server timeout",
110
+ description: "Health check / SSE probe timeout (next model load)",
111
+ currentValue: formatMs(serverTimeout),
112
+ values: SERVER_PRESETS.map(formatMs),
113
+ },
114
+ {
115
+ id: Options.SHOW_SERVER_URLS,
116
+ label: "Show server URLs",
117
+ description: "Show [Server: <url>] in /models (next open)",
118
+ currentValue: (await settings.resolveShowServerUrls()) ? "on" : "off",
119
+ values: ["on", "off"],
120
+ },
121
+ ];
122
+ };
123
+
124
+ /**
125
+ * Persists a change made in the settings menu.
126
+ * Maps the `SettingsList` id/value pair to the matching `llamaSettings`
127
+ * key and writes it via `LlamaSettingsManager.setLlamaSetting()`.
128
+ */
129
+ export const applySettingChange = async (
130
+ id: string,
131
+ newValue: string,
132
+ settings: LlamaSettingsManager,
133
+ ): Promise<void> => {
134
+ switch (id) {
135
+ case Options.REACT_TO_MODEL_SELECT:
136
+ await settings.setLlamaSetting("reactToModelSelect", newValue === "on");
137
+ return;
138
+ case Options.AUTOLOAD_ON_MESSAGE:
139
+ await settings.setLlamaSetting("autoloadOnMessage", newValue === "on");
140
+ return;
141
+ case Options.SORT_BY:
142
+ await settings.setLlamaSetting("sortBy", newValue as SortByValue);
143
+ return;
144
+ case Options.POLLING_TIMEOUT:
145
+ await settings.setLlamaSetting("pollingTimeout", parseMs(newValue));
146
+ return;
147
+ case Options.SERVER_TIMEOUT:
148
+ await settings.setLlamaSetting("serverTimeout", parseMs(newValue));
149
+ return;
150
+ case Options.SHOW_SERVER_URLS:
151
+ await settings.setLlamaSetting("showServerUrls", newValue === "on");
152
+ return;
153
+ }
154
+ };
155
+
156
+ // ─── SettingsEditor ─────────────────────────────────────────────────────────
157
+
158
+ /** Options passed to the settings editor. */
159
+ export interface SettingsEditorOptions {
160
+ tui: TUI;
161
+ theme: Theme;
162
+ keybindings: KeybindingsManager;
163
+ settings: LlamaSettingsManager;
164
+ /** Called when Esc closes the editor. */
165
+ done: () => void;
166
+ /** Called on persistence errors. */
167
+ onError: (message: string) => void;
168
+ }
169
+
170
+ /**
171
+ * Interactive settings menu for the scalar `llamaSettings` fields.
172
+ * Enter/Space cycles the value under the cursor; Esc closes.
173
+ *
174
+ * Writes go to the global/project `~/.pi/agent/settings.json` via
175
+ * `LlamaSettingsManager.setLlamaSetting()`; write errors are notified
176
+ * and leave the dialog open with values unchanged.
177
+ */
178
+ export class SettingsEditor implements Component, Focusable {
179
+ private settingsList: SettingsList | null = null;
180
+ private isFocused = false;
181
+
182
+ constructor(private readonly options: SettingsEditorOptions) {
183
+ void this.buildList();
184
+ }
185
+
186
+ /**
187
+ * Opens the editor in a modal `ui.custom` dialog and resolves when the
188
+ * user closes it (Esc). Write errors are notified via `ui` and leave
189
+ * the dialog open with values unchanged.
190
+ */
191
+ static async show(
192
+ ui: ExtensionUIContext,
193
+ settings: LlamaSettingsManager,
194
+ ): Promise<void> {
195
+ await ui.custom<void>(
196
+ (tui, theme, keybindings, done) =>
197
+ new SettingsEditor({
198
+ tui,
199
+ theme,
200
+ keybindings,
201
+ settings,
202
+ done: () => done(undefined),
203
+ onError: (message) => ui.notify(message, "error"),
204
+ }),
205
+ );
206
+ }
207
+
208
+ // -- Component -------------------------------------------------------------
209
+
210
+ invalidate(): void {
211
+ this.settingsList?.invalidate();
212
+ }
213
+
214
+ handleInput(data: string): void {
215
+ this.settingsList?.handleInput(data);
216
+ }
217
+
218
+ render(width: number): string[] {
219
+ if (!this.settingsList) return ["Loading..."];
220
+ return this.settingsList.render(width);
221
+ }
222
+
223
+ // -- Focusable -------------------------------------------------------------
224
+
225
+ get focused(): boolean {
226
+ return this.isFocused;
227
+ }
228
+
229
+ set focused(value: boolean) {
230
+ this.isFocused = value;
231
+ }
232
+
233
+ // -- internal --------------------------------------------------------------
234
+
235
+ private async buildList(): Promise<void> {
236
+ const items = await buildSettingsItems(this.options.settings);
237
+ this.settingsList = new SettingsList(
238
+ items,
239
+ Math.min(items.length + 2, 15),
240
+ getSettingsListTheme(),
241
+ (id, newValue) =>
242
+ applySettingChange(id, newValue, this.options.settings).catch(
243
+ (err: unknown) => this.options.onError(String(err)),
244
+ ),
245
+ () => this.options.done(),
246
+ );
247
+ }
248
+ }
package/src/ui/strings.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+
1
3
  import { LLAMA_SERVER_URL } from "../constants";
2
4
 
3
5
  /**
@@ -94,10 +96,12 @@ export const FIELDS = {
94
96
  } as const satisfies Record<string, Field>;
95
97
 
96
98
  /** 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;
99
+ * (or an overriding note, e.g. the add-server wizard's "(optional)"). */
100
+ export class FieldMessages {
101
+ static of(field: Field, note = field.note): string {
102
+ return note ? `${field.label} ${note}` : field.label;
103
+ }
104
+ }
101
105
 
102
106
  /**
103
107
  * Dialog titles. `edit` composes the shared `Edit <term>` title so the
@@ -124,4 +128,21 @@ export const HINTS = {
124
128
  emptyServers: " (a) add server · Esc done",
125
129
  /** Hint line when the override entry list is empty */
126
130
  emptyOverrideEntries: " (a) add override · Esc back",
131
+ /** Row description for a server in the overrides editor */
132
+ overrideServerRow: "Enter: edit this server's override entries",
127
133
  } as const;
134
+
135
+ /**
136
+ * Generates a warning message for servers that require an API key.
137
+ * Centralized so both the runtime manager and the editor wizard use
138
+ * identical wording.
139
+ */
140
+ export const authRequiredMessage = (
141
+ baseUrl: string,
142
+ providerId: string,
143
+ ): string =>
144
+ [
145
+ "[pi-llama-cpp]",
146
+ `Server at '${baseUrl}' requires a valid API key.`,
147
+ `Configure the key via '/login ${providerId}' or in '${getAgentDir()}/auth.json'.`,
148
+ ].join("\n");
@@ -1,4 +1,5 @@
1
1
  import { ServerStatus } from "../enums/serverStatus";
2
+ import type { HealthEndpoint } from "../interfaces/endpoints/health";
2
3
 
3
4
  /**
4
5
  * Probes a llama-server's `/health` endpoint and classifies the outcome.
@@ -34,7 +35,7 @@ export const checkServerHealth = async (
34
35
  signal: AbortSignal.timeout(timeout),
35
36
  headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
36
37
  });
37
- const data = (await response.json()) as { status?: string };
38
+ const data = (await response.json()) as HealthEndpoint;
38
39
  return data.status === "ok" ? ServerStatus.READY : ServerStatus.UNREACHABLE;
39
40
  } catch (error) {
40
41
  // `AbortSignal.timeout` rejects `fetch` with a `TimeoutError`
@@ -0,0 +1,21 @@
1
+ import { PROVIDER_PREFIX } from "../constants";
2
+
3
+ /**
4
+ * Resolves a server's provider ID: the custom ID when set, otherwise the
5
+ * URL-derived ID (`${PROVIDER_PREFIX}=<url>`).
6
+ *
7
+ * Single home for the rule shared by `Server.providerId` (runtime) and
8
+ * `ServerDisplay.suffix` (editor display row), so the ID shown in the
9
+ * editors can't drift from the one used at request time.
10
+ */
11
+ export class ServerIds {
12
+ /** The URL-derived ID: `<PROVIDER_PREFIX>=<url>`. */
13
+ static fromUrl(url: string): string {
14
+ return `${PROVIDER_PREFIX}=${url}`;
15
+ }
16
+
17
+ /** The effective provider ID: `customId` when set, else URL-derived. */
18
+ static resolve(url: string, customId?: string): string {
19
+ return customId ?? this.fromUrl(url);
20
+ }
21
+ }
@@ -32,7 +32,7 @@ export class SettingsStore {
32
32
  }
33
33
 
34
34
  /** Serializes (2-space indent) and writes atomically via temp file + rename. */
35
- async write(root: Record<string, unknown>): Promise<void> {
35
+ private async write(root: Record<string, unknown>): Promise<void> {
36
36
  const tmp = `${this.path}.tmp`;
37
37
  await writeFile(tmp, JSON.stringify(root, null, 2), "utf-8");
38
38
  await rename(tmp, this.path);
@@ -0,0 +1,129 @@
1
+ import { LLAMA_SERVER_URL } from "../constants";
2
+ import type { LlamaSettings } from "../interfaces/settings";
3
+ import { ServerUrl } from "./urls";
4
+
5
+ /**
6
+ * Settings readers injected by {@link LlamaSettingsManager}. Both reload
7
+ * settings from disk before reading, so the resolver always sees fresh data.
8
+ */
9
+ export interface UrlResolverDeps {
10
+ /** Merged `llamaSettings` (project overrides global). */
11
+ getLlamaSettings(): Promise<LlamaSettings>;
12
+ /** Fully merged settings map (used for the legacy `llamaServerUrl` key). */
13
+ getMergedSettings(): Promise<Record<string, any>>;
14
+ }
15
+
16
+ /**
17
+ * Multi-source llama-server URL resolution chain
18
+ *
19
+ * Resolves URLs in priority order:
20
+ *
21
+ * 1. `LLAMA_SERVER_URL` env variable
22
+ * 2. `llamaSettings.servers[].url` (current — project, then global)
23
+ * 3. `llamaServerUrl` key (legacy — project, then global)
24
+ * 4. Default URL
25
+ *
26
+ * Owns the warnings collected for dropped invalid URL entries; the
27
+ * settings manager surfaces them via `takeWarnings()`.
28
+ */
29
+ export class UrlResolver {
30
+ /** Warnings collected during URL resolution (dropped invalid entries). */
31
+ private warnings: string[] = [];
32
+
33
+ constructor(private readonly deps: UrlResolverDeps) {}
34
+
35
+ /**
36
+ * Returns and clears warnings collected during URL resolution.
37
+ */
38
+ takeWarnings(): string[] {
39
+ const warnings = [...this.warnings];
40
+ this.warnings.length = 0;
41
+ return warnings;
42
+ }
43
+
44
+ /**
45
+ * Resolves the server URLs to use in the following order:
46
+ *
47
+ * - `LLAMA_SERVER_URL` env variable
48
+ * - `llamaSettings` key (current - project, then global)
49
+ * - `llamaServerUrl` key (legacy - project, then global)
50
+ * - Default URL
51
+ *
52
+ * @returns The list of URLs to use
53
+ */
54
+ async resolveUrls(): Promise<string[]> {
55
+ let response = this.resolveEnvUrls();
56
+ if (response.length > 0) return response;
57
+
58
+ response = await this.resolveServerUrls();
59
+ if (response.length > 0) return response;
60
+
61
+ response = await this.resolveLegacyUrls();
62
+ if (response.length > 0) return response;
63
+
64
+ return [LLAMA_SERVER_URL];
65
+ }
66
+
67
+ /**
68
+ * Resolves the llama-server URLs from the environment variable.
69
+ *
70
+ * @returns A list of detected URLs
71
+ */
72
+ private resolveEnvUrls(): string[] {
73
+ const raw = process.env.LLAMA_SERVER_URL;
74
+ if (!raw) return [];
75
+
76
+ return this.parseUrls(raw);
77
+ }
78
+
79
+ /**
80
+ * Resolves the llama-server URLs from `llamaSettings.servers`.
81
+ * Settings are merged, prioritizing project over global settings.
82
+ * Reloads settings from disk before reading.
83
+ *
84
+ * @returns A list of detected URLs
85
+ */
86
+ private async resolveServerUrls(): Promise<string[]> {
87
+ const { servers = [] } = await this.deps.getLlamaSettings();
88
+ return servers.map((s) => this.parseUrls(s.url)).flat();
89
+ }
90
+
91
+ /**
92
+ * Resolves the llama-server URLs from `llamaServerUrl` legacy key.
93
+ * Settings are merged, prioritizing project over global settings.
94
+ * Reloads settings from disk before reading.
95
+ *
96
+ * @returns A list of detected URLs
97
+ */
98
+ private async resolveLegacyUrls(): Promise<string[]> {
99
+ const { llamaServerUrl = null } = await this.deps.getMergedSettings();
100
+ if (!llamaServerUrl) return [];
101
+
102
+ return this.parseUrls(llamaServerUrl);
103
+ }
104
+
105
+ /**
106
+ * Parses a raw URL string into an array of cleaned URLs.
107
+ * Splits on semicolons, trims whitespace, filters empty strings, strips
108
+ * trailing slashes, and drops entries without an http(s) scheme —
109
+ * collecting a warning for each dropped entry (same validation the
110
+ * `/models servers` editor applies).
111
+ *
112
+ * @returns A sanitized URL
113
+ */
114
+ private parseUrls(raw: string): string[] {
115
+ return raw
116
+ .split(";")
117
+ .map(ServerUrl.normalize)
118
+ .filter((u) => {
119
+ if (u.length === 0) return false;
120
+ if (!ServerUrl.isValid(u)) {
121
+ this.warnings.push(
122
+ `Ignoring invalid server URL '${u}' (needs http(s)://)`,
123
+ );
124
+ return false;
125
+ }
126
+ return true;
127
+ });
128
+ }
129
+ }
package/src/utils/urls.ts CHANGED
@@ -1,16 +1,36 @@
1
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.
2
+ * Shared server-URL handling: the single place where normalization and
3
+ * validation live, used by the settings parser (`UrlResolver.parseUrls`,
4
+ * see `utils/urlResolver`) and the editor field validator
5
+ * (`ServerFields`) so the two can't drift.
5
6
  */
6
- export const normalizeUrl = (raw: string): string =>
7
- raw.trim().replace(/\/+$/, "");
7
+ export class ServerUrl {
8
+ /** Trims surrounding whitespace and strips trailing slashes — the
9
+ * same treatment the settings parser applies. */
10
+ static normalize(raw: string): string {
11
+ return raw.trim().replace(/\/+$/, "");
12
+ }
8
13
 
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);
14
+ /** True when the URL carries an http(s) scheme — the only schemes
15
+ * llama-server endpoints use. */
16
+ static isValid(url: string): boolean {
17
+ return /^https?:\/\//i.test(url);
18
+ }
19
+
20
+ /**
21
+ * Validates and normalizes a user-entered server URL: `normalize`,
22
+ * then rejects empty strings, semicolons (the settings parser splits
23
+ * on them — use separate entries) and values without an http(s) scheme.
24
+ *
25
+ * @returns The normalized URL, or `null` when the input is invalid
26
+ */
27
+ static parse(raw: string): string | null {
28
+ // Reference the class explicitly (not `this`) so `parse` can be
29
+ // passed around as a bare callback (e.g. as a field validator)
30
+ const url = ServerUrl.normalize(raw);
31
+ if (url.length === 0 || url.includes(";") || !ServerUrl.isValid(url)) {
32
+ return null;
33
+ }
34
+ return url;
35
+ }
36
+ }
@@ -2,17 +2,17 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { initTheme } from "@earendil-works/pi-coding-agent";
3
3
  import type { KeybindingsManager, TUI } from "@earendil-works/pi-tui";
4
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
5
- import { Action } from "../src/enums/action";
5
+ import { Action } from "../../src/enums/action";
6
+ import { CommandManager } from "../../src/managers/command";
7
+ import { ServerManager } from "../../src/managers/server";
8
+ import type { LlamaSettingsManager } from "../../src/managers/settings";
9
+ import { ServerSettingsList } from "../../src/ui/editors/server/serverEditor";
10
+ import { ServerDisplay } from "../../src/ui/editors/server/utils";
6
11
  import {
7
12
  applySettingChange,
8
13
  buildSettingsItems,
9
- CommandManager,
10
14
  formatMs,
11
- } from "../src/managers/command";
12
- import { ServerManager } from "../src/managers/server";
13
- import type { LlamaSettingsManager } from "../src/managers/settings";
14
- import * as serverListEditor from "../src/ui/serverListEditor";
15
- import { ServerSettingsList } from "../src/ui/serverSettingsList";
15
+ } from "../../src/ui/settings";
16
16
  import {
17
17
  createMockCtx,
18
18
  createMockModel,
@@ -20,14 +20,15 @@ import {
20
20
  createMockServer,
21
21
  makeSettingsStub,
22
22
  mockRpc,
23
- } from "./mocks";
23
+ } from "../mocks";
24
24
 
25
25
  beforeEach(() => {
26
26
  initTheme();
27
27
  vi.clearAllMocks();
28
28
  mockRpc.mockResolvedValue({ data: [] });
29
- // Mock health checks to return "healthy" immediately
30
- vi.spyOn(serverListEditor, "getServerHealthEmoji").mockResolvedValue("🟢");
29
+ // Mock health checks and auth probes to return immediately
30
+ vi.spyOn(ServerDisplay, "healthEmoji").mockResolvedValue("🟢");
31
+ vi.spyOn(ServerDisplay, "authEmoji").mockResolvedValue("");
31
32
  });
32
33
 
33
34
  /**
@@ -163,6 +164,7 @@ describe("CommandManager", () => {
163
164
  "sortBy",
164
165
  "pollingTimeout",
165
166
  "serverTimeout",
167
+ "showServerUrls",
166
168
  ]);
167
169
  // Booleans are displayed as on/off
168
170
  expect(items[0].values).toEqual(["on", "off"]);
@@ -203,6 +205,18 @@ describe("CommandManager", () => {
203
205
  "serverTimeout",
204
206
  500,
205
207
  );
208
+
209
+ await applySettingChange("showServerUrls", "on", settingsStub);
210
+ expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
211
+ "showServerUrls",
212
+ true,
213
+ );
214
+
215
+ await applySettingChange("showServerUrls", "off", settingsStub);
216
+ expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
217
+ "showServerUrls",
218
+ false,
219
+ );
206
220
  });
207
221
  });
208
222
 
@@ -254,7 +268,7 @@ describe("CommandManager", () => {
254
268
  bold: (text: string) => text,
255
269
  }) as unknown as Theme;
256
270
 
257
- it("should open the editor without touching servers", async () => {
271
+ it("should re-register providers after the editor closes", async () => {
258
272
  const updateSpy = vi
259
273
  .spyOn(serverManager, "update")
260
274
  .mockResolvedValue(undefined);
@@ -262,8 +276,13 @@ describe("CommandManager", () => {
262
276
 
263
277
  await commandManager.handleCommand("servers", ctx as any, mockPi as any);
264
278
 
265
- expect(updateSpy).not.toHaveBeenCalled();
266
279
  expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
280
+ expect(updateSpy).toHaveBeenCalledTimes(1);
281
+ // Re-registration happens on close, not on open. The mock dialog
282
+ // resolves immediately, so the update call must come after custom.
283
+ expect(updateSpy.mock.invocationCallOrder[0]).toBeGreaterThan(
284
+ vi.mocked(ctx.ui.custom).mock.invocationCallOrder[0],
285
+ );
267
286
  });
268
287
 
269
288
  it("should notify instead of opening the editor outside the TUI", async () => {
@@ -559,5 +578,62 @@ describe("CommandManager", () => {
559
578
  expect(choices).toHaveLength(2);
560
579
  expect(choices[1]).toContain("model-b");
561
580
  });
581
+
582
+ it("should omit server URLs when showServerUrls is false", async () => {
583
+ const models = [createMockModel("model-a")];
584
+ const mockPi = createMockPi();
585
+ const servers = models.map((model) =>
586
+ createMockServer({
587
+ baseUrl: model.serverUrl,
588
+ models: [model],
589
+ }),
590
+ );
591
+ const settingsStub = makeSettingsStub({
592
+ resolveServers: vi.fn(async () => servers),
593
+ resolveShowServerUrls: vi.fn(async () => false),
594
+ });
595
+ const serverManager = new ServerManager(settingsStub);
596
+ const commandManager = new CommandManager(serverManager, settingsStub);
597
+
598
+ const ctx = createMockCtx((prompt, options) => {
599
+ if (prompt.includes("models")) return options[0];
600
+ return Action.INFO;
601
+ });
602
+
603
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
604
+
605
+ const choices = vi.mocked(ctx.ui.select).mock.calls[0][1] as string[];
606
+ expect(choices).toHaveLength(1);
607
+ expect(choices[0]).toBe("model-a ");
608
+ expect(choices[0]).not.toContain("[Server:");
609
+ });
610
+
611
+ it("should include server URLs when showServerUrls is true", async () => {
612
+ const models = [createMockModel("model-a")];
613
+ const mockPi = createMockPi();
614
+ const servers = models.map((model) =>
615
+ createMockServer({
616
+ baseUrl: model.serverUrl,
617
+ models: [model],
618
+ }),
619
+ );
620
+ const settingsStub = makeSettingsStub({
621
+ resolveServers: vi.fn(async () => servers),
622
+ resolveShowServerUrls: vi.fn(async () => true),
623
+ });
624
+ const serverManager = new ServerManager(settingsStub);
625
+ const commandManager = new CommandManager(serverManager, settingsStub);
626
+
627
+ const ctx = createMockCtx((prompt, options) => {
628
+ if (prompt.includes("models")) return options[0];
629
+ return Action.INFO;
630
+ });
631
+
632
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
633
+
634
+ const choices = vi.mocked(ctx.ui.select).mock.calls[0][1] as string[];
635
+ expect(choices).toHaveLength(1);
636
+ expect(choices[0]).toContain("[Server: http://127.0.0.1:8080]");
637
+ });
562
638
  });
563
639
  });