pi-llama-cpp 0.11.0 → 0.13.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 +186 -58
- package/package.json +5 -5
- package/src/constants.ts +8 -0
- package/src/interfaces/server.ts +7 -0
- package/src/interfaces/settings.ts +67 -1
- package/src/managers/command.ts +110 -31
- package/src/managers/events.ts +2 -2
- package/src/managers/server.ts +14 -11
- package/src/managers/settings.ts +120 -45
- package/src/models/baseModel.ts +37 -7
- package/src/models/routerModel.ts +2 -1
- package/src/server.ts +62 -23
- package/src/sse/manager.ts +9 -8
- package/src/ui/dialog.ts +290 -0
- package/src/ui/overrideEntryEditor.ts +119 -0
- package/src/ui/overrideSettingsList.ts +710 -0
- package/src/ui/serverListEditor.ts +19 -441
- package/src/ui/serverSettingsList.ts +513 -0
- package/src/ui/strings.ts +127 -0
- package/src/utils/health.ts +48 -0
- package/src/utils/settingsStore.ts +2 -6
- package/tests/commandManager.test.ts +132 -16
- package/tests/dialog.test.ts +228 -0
- package/tests/events.test.ts +12 -9
- package/tests/health.test.ts +116 -0
- package/tests/legacyModel.test.ts +4 -19
- package/tests/mocks.ts +12 -8
- package/tests/overrides.test.ts +384 -0
- package/tests/server.test.ts +59 -16
- package/tests/serverManager.test.ts +28 -62
- package/tests/settings.test.ts +489 -43
- package/tests/settingsStore.test.ts +0 -19
- package/tests/singleModel.test.ts +32 -0
- package/tests/sseManager.test.ts +4 -4
- package/tests/serverListEditor.test.ts +0 -637
|
@@ -1,637 +0,0 @@
|
|
|
1
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { KeybindingsManager, TUI } from "@earendil-works/pi-tui";
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import type { LlamaServer } from "../src/interfaces/settings";
|
|
5
|
-
import {
|
|
6
|
-
ServerListEditor,
|
|
7
|
-
addServer,
|
|
8
|
-
formatServerSuffix,
|
|
9
|
-
normalizeServerUrl,
|
|
10
|
-
removeServer,
|
|
11
|
-
updateServerField,
|
|
12
|
-
updateServerUrl,
|
|
13
|
-
} from "../src/ui/serverListEditor";
|
|
14
|
-
|
|
15
|
-
/** Raw key sequences used to drive the editor */
|
|
16
|
-
const UP = "\x1b[A";
|
|
17
|
-
const DOWN = "\x1b[B";
|
|
18
|
-
const ENTER = "\r";
|
|
19
|
-
const ESC = "\x1b";
|
|
20
|
-
|
|
21
|
-
const createMockKeybindings = (): KeybindingsManager => {
|
|
22
|
-
const map: Record<string, string> = {
|
|
23
|
-
[UP]: "tui.select.up",
|
|
24
|
-
[DOWN]: "tui.select.down",
|
|
25
|
-
[ENTER]: "tui.select.confirm",
|
|
26
|
-
[ESC]: "tui.select.cancel",
|
|
27
|
-
};
|
|
28
|
-
return {
|
|
29
|
-
matches: vi.fn((data: string, name: string) => map[data] === name),
|
|
30
|
-
} as unknown as KeybindingsManager;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const createMockTui = (): TUI => ({ requestRender: vi.fn() }) as unknown as TUI;
|
|
34
|
-
|
|
35
|
-
const createMockTheme = (): Theme =>
|
|
36
|
-
({
|
|
37
|
-
fg: (_color: string, text: string) => text,
|
|
38
|
-
bold: (text: string) => text,
|
|
39
|
-
}) as unknown as Theme;
|
|
40
|
-
|
|
41
|
-
const setup = (servers: LlamaServer[] = []) => {
|
|
42
|
-
const persist = vi.fn(async (_next: LlamaServer[]) => {});
|
|
43
|
-
const done = vi.fn();
|
|
44
|
-
const onError = vi.fn();
|
|
45
|
-
const editor = new ServerListEditor({
|
|
46
|
-
tui: createMockTui(),
|
|
47
|
-
theme: createMockTheme(),
|
|
48
|
-
keybindings: createMockKeybindings(),
|
|
49
|
-
servers,
|
|
50
|
-
persist,
|
|
51
|
-
done,
|
|
52
|
-
onError,
|
|
53
|
-
});
|
|
54
|
-
return { editor, persist, done, onError };
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/** Flushes the microtask queue so awaited persist calls settle */
|
|
58
|
-
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Stand-in terminal width in columns passed to render(), mirroring what
|
|
62
|
-
* pi-tui supplies at runtime; wide enough that no line gets truncated.
|
|
63
|
-
*/
|
|
64
|
-
const RENDER_WIDTH = 80;
|
|
65
|
-
|
|
66
|
-
const render = (editor: ServerListEditor) =>
|
|
67
|
-
editor.render(RENDER_WIDTH).join("\n");
|
|
68
|
-
|
|
69
|
-
const type = (editor: ServerListEditor, text: string) => {
|
|
70
|
-
for (const ch of text) editor.handleInput(ch);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
afterEach(() => {
|
|
74
|
-
vi.unstubAllEnvs();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe("server URL helpers", () => {
|
|
78
|
-
describe("normalizeServerUrl", () => {
|
|
79
|
-
it("should trim whitespace and strip trailing slashes", () => {
|
|
80
|
-
expect(normalizeServerUrl(" http://a:8080/ ")).toBe("http://a:8080");
|
|
81
|
-
expect(normalizeServerUrl("https://x.y///")).toBe("https://x.y");
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("should reject empty input", () => {
|
|
85
|
-
expect(normalizeServerUrl("")).toBeNull();
|
|
86
|
-
expect(normalizeServerUrl(" ")).toBeNull();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("should reject semicolons (one URL per entry)", () => {
|
|
90
|
-
expect(normalizeServerUrl("http://a:1;http://b:2")).toBeNull();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("should reject values without an http(s) scheme", () => {
|
|
94
|
-
expect(normalizeServerUrl("foo")).toBeNull();
|
|
95
|
-
expect(normalizeServerUrl("127.0.0.1:8080")).toBeNull();
|
|
96
|
-
expect(normalizeServerUrl("ftp://a:1")).toBeNull();
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe("addServer", () => {
|
|
101
|
-
it("should append a url-only entry without mutating the input", () => {
|
|
102
|
-
const servers: LlamaServer[] = [{ url: "http://a:1", name: "A" }];
|
|
103
|
-
const next = addServer(servers, "http://b:2");
|
|
104
|
-
|
|
105
|
-
expect(next).toEqual([
|
|
106
|
-
{ url: "http://a:1", name: "A" },
|
|
107
|
-
{ url: "http://b:2" },
|
|
108
|
-
]);
|
|
109
|
-
expect(servers).toEqual([{ url: "http://a:1", name: "A" }]);
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
describe("updateServerUrl", () => {
|
|
114
|
-
it("should replace the url and preserve id/name overrides", () => {
|
|
115
|
-
const servers: LlamaServer[] = [
|
|
116
|
-
{ url: "http://a:1", id: "custom", name: "A" },
|
|
117
|
-
{ url: "http://b:2" },
|
|
118
|
-
];
|
|
119
|
-
const next = updateServerUrl(servers, 0, "http://a:9");
|
|
120
|
-
|
|
121
|
-
expect(next).toEqual([
|
|
122
|
-
{ url: "http://a:9", id: "custom", name: "A" },
|
|
123
|
-
{ url: "http://b:2" },
|
|
124
|
-
]);
|
|
125
|
-
expect(servers[0].url).toBe("http://a:1");
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("updateServerField", () => {
|
|
130
|
-
it("should set id and name immutably", () => {
|
|
131
|
-
const servers: LlamaServer[] = [{ url: "http://a:1" }];
|
|
132
|
-
|
|
133
|
-
const withId = updateServerField(servers, 0, "id", " my-id ");
|
|
134
|
-
expect(withId).toEqual([{ url: "http://a:1", id: "my-id" }]);
|
|
135
|
-
expect(servers).toEqual([{ url: "http://a:1" }]);
|
|
136
|
-
|
|
137
|
-
const withName = updateServerField(withId, 0, "name", "My Name");
|
|
138
|
-
expect(withName).toEqual([
|
|
139
|
-
{ url: "http://a:1", id: "my-id", name: "My Name" },
|
|
140
|
-
]);
|
|
141
|
-
expect(withId).toEqual([{ url: "http://a:1", id: "my-id" }]);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("should remove the override key on empty/whitespace input", () => {
|
|
145
|
-
const servers: LlamaServer[] = [
|
|
146
|
-
{ url: "http://a:1", id: "custom", name: "A" },
|
|
147
|
-
];
|
|
148
|
-
|
|
149
|
-
expect(updateServerField(servers, 0, "id", "")).toEqual([
|
|
150
|
-
{ url: "http://a:1", name: "A" },
|
|
151
|
-
]);
|
|
152
|
-
expect(updateServerField(servers, 0, "name", " ")).toEqual([
|
|
153
|
-
{ url: "http://a:1", id: "custom" },
|
|
154
|
-
]);
|
|
155
|
-
expect(servers[0]).toEqual({
|
|
156
|
-
url: "http://a:1",
|
|
157
|
-
id: "custom",
|
|
158
|
-
name: "A",
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("should set the url via the generic helper (delegation)", () => {
|
|
163
|
-
const servers: LlamaServer[] = [{ url: "http://a:1", id: "custom" }];
|
|
164
|
-
|
|
165
|
-
expect(updateServerField(servers, 0, "url", "http://a:9")).toEqual([
|
|
166
|
-
{ url: "http://a:9", id: "custom" },
|
|
167
|
-
]);
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
describe("formatServerSuffix", () => {
|
|
172
|
-
it("should render id and name, auto-detected id, id only or nothing", () => {
|
|
173
|
-
expect(
|
|
174
|
-
formatServerSuffix({ url: "http://a:1", id: "my-id", name: "My Name" }),
|
|
175
|
-
).toBe("(my-id - My Name)");
|
|
176
|
-
expect(formatServerSuffix({ url: "http://a:1", name: "My Name" })).toBe(
|
|
177
|
-
"(llama-server=http://a:1 - My Name)",
|
|
178
|
-
);
|
|
179
|
-
expect(formatServerSuffix({ url: "http://a:1", id: "my-id" })).toBe(
|
|
180
|
-
"(my-id)",
|
|
181
|
-
);
|
|
182
|
-
expect(formatServerSuffix({ url: "http://a:1" })).toBe("");
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
describe("removeServer", () => {
|
|
187
|
-
it("should remove the entry at the index", () => {
|
|
188
|
-
const servers: LlamaServer[] = [
|
|
189
|
-
{ url: "http://a:1" },
|
|
190
|
-
{ url: "http://b:2" },
|
|
191
|
-
];
|
|
192
|
-
expect(removeServer(servers, 0)).toEqual([{ url: "http://b:2" }]);
|
|
193
|
-
expect(servers).toHaveLength(2);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("should allow removing the last entry", () => {
|
|
197
|
-
expect(removeServer([{ url: "http://a:1" }], 0)).toEqual([]);
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
describe("ServerListEditor", () => {
|
|
203
|
-
describe("rendering", () => {
|
|
204
|
-
it("should render the title, the rows and the selection cursor", () => {
|
|
205
|
-
const { editor } = setup([
|
|
206
|
-
{ url: "http://a:1" },
|
|
207
|
-
{ url: "http://b:2", name: "Bee" },
|
|
208
|
-
]);
|
|
209
|
-
|
|
210
|
-
const out = render(editor);
|
|
211
|
-
expect(out).toContain("Manage llama.cpp servers");
|
|
212
|
-
expect(out).toContain("→ http://a:1");
|
|
213
|
-
expect(out).toContain("(llama-server=http://b:2 - Bee)");
|
|
214
|
-
expect(out).toContain(
|
|
215
|
-
"Enter/e url · i id · n name · a add · d delete · Esc done",
|
|
216
|
-
);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("should render an empty-state hint when there are no servers", () => {
|
|
220
|
-
const { editor } = setup([]);
|
|
221
|
-
|
|
222
|
-
const out = render(editor);
|
|
223
|
-
expect(out).toContain("No servers configured");
|
|
224
|
-
expect(out).toContain("http://127.0.0.1:8080");
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("should render the edit mode with a URL field", () => {
|
|
228
|
-
const { editor } = setup([{ url: "http://a:1" }]);
|
|
229
|
-
editor.handleInput(ENTER);
|
|
230
|
-
|
|
231
|
-
const out = render(editor);
|
|
232
|
-
expect(out).toContain("URL:");
|
|
233
|
-
expect(out).toContain("> http://a:1");
|
|
234
|
-
expect(out).toContain("Enter save · Esc cancel");
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
describe("list mode", () => {
|
|
239
|
-
it("should move the selection with up/down, wrapping around", () => {
|
|
240
|
-
const { editor } = setup([{ url: "http://a:1" }, { url: "http://b:2" }]);
|
|
241
|
-
|
|
242
|
-
editor.handleInput(DOWN);
|
|
243
|
-
expect(render(editor)).toContain("→ http://b:2");
|
|
244
|
-
|
|
245
|
-
editor.handleInput(DOWN);
|
|
246
|
-
expect(render(editor)).toContain("→ http://a:1");
|
|
247
|
-
|
|
248
|
-
editor.handleInput(UP);
|
|
249
|
-
expect(render(editor)).toContain("→ http://b:2");
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it("should close on Escape", () => {
|
|
253
|
-
const { editor, done } = setup([{ url: "http://a:1" }]);
|
|
254
|
-
|
|
255
|
-
editor.handleInput(ESC);
|
|
256
|
-
|
|
257
|
-
expect(done).toHaveBeenCalledTimes(1);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it("should ignore edit/delete on an empty list", () => {
|
|
261
|
-
const { editor, persist } = setup([]);
|
|
262
|
-
|
|
263
|
-
editor.handleInput(ENTER);
|
|
264
|
-
editor.handleInput("d");
|
|
265
|
-
editor.handleInput("i");
|
|
266
|
-
editor.handleInput("n");
|
|
267
|
-
|
|
268
|
-
expect(render(editor)).not.toContain("URL:");
|
|
269
|
-
expect(render(editor)).not.toContain("ID:");
|
|
270
|
-
expect(render(editor)).not.toContain("Name:");
|
|
271
|
-
expect(persist).not.toHaveBeenCalled();
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it("should open the id/name editors with i/n, prefilling the value", () => {
|
|
275
|
-
const { editor } = setup([
|
|
276
|
-
{ url: "http://a:1", id: "custom", name: "A" },
|
|
277
|
-
]);
|
|
278
|
-
|
|
279
|
-
editor.handleInput("i");
|
|
280
|
-
expect(render(editor)).toContain("ID:");
|
|
281
|
-
expect(render(editor)).toContain("> custom");
|
|
282
|
-
|
|
283
|
-
editor.handleInput(ESC);
|
|
284
|
-
editor.handleInput("n");
|
|
285
|
-
expect(render(editor)).toContain("Name:");
|
|
286
|
-
expect(render(editor)).toContain("> A");
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("should prefill an empty input when no id/name override is set", () => {
|
|
290
|
-
const { editor } = setup([{ url: "http://a:1" }]);
|
|
291
|
-
|
|
292
|
-
editor.handleInput("i");
|
|
293
|
-
expect(render(editor)).toContain("ID:");
|
|
294
|
-
expect(render(editor)).toContain("> ");
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
describe("editing", () => {
|
|
299
|
-
it("should prefill the input and append at the cursor (cursor at end)", async () => {
|
|
300
|
-
const { editor, persist, done } = setup([{ url: "http://a:1" }]);
|
|
301
|
-
|
|
302
|
-
editor.handleInput(ENTER);
|
|
303
|
-
editor.handleInput("X");
|
|
304
|
-
editor.handleInput(ENTER);
|
|
305
|
-
await flush();
|
|
306
|
-
|
|
307
|
-
expect(persist).toHaveBeenCalledWith([{ url: "http://a:1X" }]);
|
|
308
|
-
expect(done).not.toHaveBeenCalled();
|
|
309
|
-
// Back in list mode
|
|
310
|
-
expect(render(editor)).not.toContain("URL:");
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it("should preserve id/name overrides when saving", async () => {
|
|
314
|
-
const { editor, persist } = setup([
|
|
315
|
-
{ url: "http://a:1", id: "custom", name: "A" },
|
|
316
|
-
]);
|
|
317
|
-
|
|
318
|
-
editor.handleInput(ENTER);
|
|
319
|
-
editor.handleInput("X");
|
|
320
|
-
editor.handleInput(ENTER);
|
|
321
|
-
await flush();
|
|
322
|
-
|
|
323
|
-
expect(persist).toHaveBeenCalledWith([
|
|
324
|
-
{ url: "http://a:1X", id: "custom", name: "A" },
|
|
325
|
-
]);
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it("should revert without writing on Escape", async () => {
|
|
329
|
-
const { editor, persist } = setup([{ url: "http://a:1" }]);
|
|
330
|
-
|
|
331
|
-
editor.handleInput(ENTER);
|
|
332
|
-
editor.handleInput("X");
|
|
333
|
-
editor.handleInput(ESC);
|
|
334
|
-
await flush();
|
|
335
|
-
|
|
336
|
-
expect(persist).not.toHaveBeenCalled();
|
|
337
|
-
expect(render(editor)).not.toContain("URL:");
|
|
338
|
-
expect(render(editor)).not.toContain("http://a:1X");
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
it("should reject invalid URLs with an inline error and keep editing", async () => {
|
|
342
|
-
const { editor, persist, onError } = setup([{ url: "http://a:1" }]);
|
|
343
|
-
|
|
344
|
-
editor.handleInput("a");
|
|
345
|
-
type(editor, "foo");
|
|
346
|
-
editor.handleInput(ENTER);
|
|
347
|
-
await flush();
|
|
348
|
-
|
|
349
|
-
expect(persist).not.toHaveBeenCalled();
|
|
350
|
-
expect(onError).not.toHaveBeenCalled();
|
|
351
|
-
expect(render(editor)).toContain("Invalid URL");
|
|
352
|
-
expect(render(editor)).toContain("URL:");
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
it("should reject semicolon-separated URLs", async () => {
|
|
356
|
-
const { editor, persist } = setup([]);
|
|
357
|
-
|
|
358
|
-
editor.handleInput("a");
|
|
359
|
-
type(editor, "http://a:1;http://b:2");
|
|
360
|
-
editor.handleInput(ENTER);
|
|
361
|
-
await flush();
|
|
362
|
-
|
|
363
|
-
expect(persist).not.toHaveBeenCalled();
|
|
364
|
-
expect(render(editor)).toContain("Invalid URL");
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
it("should normalize the URL on save", async () => {
|
|
368
|
-
const { editor, persist } = setup([{ url: "http://a:1" }]);
|
|
369
|
-
|
|
370
|
-
editor.handleInput("a");
|
|
371
|
-
type(editor, " http://new:2/ ");
|
|
372
|
-
editor.handleInput(ENTER);
|
|
373
|
-
await flush();
|
|
374
|
-
|
|
375
|
-
expect(persist).toHaveBeenCalledWith([
|
|
376
|
-
{ url: "http://a:1" },
|
|
377
|
-
{ url: "http://new:2" },
|
|
378
|
-
]);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it("should move the selection to the added entry", async () => {
|
|
382
|
-
const { editor } = setup([{ url: "http://a:1" }]);
|
|
383
|
-
|
|
384
|
-
editor.handleInput("a");
|
|
385
|
-
type(editor, "http://new:2");
|
|
386
|
-
editor.handleInput(ENTER);
|
|
387
|
-
await flush();
|
|
388
|
-
|
|
389
|
-
expect(render(editor)).toContain("→ http://new:2");
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
it("should notify and stay in edit mode when the write fails", async () => {
|
|
393
|
-
const { editor, persist, onError } = setup([{ url: "http://a:1" }]);
|
|
394
|
-
persist.mockRejectedValueOnce(new Error("disk boom"));
|
|
395
|
-
|
|
396
|
-
editor.handleInput("a");
|
|
397
|
-
type(editor, "http://new:2");
|
|
398
|
-
editor.handleInput(ENTER);
|
|
399
|
-
await flush();
|
|
400
|
-
|
|
401
|
-
expect(onError).toHaveBeenCalledWith("disk boom");
|
|
402
|
-
// Pre-mutation list unchanged (no new row), still editing
|
|
403
|
-
expect(render(editor)).toContain("→ http://a:1");
|
|
404
|
-
expect(render(editor)).toContain("URL:");
|
|
405
|
-
});
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
describe("editing id/name", () => {
|
|
409
|
-
it("should persist id and name overrides", async () => {
|
|
410
|
-
const { editor, persist } = setup([{ url: "http://a:1" }]);
|
|
411
|
-
|
|
412
|
-
editor.handleInput("i");
|
|
413
|
-
type(editor, "my-id");
|
|
414
|
-
editor.handleInput(ENTER);
|
|
415
|
-
await flush();
|
|
416
|
-
|
|
417
|
-
editor.handleInput("n");
|
|
418
|
-
type(editor, "My Name");
|
|
419
|
-
editor.handleInput(ENTER);
|
|
420
|
-
await flush();
|
|
421
|
-
|
|
422
|
-
expect(persist).toHaveBeenLastCalledWith([
|
|
423
|
-
{ url: "http://a:1", id: "my-id", name: "My Name" },
|
|
424
|
-
]);
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
it("should trim id/name values", async () => {
|
|
428
|
-
const { editor, persist } = setup([{ url: "http://a:1" }]);
|
|
429
|
-
|
|
430
|
-
editor.handleInput("i");
|
|
431
|
-
type(editor, " my-id ");
|
|
432
|
-
editor.handleInput(ENTER);
|
|
433
|
-
await flush();
|
|
434
|
-
|
|
435
|
-
expect(persist).toHaveBeenCalledWith([
|
|
436
|
-
{ url: "http://a:1", id: "my-id" },
|
|
437
|
-
]);
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
it("should clear an override on empty input", async () => {
|
|
441
|
-
const { editor, persist } = setup([
|
|
442
|
-
{ url: "http://a:1", id: "custom", name: "A" },
|
|
443
|
-
]);
|
|
444
|
-
|
|
445
|
-
editor.handleInput("i");
|
|
446
|
-
// Clear the prefilled id
|
|
447
|
-
for (let i = 0; i < "custom".length; i++) {
|
|
448
|
-
editor.handleInput("\x7f"); // backspace
|
|
449
|
-
}
|
|
450
|
-
editor.handleInput(ENTER);
|
|
451
|
-
await flush();
|
|
452
|
-
|
|
453
|
-
expect(persist).toHaveBeenCalledWith([{ url: "http://a:1", name: "A" }]);
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
it("should revert without writing on Escape (even after clearing)", async () => {
|
|
457
|
-
const { editor, persist } = setup([{ url: "http://a:1", id: "custom" }]);
|
|
458
|
-
|
|
459
|
-
editor.handleInput("i");
|
|
460
|
-
for (let i = 0; i < "custom".length; i++) {
|
|
461
|
-
editor.handleInput("\x7f");
|
|
462
|
-
}
|
|
463
|
-
editor.handleInput(ESC);
|
|
464
|
-
await flush();
|
|
465
|
-
|
|
466
|
-
expect(persist).not.toHaveBeenCalled();
|
|
467
|
-
expect(render(editor)).toContain("(custom)");
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
it("should notify and stay in edit mode when the write fails", async () => {
|
|
471
|
-
const { editor, persist, onError } = setup([{ url: "http://a:1" }]);
|
|
472
|
-
persist.mockRejectedValueOnce(new Error("disk boom"));
|
|
473
|
-
|
|
474
|
-
editor.handleInput("i");
|
|
475
|
-
type(editor, "my-id");
|
|
476
|
-
editor.handleInput(ENTER);
|
|
477
|
-
await flush();
|
|
478
|
-
|
|
479
|
-
expect(onError).toHaveBeenCalledWith("disk boom");
|
|
480
|
-
expect(render(editor)).toContain("ID:");
|
|
481
|
-
expect(render(editor)).not.toContain("(my-id)");
|
|
482
|
-
});
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
describe("deleting", () => {
|
|
486
|
-
it("should ask for confirmation before deleting", () => {
|
|
487
|
-
const { editor, persist } = setup([
|
|
488
|
-
{ url: "http://a:1" },
|
|
489
|
-
{ url: "http://b:2" },
|
|
490
|
-
]);
|
|
491
|
-
|
|
492
|
-
editor.handleInput("d");
|
|
493
|
-
|
|
494
|
-
expect(render(editor)).toContain('About to delete "http://a:1"');
|
|
495
|
-
expect(render(editor)).toContain("Are you sure?");
|
|
496
|
-
expect(render(editor)).toContain("y delete · Esc/n cancel");
|
|
497
|
-
expect(persist).not.toHaveBeenCalled();
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
it("should persist the list without the selected entry on y", async () => {
|
|
501
|
-
const { editor, persist } = setup([
|
|
502
|
-
{ url: "http://a:1" },
|
|
503
|
-
{ url: "http://b:2" },
|
|
504
|
-
]);
|
|
505
|
-
|
|
506
|
-
editor.handleInput("d");
|
|
507
|
-
editor.handleInput("y");
|
|
508
|
-
await flush();
|
|
509
|
-
|
|
510
|
-
expect(persist).toHaveBeenCalledWith([{ url: "http://b:2" }]);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
it("should ignore Enter so it cannot accidentally confirm", () => {
|
|
514
|
-
const { editor, persist } = setup([{ url: "http://a:1" }]);
|
|
515
|
-
|
|
516
|
-
editor.handleInput("d");
|
|
517
|
-
editor.handleInput(ENTER);
|
|
518
|
-
|
|
519
|
-
expect(render(editor)).toContain("Are you sure?");
|
|
520
|
-
expect(persist).not.toHaveBeenCalled();
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
it("should cancel on Esc without persisting", async () => {
|
|
524
|
-
const { editor, persist, done } = setup([{ url: "http://a:1" }]);
|
|
525
|
-
|
|
526
|
-
editor.handleInput("d");
|
|
527
|
-
editor.handleInput(ESC);
|
|
528
|
-
await flush();
|
|
529
|
-
|
|
530
|
-
expect(persist).not.toHaveBeenCalled();
|
|
531
|
-
expect(done).not.toHaveBeenCalled();
|
|
532
|
-
expect(render(editor)).toContain("→ http://a:1");
|
|
533
|
-
expect(render(editor)).not.toContain("Are you sure?");
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
it("should return to the original line count after canceling", () => {
|
|
537
|
-
const { editor } = setup([{ url: "http://a:1" }]);
|
|
538
|
-
|
|
539
|
-
const before = editor.render(RENDER_WIDTH).length;
|
|
540
|
-
editor.handleInput("d");
|
|
541
|
-
// "About to delete …" + "Are you sure?"
|
|
542
|
-
expect(editor.render(RENDER_WIDTH).length).toBe(before + 2);
|
|
543
|
-
editor.handleInput(ESC);
|
|
544
|
-
|
|
545
|
-
expect(editor.render(RENDER_WIDTH).length).toBe(before);
|
|
546
|
-
expect(render(editor)).not.toContain("Are you sure?");
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
it("should never embed newlines in rendered lines", () => {
|
|
550
|
-
// pi-tui's diff renderer assumes one rendered line == one terminal
|
|
551
|
-
// row; a raw \n inside a line leaves stale residue after the prompt
|
|
552
|
-
// shrinks.
|
|
553
|
-
const { editor } = setup([{ url: "http://a:1" }]);
|
|
554
|
-
|
|
555
|
-
editor.handleInput("d");
|
|
556
|
-
|
|
557
|
-
for (const line of editor.render(RENDER_WIDTH)) {
|
|
558
|
-
expect(line).not.toContain("\n");
|
|
559
|
-
}
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
it("should cancel on n without persisting", async () => {
|
|
563
|
-
const { editor, persist } = setup([{ url: "http://a:1" }]);
|
|
564
|
-
|
|
565
|
-
editor.handleInput("d");
|
|
566
|
-
editor.handleInput("n");
|
|
567
|
-
await flush();
|
|
568
|
-
|
|
569
|
-
expect(persist).not.toHaveBeenCalled();
|
|
570
|
-
expect(render(editor)).toContain("→ http://a:1");
|
|
571
|
-
expect(render(editor)).not.toContain("Are you sure?");
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
it("should ignore other keys while confirming", () => {
|
|
575
|
-
const { editor, persist } = setup([{ url: "http://a:1" }]);
|
|
576
|
-
|
|
577
|
-
editor.handleInput("d");
|
|
578
|
-
editor.handleInput(DOWN);
|
|
579
|
-
editor.handleInput(ENTER);
|
|
580
|
-
editor.handleInput("x");
|
|
581
|
-
|
|
582
|
-
expect(render(editor)).toContain("Are you sure?");
|
|
583
|
-
expect(persist).not.toHaveBeenCalled();
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
it("should clamp the selection when the last entry is removed", async () => {
|
|
587
|
-
const { editor } = setup([{ url: "http://a:1" }, { url: "http://b:2" }]);
|
|
588
|
-
|
|
589
|
-
editor.handleInput(DOWN);
|
|
590
|
-
editor.handleInput("d");
|
|
591
|
-
editor.handleInput("y");
|
|
592
|
-
await flush();
|
|
593
|
-
|
|
594
|
-
expect(render(editor)).toContain("→ http://a:1");
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
it("should allow emptying the list", async () => {
|
|
598
|
-
const { editor, persist } = setup([{ url: "http://a:1" }]);
|
|
599
|
-
|
|
600
|
-
editor.handleInput("d");
|
|
601
|
-
editor.handleInput("y");
|
|
602
|
-
await flush();
|
|
603
|
-
|
|
604
|
-
expect(persist).toHaveBeenCalledWith([]);
|
|
605
|
-
expect(render(editor)).toContain("No servers configured");
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
it("should notify and keep the list when the write fails", async () => {
|
|
609
|
-
const { editor, persist, onError } = setup([{ url: "http://a:1" }]);
|
|
610
|
-
persist.mockRejectedValueOnce(new Error("disk boom"));
|
|
611
|
-
|
|
612
|
-
editor.handleInput("d");
|
|
613
|
-
editor.handleInput("y");
|
|
614
|
-
await flush();
|
|
615
|
-
|
|
616
|
-
expect(onError).toHaveBeenCalledWith("disk boom");
|
|
617
|
-
expect(render(editor)).toContain("http://a:1");
|
|
618
|
-
});
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
describe("env override hint", () => {
|
|
622
|
-
it("should hint when LLAMA_SERVER_URL overrides the settings", () => {
|
|
623
|
-
vi.stubEnv("LLAMA_SERVER_URL", "http://env:1");
|
|
624
|
-
const { editor } = setup([{ url: "http://a:1" }]);
|
|
625
|
-
|
|
626
|
-
expect(render(editor)).toContain(
|
|
627
|
-
"LLAMA_SERVER_URL env var overrides these servers",
|
|
628
|
-
);
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
it("should not hint when LLAMA_SERVER_URL is unset", () => {
|
|
632
|
-
const { editor } = setup([{ url: "http://a:1" }]);
|
|
633
|
-
|
|
634
|
-
expect(render(editor)).not.toContain("LLAMA_SERVER_URL");
|
|
635
|
-
});
|
|
636
|
-
});
|
|
637
|
-
});
|