pi-llama-cpp 0.13.0 → 0.14.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 +7 -8
- package/package.json +2 -2
- package/src/api/client.ts +25 -0
- package/src/constants.ts +0 -5
- package/src/enums/status.ts +0 -1
- package/src/interfaces/endpoints/models.ts +1 -1
- package/src/interfaces/settings.ts +1 -1
- package/src/interfaces/sortBy.ts +4 -0
- package/src/managers/command/models.ts +247 -0
- package/src/managers/command.ts +29 -461
- package/src/managers/events.ts +2 -2
- package/src/managers/server.ts +27 -3
- package/src/managers/settings.ts +12 -88
- package/src/models/baseModel.ts +27 -10
- package/src/models/legacyModel.ts +2 -2
- package/src/models/routerModel.ts +2 -2
- package/src/models/singleModel.ts +1 -1
- package/src/server.ts +11 -29
- package/src/sse/client.ts +113 -59
- package/src/sse/fetch.ts +43 -0
- package/src/sse/manager.ts +9 -27
- package/src/sse/types.ts +0 -4
- package/src/ui/dialog/base.ts +118 -0
- package/src/ui/dialog/confirm.ts +45 -0
- package/src/ui/dialog/factory.ts +111 -0
- package/src/ui/dialog/input.ts +63 -0
- package/src/ui/dialog/options.ts +23 -0
- package/src/ui/editors/editorOptions.ts +43 -0
- package/src/ui/editors/itemBuilder.ts +47 -0
- package/src/ui/editors/listEditor.ts +291 -0
- package/src/ui/editors/override/entry.ts +24 -0
- package/src/ui/editors/override/entryEditor.ts +166 -0
- package/src/ui/editors/override/fields.ts +294 -0
- package/src/ui/editors/override/handlers.ts +118 -0
- package/src/ui/editors/override/itemBuilder.ts +42 -0
- package/src/ui/editors/override/overrideList.ts +127 -0
- package/src/ui/editors/server/builder.ts +60 -0
- package/src/ui/editors/server/fields.ts +84 -0
- package/src/ui/editors/server/handlers.ts +32 -0
- package/src/ui/editors/server/itemBuilder.ts +68 -0
- package/src/ui/editors/server/serverEditor.ts +161 -0
- package/src/ui/editors/server/utils.ts +45 -0
- package/src/ui/editors/server/wizard.ts +110 -0
- package/src/ui/editors/settingField.ts +37 -0
- package/src/ui/editors/settingsListFactory.ts +33 -0
- package/src/ui/settings/index.ts +237 -0
- package/src/ui/strings.ts +8 -4
- package/src/utils/serverIds.ts +21 -0
- package/src/utils/settingsStore.ts +1 -1
- package/src/utils/urlResolver.ts +129 -0
- package/src/utils/urls.ts +33 -13
- package/tests/commandManager.test.ts +14 -9
- package/tests/dialog.test.ts +55 -4
- package/tests/legacyModel.test.ts +34 -28
- package/tests/overrides.test.ts +111 -88
- package/tests/routerModel.test.ts +67 -68
- package/tests/server.test.ts +0 -12
- package/tests/settings.test.ts +10 -8
- package/tests/singleModel.test.ts +12 -12
- package/tests/sseManager.test.ts +6 -24
- package/src/ui/dialog.ts +0 -290
- package/src/ui/overrideEntryEditor.ts +0 -119
- package/src/ui/overrideSettingsList.ts +0 -710
- package/src/ui/serverListEditor.ts +0 -59
- package/src/ui/serverSettingsList.ts +0 -513
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
type SortByValue = NonNullable<LlamaSettings["sortBy"]>;
|
|
34
|
+
|
|
35
|
+
export const SORT_VALUES: SortByValue[] = [
|
|
36
|
+
"asc",
|
|
37
|
+
"desc",
|
|
38
|
+
"asc-name",
|
|
39
|
+
"desc-name",
|
|
40
|
+
"api",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Presets (ms) for `pollingTimeout` */
|
|
44
|
+
export const POLLING_PRESETS = [15000, 30000, 60000, 120000, 300000];
|
|
45
|
+
|
|
46
|
+
/** Presets (ms) for `serverTimeout` */
|
|
47
|
+
export const SERVER_PRESETS = [500, 1000, 2000, 5000, 10000];
|
|
48
|
+
|
|
49
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Formats milliseconds compactly for display (e.g. `500 -> "500ms"`,
|
|
53
|
+
* `60000 -> "60s"`).
|
|
54
|
+
*/
|
|
55
|
+
export const formatMs = (ms: number): string =>
|
|
56
|
+
ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parses a value produced by `formatMs()` back to milliseconds.
|
|
60
|
+
* Only ever called with values from the preset lists.
|
|
61
|
+
*/
|
|
62
|
+
const parseMs = (value: string): number =>
|
|
63
|
+
value.endsWith("ms")
|
|
64
|
+
? Number(value.slice(0, -2))
|
|
65
|
+
: Number(value.slice(0, -1)) * 1000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds the `SettingItem[]` for `/models settings` from the current
|
|
69
|
+
* (merged) values of the scalar `llamaSettings` fields.
|
|
70
|
+
*/
|
|
71
|
+
export const buildSettingsItems = async (
|
|
72
|
+
settings: LlamaSettingsManager,
|
|
73
|
+
): Promise<SettingItem[]> => {
|
|
74
|
+
const { pollingTimeout, serverTimeout } = await settings.resolveTimeouts();
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
id: Options.REACT_TO_MODEL_SELECT,
|
|
79
|
+
label: "React to model selection",
|
|
80
|
+
description: "Load the model when you pick it in Pi (immediate)",
|
|
81
|
+
currentValue: (await settings.resolveReactToModelSelect()) ? "on" : "off",
|
|
82
|
+
values: ["on", "off"],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: Options.AUTOLOAD_ON_MESSAGE,
|
|
86
|
+
label: "Autoload on message",
|
|
87
|
+
description:
|
|
88
|
+
"Auto-load the selected model when you send a message (immediate)",
|
|
89
|
+
currentValue: (await settings.resolveAutoloadOnMessage()) ? "on" : "off",
|
|
90
|
+
values: ["on", "off"],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: Options.SORT_BY,
|
|
94
|
+
label: "Sort models by",
|
|
95
|
+
description: "Order of models in /models (next open)",
|
|
96
|
+
currentValue: await settings.resolveSortBy(),
|
|
97
|
+
values: [...SORT_VALUES],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: Options.POLLING_TIMEOUT,
|
|
101
|
+
label: "Polling timeout",
|
|
102
|
+
description: "Max model-load wait (next model load)",
|
|
103
|
+
currentValue: formatMs(pollingTimeout),
|
|
104
|
+
values: POLLING_PRESETS.map(formatMs),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: Options.SERVER_TIMEOUT,
|
|
108
|
+
label: "Server timeout",
|
|
109
|
+
description: "Health check / SSE probe timeout (next model load)",
|
|
110
|
+
currentValue: formatMs(serverTimeout),
|
|
111
|
+
values: SERVER_PRESETS.map(formatMs),
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Persists a change made in the settings menu.
|
|
118
|
+
* Maps the `SettingsList` id/value pair to the matching `llamaSettings`
|
|
119
|
+
* key and writes it via `LlamaSettingsManager.setLlamaSetting()`.
|
|
120
|
+
*/
|
|
121
|
+
export const applySettingChange = async (
|
|
122
|
+
id: string,
|
|
123
|
+
newValue: string,
|
|
124
|
+
settings: LlamaSettingsManager,
|
|
125
|
+
): Promise<void> => {
|
|
126
|
+
switch (id) {
|
|
127
|
+
case Options.REACT_TO_MODEL_SELECT:
|
|
128
|
+
await settings.setLlamaSetting("reactToModelSelect", newValue === "on");
|
|
129
|
+
return;
|
|
130
|
+
case Options.AUTOLOAD_ON_MESSAGE:
|
|
131
|
+
await settings.setLlamaSetting("autoloadOnMessage", newValue === "on");
|
|
132
|
+
return;
|
|
133
|
+
case Options.SORT_BY:
|
|
134
|
+
await settings.setLlamaSetting("sortBy", newValue as SortByValue);
|
|
135
|
+
return;
|
|
136
|
+
case Options.POLLING_TIMEOUT:
|
|
137
|
+
await settings.setLlamaSetting("pollingTimeout", parseMs(newValue));
|
|
138
|
+
return;
|
|
139
|
+
case Options.SERVER_TIMEOUT:
|
|
140
|
+
await settings.setLlamaSetting("serverTimeout", parseMs(newValue));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// ─── SettingsEditor ─────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/** Options passed to the settings editor. */
|
|
148
|
+
export interface SettingsEditorOptions {
|
|
149
|
+
tui: TUI;
|
|
150
|
+
theme: Theme;
|
|
151
|
+
keybindings: KeybindingsManager;
|
|
152
|
+
settings: LlamaSettingsManager;
|
|
153
|
+
/** Called when Esc closes the editor. */
|
|
154
|
+
done: () => void;
|
|
155
|
+
/** Called on persistence errors. */
|
|
156
|
+
onError: (message: string) => void;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Interactive settings menu for the scalar `llamaSettings` fields.
|
|
161
|
+
* Enter/Space cycles the value under the cursor; Esc closes.
|
|
162
|
+
*
|
|
163
|
+
* Writes go to the global/project `~/.pi/agent/settings.json` via
|
|
164
|
+
* `LlamaSettingsManager.setLlamaSetting()`; write errors are notified
|
|
165
|
+
* and leave the dialog open with values unchanged.
|
|
166
|
+
*/
|
|
167
|
+
export class SettingsEditor implements Component, Focusable {
|
|
168
|
+
private settingsList: SettingsList | null = null;
|
|
169
|
+
private isFocused = false;
|
|
170
|
+
|
|
171
|
+
constructor(private readonly options: SettingsEditorOptions) {
|
|
172
|
+
void this.buildList();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Opens the editor in a modal `ui.custom` dialog and resolves when the
|
|
177
|
+
* user closes it (Esc). Write errors are notified via `ui` and leave
|
|
178
|
+
* the dialog open with values unchanged.
|
|
179
|
+
*/
|
|
180
|
+
static async show(
|
|
181
|
+
ui: ExtensionUIContext,
|
|
182
|
+
settings: LlamaSettingsManager,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
await ui.custom<void>(
|
|
185
|
+
(tui, theme, keybindings, done) =>
|
|
186
|
+
new SettingsEditor({
|
|
187
|
+
tui,
|
|
188
|
+
theme,
|
|
189
|
+
keybindings,
|
|
190
|
+
settings,
|
|
191
|
+
done: () => done(undefined),
|
|
192
|
+
onError: (message) => ui.notify(message, "error"),
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// -- Component -------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
invalidate(): void {
|
|
200
|
+
this.settingsList?.invalidate();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
handleInput(data: string): void {
|
|
204
|
+
this.settingsList?.handleInput(data);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
render(width: number): string[] {
|
|
208
|
+
if (!this.settingsList) return ["Loading..."];
|
|
209
|
+
return this.settingsList.render(width);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// -- Focusable -------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
get focused(): boolean {
|
|
215
|
+
return this.isFocused;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
set focused(value: boolean) {
|
|
219
|
+
this.isFocused = value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// -- internal --------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
private async buildList(): Promise<void> {
|
|
225
|
+
const items = await buildSettingsItems(this.options.settings);
|
|
226
|
+
this.settingsList = new SettingsList(
|
|
227
|
+
items,
|
|
228
|
+
Math.min(items.length + 2, 15),
|
|
229
|
+
getSettingsListTheme(),
|
|
230
|
+
(id, newValue) =>
|
|
231
|
+
applySettingChange(id, newValue, this.options.settings).catch(
|
|
232
|
+
(err: unknown) => this.options.onError(String(err)),
|
|
233
|
+
),
|
|
234
|
+
() => this.options.done(),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/ui/strings.ts
CHANGED
|
@@ -94,10 +94,12 @@ export const FIELDS = {
|
|
|
94
94
|
} as const satisfies Record<string, Field>;
|
|
95
95
|
|
|
96
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
|
-
|
|
100
|
-
|
|
97
|
+
* (or an overriding note, e.g. the add-server wizard's "(optional)"). */
|
|
98
|
+
export class FieldMessages {
|
|
99
|
+
static of(field: Field, note = field.note): string {
|
|
100
|
+
return note ? `${field.label} ${note}` : field.label;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
101
103
|
|
|
102
104
|
/**
|
|
103
105
|
* Dialog titles. `edit` composes the shared `Edit <term>` title so the
|
|
@@ -124,4 +126,6 @@ export const HINTS = {
|
|
|
124
126
|
emptyServers: " (a) add server · Esc done",
|
|
125
127
|
/** Hint line when the override entry list is empty */
|
|
126
128
|
emptyOverrideEntries: " (a) add override · Esc back",
|
|
129
|
+
/** Row description for a server in the overrides editor */
|
|
130
|
+
overrideServerRow: "Enter: edit this server's override entries",
|
|
127
131
|
} as const;
|
|
@@ -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 (see REFACTOR.md #4).
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
}
|
|
@@ -3,16 +3,16 @@ 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
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/
|
|
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,
|
|
@@ -27,7 +27,7 @@ beforeEach(() => {
|
|
|
27
27
|
vi.clearAllMocks();
|
|
28
28
|
mockRpc.mockResolvedValue({ data: [] });
|
|
29
29
|
// Mock health checks to return "healthy" immediately
|
|
30
|
-
vi.spyOn(
|
|
30
|
+
vi.spyOn(ServerDisplay, "healthEmoji").mockResolvedValue("🟢");
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -254,7 +254,7 @@ describe("CommandManager", () => {
|
|
|
254
254
|
bold: (text: string) => text,
|
|
255
255
|
}) as unknown as Theme;
|
|
256
256
|
|
|
257
|
-
it("should
|
|
257
|
+
it("should re-register providers after the editor closes", async () => {
|
|
258
258
|
const updateSpy = vi
|
|
259
259
|
.spyOn(serverManager, "update")
|
|
260
260
|
.mockResolvedValue(undefined);
|
|
@@ -262,8 +262,13 @@ describe("CommandManager", () => {
|
|
|
262
262
|
|
|
263
263
|
await commandManager.handleCommand("servers", ctx as any, mockPi as any);
|
|
264
264
|
|
|
265
|
-
expect(updateSpy).not.toHaveBeenCalled();
|
|
266
265
|
expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
|
|
266
|
+
expect(updateSpy).toHaveBeenCalledTimes(1);
|
|
267
|
+
// Re-registration happens on close, not on open. The mock dialog
|
|
268
|
+
// resolves immediately, so the update call must come after custom.
|
|
269
|
+
expect(updateSpy.mock.invocationCallOrder[0]).toBeGreaterThan(
|
|
270
|
+
vi.mocked(ctx.ui.custom).mock.invocationCallOrder[0],
|
|
271
|
+
);
|
|
267
272
|
});
|
|
268
273
|
|
|
269
274
|
it("should notify instead of opening the editor outside the TUI", async () => {
|
package/tests/dialog.test.ts
CHANGED
|
@@ -5,9 +5,10 @@ import {
|
|
|
5
5
|
type TUI,
|
|
6
6
|
} from "@earendil-works/pi-tui";
|
|
7
7
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
-
import { ConfirmDialog
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { ConfirmDialog } from "../src/ui/dialog/confirm";
|
|
9
|
+
import { InputDialog } from "../src/ui/dialog/input";
|
|
10
|
+
import { OverrideSettingsList } from "../src/ui/editors/override/overrideList";
|
|
11
|
+
import { ServerSettingsList } from "../src/ui/editors/server/serverEditor";
|
|
11
12
|
|
|
12
13
|
beforeEach(() => {
|
|
13
14
|
initTheme();
|
|
@@ -191,7 +192,7 @@ describe("OverrideEntryListEditor cost re-prefill", () => {
|
|
|
191
192
|
|
|
192
193
|
it("re-entering a cost field prefills the just-saved value", async () => {
|
|
193
194
|
const persist = vi.fn().mockResolvedValue(undefined);
|
|
194
|
-
const list =
|
|
195
|
+
const list = new OverrideSettingsList({
|
|
195
196
|
tui: createMockTui(),
|
|
196
197
|
theme: createMockTheme(),
|
|
197
198
|
keybindings: createKeybindings(),
|
|
@@ -226,3 +227,53 @@ describe("OverrideEntryListEditor cost re-prefill", () => {
|
|
|
226
227
|
expect(rendered).not.toContain("0.2");
|
|
227
228
|
});
|
|
228
229
|
});
|
|
230
|
+
|
|
231
|
+
describe("OverrideSettingsList escape navigation", () => {
|
|
232
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
233
|
+
|
|
234
|
+
const build = () => {
|
|
235
|
+
const done = vi.fn();
|
|
236
|
+
const list = new OverrideSettingsList({
|
|
237
|
+
tui: createMockTui(),
|
|
238
|
+
theme: createMockTheme(),
|
|
239
|
+
keybindings: createKeybindings(),
|
|
240
|
+
servers: [
|
|
241
|
+
{
|
|
242
|
+
url: "http://x:1",
|
|
243
|
+
overrides: { "gpt-*": { cost: { input: 0.2 } } },
|
|
244
|
+
},
|
|
245
|
+
] as never[],
|
|
246
|
+
persist: vi.fn().mockResolvedValue(undefined),
|
|
247
|
+
done,
|
|
248
|
+
onError: vi.fn(),
|
|
249
|
+
onChanged: vi.fn(),
|
|
250
|
+
});
|
|
251
|
+
return { list, done };
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
it("Esc from the field submenu then the entry list lands on the server list", async () => {
|
|
255
|
+
const { list, done } = build();
|
|
256
|
+
|
|
257
|
+
list.handleInput(ENTER); // drill into the server row (entry list)
|
|
258
|
+
list.handleInput(ENTER); // open the field submenu
|
|
259
|
+
expect(list.render(80).join("\n")).not.toContain("http://x:1");
|
|
260
|
+
|
|
261
|
+
list.handleInput(ESC); // field submenu → entry list
|
|
262
|
+
await flush();
|
|
263
|
+
expect(done).not.toHaveBeenCalled();
|
|
264
|
+
expect(list.render(80).join("\n")).not.toContain("http://x:1");
|
|
265
|
+
|
|
266
|
+
list.handleInput(ESC); // entry list → server list
|
|
267
|
+
await flush();
|
|
268
|
+
// The dialog must stay open, showing the server row again
|
|
269
|
+
expect(done).not.toHaveBeenCalled();
|
|
270
|
+
expect(list.render(80).join("\n")).toContain("http://x:1");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("Esc from the server list closes the dialog", async () => {
|
|
274
|
+
const { list, done } = build();
|
|
275
|
+
|
|
276
|
+
list.handleInput(ESC);
|
|
277
|
+
expect(done).toHaveBeenCalledTimes(1);
|
|
278
|
+
});
|
|
279
|
+
});
|