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.
Files changed (41) hide show
  1. package/README.md +187 -10
  2. package/package.json +6 -6
  3. package/src/api/client.ts +76 -32
  4. package/src/constants.ts +15 -0
  5. package/src/index.ts +3 -5
  6. package/src/interfaces/events.ts +14 -3
  7. package/src/interfaces/server.ts +27 -0
  8. package/src/interfaces/settings.ts +76 -2
  9. package/src/managers/command.ts +369 -50
  10. package/src/managers/events.ts +28 -10
  11. package/src/managers/server.ts +90 -16
  12. package/src/managers/settings.ts +181 -44
  13. package/src/models/baseModel.ts +37 -15
  14. package/src/models/routerModel.ts +2 -1
  15. package/src/server.ts +103 -28
  16. package/src/sse/client.ts +28 -16
  17. package/src/sse/manager.ts +26 -13
  18. package/src/ui/dialog.ts +287 -0
  19. package/src/ui/overrideEntryEditor.ts +119 -0
  20. package/src/ui/overrideSettingsList.ts +682 -0
  21. package/src/ui/serverListEditor.ts +32 -0
  22. package/src/ui/serverSettingsList.ts +466 -0
  23. package/src/ui/strings.ts +127 -0
  24. package/src/utils/errors.ts +5 -0
  25. package/src/utils/settingsStore.ts +56 -0
  26. package/src/utils/urls.ts +16 -0
  27. package/tests/commandManager.test.ts +346 -11
  28. package/tests/dialog.test.ts +186 -0
  29. package/tests/events.test.ts +120 -88
  30. package/tests/legacyModel.test.ts +4 -19
  31. package/tests/mocks.ts +149 -32
  32. package/tests/overrides.test.ts +352 -0
  33. package/tests/server.test.ts +42 -40
  34. package/tests/serverManager.test.ts +264 -55
  35. package/tests/settings.test.ts +654 -51
  36. package/tests/settingsStore.test.ts +190 -0
  37. package/tests/singleModel.test.ts +32 -0
  38. package/tests/sseManager.test.ts +88 -11
  39. package/src/interfaces/auth.ts +0 -6
  40. package/src/utils/cache.ts +0 -39
  41. 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 { CommandManager } from "../src/managers/command";
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
- serverManager = new ServerManager([]);
26
- commandManager = new CommandManager(serverManager);
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(2);
33
- expect(completions?.map((c) => c.value)).toEqual(["info", "unload"]);
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
- serverManager = new ServerManager([server] as any);
57
- commandManager = new CommandManager(serverManager);
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
- serverManager = new ServerManager([server] as any);
84
- commandManager = new CommandManager(serverManager);
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 serverManager = new ServerManager(servers as any);
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
+ });