pi-llama-cpp 0.10.0 → 0.12.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 +187 -10
- package/package.json +6 -6
- package/src/api/client.ts +76 -32
- package/src/constants.ts +15 -0
- package/src/index.ts +3 -5
- package/src/interfaces/events.ts +14 -3
- package/src/interfaces/server.ts +27 -0
- package/src/interfaces/settings.ts +76 -2
- package/src/managers/command.ts +369 -50
- package/src/managers/events.ts +28 -10
- package/src/managers/server.ts +90 -16
- package/src/managers/settings.ts +181 -44
- package/src/models/baseModel.ts +37 -15
- package/src/models/routerModel.ts +2 -1
- package/src/server.ts +103 -28
- package/src/sse/client.ts +28 -16
- package/src/sse/manager.ts +26 -13
- package/src/ui/dialog.ts +287 -0
- package/src/ui/overrideEntryEditor.ts +119 -0
- package/src/ui/overrideSettingsList.ts +682 -0
- package/src/ui/serverListEditor.ts +32 -0
- package/src/ui/serverSettingsList.ts +466 -0
- package/src/ui/strings.ts +127 -0
- package/src/utils/errors.ts +5 -0
- package/src/utils/settingsStore.ts +56 -0
- package/src/utils/urls.ts +16 -0
- package/tests/commandManager.test.ts +346 -11
- package/tests/dialog.test.ts +186 -0
- package/tests/events.test.ts +120 -88
- package/tests/legacyModel.test.ts +4 -19
- package/tests/mocks.ts +149 -32
- package/tests/overrides.test.ts +352 -0
- package/tests/server.test.ts +42 -40
- package/tests/serverManager.test.ts +264 -55
- package/tests/settings.test.ts +654 -51
- package/tests/settingsStore.test.ts +190 -0
- package/tests/singleModel.test.ts +32 -0
- package/tests/sseManager.test.ts +88 -11
- package/src/interfaces/auth.ts +0 -6
- package/src/utils/cache.ts +0 -39
- package/src/utils/mutex.ts +0 -24
|
@@ -1,16 +1,28 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { initTheme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { KeybindingsManager, TUI } from "@earendil-works/pi-tui";
|
|
1
4
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
5
|
import { Action } from "../src/enums/action";
|
|
3
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
applySettingChange,
|
|
8
|
+
buildSettingsItems,
|
|
9
|
+
CommandManager,
|
|
10
|
+
formatMs,
|
|
11
|
+
} from "../src/managers/command";
|
|
4
12
|
import { ServerManager } from "../src/managers/server";
|
|
13
|
+
import type { LlamaSettingsManager } from "../src/managers/settings";
|
|
14
|
+
import { ServerSettingsList } from "../src/ui/serverSettingsList";
|
|
5
15
|
import {
|
|
6
16
|
createMockCtx,
|
|
7
17
|
createMockModel,
|
|
8
18
|
createMockPi,
|
|
9
19
|
createMockServer,
|
|
20
|
+
makeSettingsStub,
|
|
10
21
|
mockRpc,
|
|
11
22
|
} from "./mocks";
|
|
12
23
|
|
|
13
24
|
beforeEach(() => {
|
|
25
|
+
initTheme();
|
|
14
26
|
vi.clearAllMocks();
|
|
15
27
|
mockRpc.mockResolvedValue({ data: [] });
|
|
16
28
|
});
|
|
@@ -18,19 +30,27 @@ beforeEach(() => {
|
|
|
18
30
|
describe("CommandManager", () => {
|
|
19
31
|
let serverManager: ServerManager;
|
|
20
32
|
let commandManager: CommandManager;
|
|
33
|
+
let settingsStub: LlamaSettingsManager;
|
|
21
34
|
let mockPi: ReturnType<typeof createMockPi>;
|
|
22
35
|
|
|
23
36
|
beforeEach(() => {
|
|
24
37
|
mockPi = createMockPi();
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
settingsStub = makeSettingsStub();
|
|
39
|
+
serverManager = new ServerManager(settingsStub);
|
|
40
|
+
commandManager = new CommandManager(serverManager, settingsStub);
|
|
27
41
|
});
|
|
28
42
|
|
|
29
43
|
describe("getArgumentCompletions", () => {
|
|
30
44
|
it("should provide completions for /models", () => {
|
|
31
45
|
const completions = commandManager.getArgumentCompletions("");
|
|
32
|
-
expect(completions).toHaveLength(
|
|
33
|
-
expect(completions?.map((c) => c.value)).toEqual([
|
|
46
|
+
expect(completions).toHaveLength(5);
|
|
47
|
+
expect(completions?.map((c) => c.value)).toEqual([
|
|
48
|
+
"info",
|
|
49
|
+
"unload",
|
|
50
|
+
"settings",
|
|
51
|
+
"servers",
|
|
52
|
+
"overrides",
|
|
53
|
+
]);
|
|
34
54
|
});
|
|
35
55
|
|
|
36
56
|
it("should filter completions by prefix", () => {
|
|
@@ -43,6 +63,11 @@ describe("CommandManager", () => {
|
|
|
43
63
|
const completions = commandManager.getArgumentCompletions("zzz");
|
|
44
64
|
expect(completions).toBeNull();
|
|
45
65
|
});
|
|
66
|
+
|
|
67
|
+
it("should provide the server/settings completions by prefix", () => {
|
|
68
|
+
const completions = commandManager.getArgumentCompletions("s");
|
|
69
|
+
expect(completions?.map((c) => c.value)).toEqual(["settings", "servers"]);
|
|
70
|
+
});
|
|
46
71
|
});
|
|
47
72
|
|
|
48
73
|
describe("handleCommand", () => {
|
|
@@ -53,8 +78,11 @@ describe("CommandManager", () => {
|
|
|
53
78
|
baseUrl: "http://127.0.0.1:8080",
|
|
54
79
|
models: [model1, model2],
|
|
55
80
|
});
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
const unloadSettings = makeSettingsStub({
|
|
82
|
+
resolveServers: vi.fn(async () => [server]),
|
|
83
|
+
});
|
|
84
|
+
serverManager = new ServerManager(unloadSettings);
|
|
85
|
+
commandManager = new CommandManager(serverManager, unloadSettings);
|
|
58
86
|
|
|
59
87
|
const ctx = {
|
|
60
88
|
ui: {
|
|
@@ -80,8 +108,11 @@ describe("CommandManager", () => {
|
|
|
80
108
|
baseUrl: "http://127.0.0.1:8080",
|
|
81
109
|
models: [model1, model2],
|
|
82
110
|
});
|
|
83
|
-
|
|
84
|
-
|
|
111
|
+
const infoSettings = makeSettingsStub({
|
|
112
|
+
resolveServers: vi.fn(async () => [server]),
|
|
113
|
+
});
|
|
114
|
+
serverManager = new ServerManager(infoSettings);
|
|
115
|
+
commandManager = new CommandManager(serverManager, infoSettings);
|
|
85
116
|
|
|
86
117
|
const ctx = {
|
|
87
118
|
ui: {
|
|
@@ -97,6 +128,272 @@ describe("CommandManager", () => {
|
|
|
97
128
|
});
|
|
98
129
|
});
|
|
99
130
|
|
|
131
|
+
describe("settings menu helpers", () => {
|
|
132
|
+
it("should format milliseconds compactly", () => {
|
|
133
|
+
expect(formatMs(500)).toBe("500ms");
|
|
134
|
+
expect(formatMs(60000)).toBe("60s");
|
|
135
|
+
expect(formatMs(1500)).toBe("1500ms");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should build one item per editable scalar field", async () => {
|
|
139
|
+
const items = await buildSettingsItems(settingsStub);
|
|
140
|
+
expect(items.map((i) => i.id)).toEqual([
|
|
141
|
+
"reactToModelSelect",
|
|
142
|
+
"autoloadOnMessage",
|
|
143
|
+
"sortBy",
|
|
144
|
+
"pollingTimeout",
|
|
145
|
+
"serverTimeout",
|
|
146
|
+
]);
|
|
147
|
+
// Booleans are displayed as on/off
|
|
148
|
+
expect(items[0].values).toEqual(["on", "off"]);
|
|
149
|
+
// Timeouts cycle through formatted presets
|
|
150
|
+
expect(items[3].values).toEqual(["15s", "30s", "60s", "120s", "300s"]);
|
|
151
|
+
expect(items[4].values).toEqual(["500ms", "1s", "2s", "5s", "10s"]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should map changes to the right key and type", async () => {
|
|
155
|
+
const settingsStub = makeSettingsStub();
|
|
156
|
+
|
|
157
|
+
await applySettingChange("reactToModelSelect", "on", settingsStub);
|
|
158
|
+
expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
|
|
159
|
+
"reactToModelSelect",
|
|
160
|
+
true,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
await applySettingChange("autoloadOnMessage", "off", settingsStub);
|
|
164
|
+
expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
|
|
165
|
+
"autoloadOnMessage",
|
|
166
|
+
false,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
await applySettingChange("sortBy", "desc-name", settingsStub);
|
|
170
|
+
expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
|
|
171
|
+
"sortBy",
|
|
172
|
+
"desc-name",
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
await applySettingChange("pollingTimeout", "120s", settingsStub);
|
|
176
|
+
expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
|
|
177
|
+
"pollingTimeout",
|
|
178
|
+
120000,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
await applySettingChange("serverTimeout", "500ms", settingsStub);
|
|
182
|
+
expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
|
|
183
|
+
"serverTimeout",
|
|
184
|
+
500,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("/models settings menu", () => {
|
|
190
|
+
it("should open the settings menu without touching servers", async () => {
|
|
191
|
+
const updateSpy = vi
|
|
192
|
+
.spyOn(serverManager, "update")
|
|
193
|
+
.mockResolvedValue(undefined);
|
|
194
|
+
const ctx = createMockCtx(() => null);
|
|
195
|
+
|
|
196
|
+
await commandManager.handleCommand("settings", ctx as any, mockPi as any);
|
|
197
|
+
|
|
198
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
199
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
200
|
+
expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("should notify instead of opening the menu outside the TUI", async () => {
|
|
204
|
+
const ctx = { ...createMockCtx(() => null), mode: "rpc" } as any;
|
|
205
|
+
|
|
206
|
+
await commandManager.handleCommand("settings", ctx, mockPi as any);
|
|
207
|
+
|
|
208
|
+
expect(ctx.ui.custom).not.toHaveBeenCalled();
|
|
209
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
210
|
+
"/models settings requires an interactive session (TUI)",
|
|
211
|
+
"warning",
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("/models servers editor", () => {
|
|
217
|
+
const ESC = "\x1b";
|
|
218
|
+
const ENTER = "\r";
|
|
219
|
+
|
|
220
|
+
const createMockKeybindings = (): KeybindingsManager =>
|
|
221
|
+
({
|
|
222
|
+
matches: vi.fn(
|
|
223
|
+
(data: string, name: string) =>
|
|
224
|
+
(data === ENTER && name === "tui.select.confirm") ||
|
|
225
|
+
(data === ESC && name === "tui.select.cancel") ||
|
|
226
|
+
(data === "\x1b[A" && name === "tui.select.up") ||
|
|
227
|
+
(data === "\x1b[B" && name === "tui.select.down"),
|
|
228
|
+
),
|
|
229
|
+
}) as unknown as KeybindingsManager;
|
|
230
|
+
|
|
231
|
+
const createMockTheme = (): Theme =>
|
|
232
|
+
({
|
|
233
|
+
fg: (_color: string, text: string) => text,
|
|
234
|
+
bold: (text: string) => text,
|
|
235
|
+
}) as unknown as Theme;
|
|
236
|
+
|
|
237
|
+
it("should open the editor without touching servers", async () => {
|
|
238
|
+
const updateSpy = vi
|
|
239
|
+
.spyOn(serverManager, "update")
|
|
240
|
+
.mockResolvedValue(undefined);
|
|
241
|
+
const ctx = createMockCtx(() => null);
|
|
242
|
+
|
|
243
|
+
await commandManager.handleCommand("servers", ctx as any, mockPi as any);
|
|
244
|
+
|
|
245
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
246
|
+
expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("should notify instead of opening the editor outside the TUI", async () => {
|
|
250
|
+
const ctx = { ...createMockCtx(() => null), mode: "rpc" } as any;
|
|
251
|
+
|
|
252
|
+
await commandManager.handleCommand("servers", ctx, mockPi as any);
|
|
253
|
+
|
|
254
|
+
expect(ctx.ui.custom).not.toHaveBeenCalled();
|
|
255
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
256
|
+
"/models servers requires an interactive session (TUI)",
|
|
257
|
+
"warning",
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should wire the editor to the merged servers and the write path", async () => {
|
|
262
|
+
const editorSettings = makeSettingsStub({
|
|
263
|
+
getLlamaServers: vi.fn(async () => [{ url: "http://seed:1" }]),
|
|
264
|
+
});
|
|
265
|
+
commandManager = new CommandManager(serverManager, editorSettings);
|
|
266
|
+
const ctx = createMockCtx(() => null);
|
|
267
|
+
|
|
268
|
+
await commandManager.handleCommand("servers", ctx as any, mockPi as any);
|
|
269
|
+
|
|
270
|
+
// Invoke the factory captured from ctx.ui.custom
|
|
271
|
+
const factory = vi.mocked(ctx.ui.custom).mock.calls[0][0] as (
|
|
272
|
+
tui: TUI,
|
|
273
|
+
theme: Theme,
|
|
274
|
+
kb: KeybindingsManager,
|
|
275
|
+
done: (result: undefined) => void,
|
|
276
|
+
) => ServerSettingsList;
|
|
277
|
+
const done = vi.fn();
|
|
278
|
+
const editor = factory(
|
|
279
|
+
{ requestRender: vi.fn() } as unknown as TUI,
|
|
280
|
+
createMockTheme(),
|
|
281
|
+
{
|
|
282
|
+
matches: vi.fn(
|
|
283
|
+
(data: string, name: string) =>
|
|
284
|
+
(data === ENTER && name === "tui.select.confirm") ||
|
|
285
|
+
(data === ESC && name === "tui.select.cancel"),
|
|
286
|
+
),
|
|
287
|
+
} as unknown as KeybindingsManager,
|
|
288
|
+
done,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
expect(editor).toBeInstanceOf(ServerSettingsList);
|
|
292
|
+
// Seeded with the merged snapshot
|
|
293
|
+
expect(editor.render(80).join("\n")).toContain("http://seed:1");
|
|
294
|
+
|
|
295
|
+
// Add a server through the wizard (URL → ID → name) →
|
|
296
|
+
// setLlamaSetting("servers", …) persists only after the final step
|
|
297
|
+
editor.handleInput("a");
|
|
298
|
+
expect(editor.render(80).join("\n")).toContain("Add server · 1/3");
|
|
299
|
+
for (const ch of "http://new:2") editor.handleInput(ch);
|
|
300
|
+
editor.handleInput(ENTER);
|
|
301
|
+
expect(editor.render(80).join("\n")).toContain("Add server · 2/3");
|
|
302
|
+
editor.handleInput(ENTER); // skip optional ID
|
|
303
|
+
expect(editor.render(80).join("\n")).toContain("Add server · 3/3");
|
|
304
|
+
editor.handleInput(ENTER); // skip optional name → persist
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
306
|
+
expect(editorSettings.setLlamaSetting).toHaveBeenCalledWith("servers", [
|
|
307
|
+
{ url: "http://seed:1" },
|
|
308
|
+
{ url: "http://new:2" },
|
|
309
|
+
]);
|
|
310
|
+
|
|
311
|
+
// Esc closes the editor
|
|
312
|
+
editor.handleInput(ESC);
|
|
313
|
+
expect(done).toHaveBeenCalledTimes(1);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("should persist nothing when the add wizard is cancelled", async () => {
|
|
317
|
+
const editorSettings = makeSettingsStub({
|
|
318
|
+
getLlamaServers: vi.fn(async () => [{ url: "http://seed:1" }]),
|
|
319
|
+
});
|
|
320
|
+
commandManager = new CommandManager(serverManager, editorSettings);
|
|
321
|
+
const ctx = createMockCtx(() => null);
|
|
322
|
+
|
|
323
|
+
await commandManager.handleCommand("servers", ctx as any, mockPi as any);
|
|
324
|
+
|
|
325
|
+
const factory = vi.mocked(ctx.ui.custom).mock.calls[0][0] as (
|
|
326
|
+
tui: TUI,
|
|
327
|
+
theme: Theme,
|
|
328
|
+
kb: KeybindingsManager,
|
|
329
|
+
done: (result: undefined) => void,
|
|
330
|
+
) => ServerSettingsList;
|
|
331
|
+
const editor = factory(
|
|
332
|
+
{ requestRender: vi.fn() } as unknown as TUI,
|
|
333
|
+
createMockTheme(),
|
|
334
|
+
createMockKeybindings(),
|
|
335
|
+
vi.fn(),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// Walk to wizard step 2, then abort — nothing must be persisted
|
|
339
|
+
editor.handleInput("a");
|
|
340
|
+
for (const ch of "http://new:2") editor.handleInput(ch);
|
|
341
|
+
editor.handleInput(ENTER);
|
|
342
|
+
editor.handleInput(ESC);
|
|
343
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
344
|
+
|
|
345
|
+
expect(editorSettings.setLlamaSetting).not.toHaveBeenCalled();
|
|
346
|
+
// Back to the list view
|
|
347
|
+
expect(editor.render(80).join("\n")).toContain("http://seed:1");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("should delete the selected server after confirming", async () => {
|
|
351
|
+
const editorSettings = makeSettingsStub({
|
|
352
|
+
getLlamaServers: vi.fn(async () => [
|
|
353
|
+
{ url: "http://seed:1" },
|
|
354
|
+
{ url: "http://doomed:2" },
|
|
355
|
+
]),
|
|
356
|
+
});
|
|
357
|
+
commandManager = new CommandManager(serverManager, editorSettings);
|
|
358
|
+
const ctx = createMockCtx(() => null);
|
|
359
|
+
|
|
360
|
+
await commandManager.handleCommand("servers", ctx as any, mockPi as any);
|
|
361
|
+
|
|
362
|
+
const factory = vi.mocked(ctx.ui.custom).mock.calls[0][0] as (
|
|
363
|
+
tui: TUI,
|
|
364
|
+
theme: Theme,
|
|
365
|
+
kb: KeybindingsManager,
|
|
366
|
+
done: (result: undefined) => void,
|
|
367
|
+
) => ServerSettingsList;
|
|
368
|
+
const editor = factory(
|
|
369
|
+
{ requestRender: vi.fn() } as unknown as TUI,
|
|
370
|
+
createMockTheme(),
|
|
371
|
+
createMockKeybindings(),
|
|
372
|
+
vi.fn(),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// Move to the second row and open the confirm dialog
|
|
376
|
+
editor.handleInput("\x1b[B");
|
|
377
|
+
editor.handleInput("d");
|
|
378
|
+
expect(editor.render(80).join("\n")).toContain(
|
|
379
|
+
'Delete "http://doomed:2"?',
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Esc keeps the server
|
|
383
|
+
editor.handleInput(ESC);
|
|
384
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
385
|
+
expect(editorSettings.setLlamaSetting).not.toHaveBeenCalled();
|
|
386
|
+
|
|
387
|
+
// Re-open and confirm the default "Delete" selection
|
|
388
|
+
editor.handleInput("d");
|
|
389
|
+
editor.handleInput(ENTER);
|
|
390
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
391
|
+
expect(editorSettings.setLlamaSetting).toHaveBeenCalledWith("servers", [
|
|
392
|
+
{ url: "http://seed:1" },
|
|
393
|
+
]);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
100
397
|
describe("/models interactive menu", () => {
|
|
101
398
|
const CHOICE = "model-a [Server: http://127.0.0.1:8080]";
|
|
102
399
|
|
|
@@ -113,10 +410,15 @@ describe("CommandManager", () => {
|
|
|
113
410
|
models: [model],
|
|
114
411
|
}),
|
|
115
412
|
);
|
|
116
|
-
const
|
|
413
|
+
const settingsStub = makeSettingsStub({
|
|
414
|
+
resolveServers: vi.fn(async () => servers),
|
|
415
|
+
});
|
|
416
|
+
const serverManager = new ServerManager(settingsStub);
|
|
117
417
|
return {
|
|
118
|
-
commandManager: new CommandManager(serverManager),
|
|
418
|
+
commandManager: new CommandManager(serverManager, settingsStub),
|
|
119
419
|
serverManager,
|
|
420
|
+
settingsStub,
|
|
421
|
+
servers,
|
|
120
422
|
mockPi,
|
|
121
423
|
};
|
|
122
424
|
};
|
|
@@ -198,5 +500,38 @@ describe("CommandManager", () => {
|
|
|
198
500
|
expect(ctx.ui.select).toHaveBeenCalledTimes(3);
|
|
199
501
|
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
200
502
|
});
|
|
503
|
+
|
|
504
|
+
it("should pick up servers added via the editor on the next /models scan", async () => {
|
|
505
|
+
const { commandManager, settingsStub, servers } = createCommandManager([
|
|
506
|
+
createMockModel("model-a"),
|
|
507
|
+
]);
|
|
508
|
+
|
|
509
|
+
const ctx = createMockCtx(() => null);
|
|
510
|
+
await commandManager.handleCommand("", ctx as any, mockPi as any);
|
|
511
|
+
|
|
512
|
+
// First scan: only model-a is offered
|
|
513
|
+
let choices = vi.mocked(ctx.ui.select).mock.calls[0][1] as string[];
|
|
514
|
+
expect(choices).toHaveLength(1);
|
|
515
|
+
expect(choices[0]).toContain("model-a");
|
|
516
|
+
|
|
517
|
+
// Editor persist → settings change → resolveServers now returns both
|
|
518
|
+
const modelB = createMockModel("model-b", {
|
|
519
|
+
serverUrl: "http://127.0.0.1:8081",
|
|
520
|
+
});
|
|
521
|
+
const serverB = createMockServer({
|
|
522
|
+
baseUrl: "http://127.0.0.1:8081",
|
|
523
|
+
models: [modelB],
|
|
524
|
+
});
|
|
525
|
+
vi.mocked(settingsStub.resolveServers).mockResolvedValue([
|
|
526
|
+
...servers,
|
|
527
|
+
serverB,
|
|
528
|
+
]);
|
|
529
|
+
|
|
530
|
+
await commandManager.handleCommand("", ctx as any, mockPi as any);
|
|
531
|
+
|
|
532
|
+
choices = vi.mocked(ctx.ui.select).mock.calls[1][1] as string[];
|
|
533
|
+
expect(choices).toHaveLength(2);
|
|
534
|
+
expect(choices[1]).toContain("model-b");
|
|
535
|
+
});
|
|
201
536
|
});
|
|
202
537
|
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { initTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
getKeybindings,
|
|
4
|
+
type KeybindingsManager,
|
|
5
|
+
type TUI,
|
|
6
|
+
} from "@earendil-works/pi-tui";
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { ConfirmDialog, InputDialog } from "../src/ui/dialog";
|
|
9
|
+
import { ServerSettingsList } from "../src/ui/serverSettingsList";
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
initTheme();
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const ESC = "\x1b";
|
|
17
|
+
const ENTER = "\r";
|
|
18
|
+
|
|
19
|
+
const createMockTheme = (): Theme =>
|
|
20
|
+
({
|
|
21
|
+
fg: (_color: string, text: string) => text,
|
|
22
|
+
bold: (text: string) => text,
|
|
23
|
+
}) as unknown as Theme;
|
|
24
|
+
|
|
25
|
+
const createMockTui = (): TUI => ({ requestRender: vi.fn() }) as unknown as TUI;
|
|
26
|
+
|
|
27
|
+
const createKeybindings = (): KeybindingsManager => getKeybindings();
|
|
28
|
+
|
|
29
|
+
describe("InputDialog", () => {
|
|
30
|
+
const build = (
|
|
31
|
+
overrides: Partial<ConstructorParameters<typeof InputDialog>[0]> = {},
|
|
32
|
+
) => {
|
|
33
|
+
const onSubmit = vi.fn();
|
|
34
|
+
const onCancel = vi.fn();
|
|
35
|
+
const dialog = new InputDialog({
|
|
36
|
+
theme: createMockTheme(),
|
|
37
|
+
tui: createMockTui(),
|
|
38
|
+
title: "Dialog title",
|
|
39
|
+
message: "Server URL",
|
|
40
|
+
placeholder: "http://127.0.0.1:8080",
|
|
41
|
+
validate: (raw) => (raw.startsWith("http") ? raw : null),
|
|
42
|
+
onSubmit,
|
|
43
|
+
onCancel,
|
|
44
|
+
...overrides,
|
|
45
|
+
});
|
|
46
|
+
return { dialog, onSubmit, onCancel };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
it("renders the framed title, message, placeholder and key hints", () => {
|
|
50
|
+
const { dialog } = build();
|
|
51
|
+
const rendered = dialog.render(60).join("\n");
|
|
52
|
+
expect(rendered).toContain("Dialog title");
|
|
53
|
+
expect(rendered).toContain("Server URL");
|
|
54
|
+
expect(rendered).toContain("e.g., http://127.0.0.1:8080");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("submits the validated value on Enter", () => {
|
|
58
|
+
const { dialog, onSubmit, onCancel } = build({
|
|
59
|
+
initialValue: "http://x:1",
|
|
60
|
+
});
|
|
61
|
+
dialog.handleInput(ENTER);
|
|
62
|
+
expect(onSubmit).toHaveBeenCalledWith("http://x:1");
|
|
63
|
+
expect(onCancel).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("keeps the dialog open with an error on invalid input", () => {
|
|
67
|
+
const { dialog, onSubmit, onCancel } = build();
|
|
68
|
+
for (const ch of "no-scheme") dialog.handleInput(ch);
|
|
69
|
+
dialog.handleInput(ENTER);
|
|
70
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
71
|
+
expect(dialog.render(60).join("\n")).toContain('Invalid value "no-scheme"');
|
|
72
|
+
|
|
73
|
+
// Esc still cancels after an error
|
|
74
|
+
dialog.handleInput(ESC);
|
|
75
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("cancels on Esc without submitting", () => {
|
|
79
|
+
const { dialog, onSubmit, onCancel } = build({
|
|
80
|
+
initialValue: "http://x:1",
|
|
81
|
+
});
|
|
82
|
+
dialog.handleInput(ESC);
|
|
83
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("ConfirmDialog", () => {
|
|
89
|
+
const build = () => {
|
|
90
|
+
const onConfirm = vi.fn();
|
|
91
|
+
const onCancel = vi.fn();
|
|
92
|
+
const dialog = new ConfirmDialog({
|
|
93
|
+
theme: createMockTheme(),
|
|
94
|
+
tui: createMockTui(),
|
|
95
|
+
title: "Delete server",
|
|
96
|
+
message: 'Delete "http://x:1"?',
|
|
97
|
+
onConfirm,
|
|
98
|
+
onCancel,
|
|
99
|
+
});
|
|
100
|
+
return { dialog, onConfirm, onCancel };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
it("renders the framed message and both options", () => {
|
|
104
|
+
const { dialog } = build();
|
|
105
|
+
const rendered = dialog.render(60).join("\n");
|
|
106
|
+
expect(rendered).toContain("Delete server");
|
|
107
|
+
expect(rendered).toContain('Delete "http://x:1"?');
|
|
108
|
+
expect(rendered).toContain("Delete");
|
|
109
|
+
expect(rendered).toContain("Cancel");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("confirms with Enter on the default selection", () => {
|
|
113
|
+
const { dialog, onConfirm, onCancel } = build();
|
|
114
|
+
dialog.handleInput(ENTER);
|
|
115
|
+
expect(onConfirm).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(onCancel).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("cancels on Esc", () => {
|
|
120
|
+
const { dialog, onConfirm, onCancel } = build();
|
|
121
|
+
dialog.handleInput(ESC);
|
|
122
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(onConfirm).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("ServerSettingsList add wizard", () => {
|
|
128
|
+
const buildEditor = (servers: { url: string; id?: string }[] = []) => {
|
|
129
|
+
const persist = vi.fn().mockResolvedValue(undefined);
|
|
130
|
+
const done = vi.fn();
|
|
131
|
+
const onError = vi.fn();
|
|
132
|
+
const editor = new ServerSettingsList({
|
|
133
|
+
tui: createMockTui(),
|
|
134
|
+
theme: createMockTheme(),
|
|
135
|
+
keybindings: createKeybindings(),
|
|
136
|
+
servers: servers as never[],
|
|
137
|
+
persist,
|
|
138
|
+
done,
|
|
139
|
+
onError,
|
|
140
|
+
});
|
|
141
|
+
return { editor, persist, done, onError };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
it("persists only the URL when the optional steps are skipped", async () => {
|
|
145
|
+
const { editor, persist } = buildEditor();
|
|
146
|
+
|
|
147
|
+
editor.handleInput("a");
|
|
148
|
+
for (const ch of "http://x:1") editor.handleInput(ch);
|
|
149
|
+
editor.handleInput(ENTER); // URL → ID
|
|
150
|
+
editor.handleInput(ENTER); // skip ID → name
|
|
151
|
+
editor.handleInput(ENTER); // skip name → persist
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
153
|
+
|
|
154
|
+
expect(persist).toHaveBeenCalledWith([{ url: "http://x:1" }]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("persists the URL and ID when the ID step is filled", async () => {
|
|
158
|
+
const { editor, persist } = buildEditor();
|
|
159
|
+
|
|
160
|
+
editor.handleInput("a");
|
|
161
|
+
for (const ch of "http://x:1") editor.handleInput(ch);
|
|
162
|
+
editor.handleInput(ENTER);
|
|
163
|
+
for (const ch of "my-id") editor.handleInput(ch);
|
|
164
|
+
editor.handleInput(ENTER); // ID → name
|
|
165
|
+
editor.handleInput(ENTER); // skip name → persist
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
167
|
+
|
|
168
|
+
expect(persist).toHaveBeenCalledWith([{ url: "http://x:1", id: "my-id" }]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("rejects an invalid URL at step 1 and aborts cleanly on Esc", async () => {
|
|
172
|
+
const { editor, persist } = buildEditor();
|
|
173
|
+
|
|
174
|
+
editor.handleInput("a");
|
|
175
|
+
for (const ch of "not-a-url") editor.handleInput(ch);
|
|
176
|
+
editor.handleInput(ENTER);
|
|
177
|
+
expect(persist).not.toHaveBeenCalled();
|
|
178
|
+
expect(editor.render(80).join("\n")).toContain('Invalid value "not-a-url"');
|
|
179
|
+
|
|
180
|
+
editor.handleInput(ESC);
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
182
|
+
expect(persist).not.toHaveBeenCalled();
|
|
183
|
+
// Back at the (empty) list view
|
|
184
|
+
expect(editor.render(80).join("\n")).toContain("(a) add server");
|
|
185
|
+
});
|
|
186
|
+
});
|