pi-llama-cpp 0.9.2 → 0.11.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.
@@ -1,12 +1,23 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { KeybindingsManager, TUI } from "@earendil-works/pi-tui";
1
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
4
  import { Action } from "../src/enums/action";
3
- import { CommandManager } from "../src/managers/command";
5
+ import {
6
+ applySettingChange,
7
+ buildSettingsItems,
8
+ CommandManager,
9
+ formatMs,
10
+ } from "../src/managers/command";
4
11
  import { ServerManager } from "../src/managers/server";
12
+ import type { LlamaSettingsManager } from "../src/managers/settings";
13
+ import type { Server } from "../src/server";
14
+ import { ServerListEditor } from "../src/ui/serverListEditor";
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
 
@@ -18,19 +29,26 @@ beforeEach(() => {
18
29
  describe("CommandManager", () => {
19
30
  let serverManager: ServerManager;
20
31
  let commandManager: CommandManager;
32
+ let settingsStub: LlamaSettingsManager;
21
33
  let mockPi: ReturnType<typeof createMockPi>;
22
34
 
23
35
  beforeEach(() => {
24
36
  mockPi = createMockPi();
25
- serverManager = new ServerManager([]);
26
- commandManager = new CommandManager(serverManager);
37
+ settingsStub = makeSettingsStub();
38
+ serverManager = new ServerManager(settingsStub);
39
+ commandManager = new CommandManager(serverManager, settingsStub);
27
40
  });
28
41
 
29
42
  describe("getArgumentCompletions", () => {
30
43
  it("should provide completions for /models", () => {
31
44
  const completions = commandManager.getArgumentCompletions("");
32
- expect(completions).toHaveLength(2);
33
- expect(completions?.map((c) => c.value)).toEqual(["info", "unload"]);
45
+ expect(completions).toHaveLength(4);
46
+ expect(completions?.map((c) => c.value)).toEqual([
47
+ "info",
48
+ "unload",
49
+ "servers",
50
+ "settings",
51
+ ]);
34
52
  });
35
53
 
36
54
  it("should filter completions by prefix", () => {
@@ -43,6 +61,11 @@ describe("CommandManager", () => {
43
61
  const completions = commandManager.getArgumentCompletions("zzz");
44
62
  expect(completions).toBeNull();
45
63
  });
64
+
65
+ it("should provide the server/settings completions by prefix", () => {
66
+ const completions = commandManager.getArgumentCompletions("s");
67
+ expect(completions?.map((c) => c.value)).toEqual(["servers", "settings"]);
68
+ });
46
69
  });
47
70
 
48
71
  describe("handleCommand", () => {
@@ -53,8 +76,11 @@ describe("CommandManager", () => {
53
76
  baseUrl: "http://127.0.0.1:8080",
54
77
  models: [model1, model2],
55
78
  });
56
- serverManager = new ServerManager([server] as any);
57
- commandManager = new CommandManager(serverManager);
79
+ const unloadSettings = makeSettingsStub({
80
+ resolveServers: vi.fn((): Server[] => [server]),
81
+ });
82
+ serverManager = new ServerManager(unloadSettings);
83
+ commandManager = new CommandManager(serverManager, unloadSettings);
58
84
 
59
85
  const ctx = {
60
86
  ui: {
@@ -80,8 +106,11 @@ describe("CommandManager", () => {
80
106
  baseUrl: "http://127.0.0.1:8080",
81
107
  models: [model1, model2],
82
108
  });
83
- serverManager = new ServerManager([server] as any);
84
- commandManager = new CommandManager(serverManager);
109
+ const infoSettings = makeSettingsStub({
110
+ resolveServers: vi.fn((): Server[] => [server]),
111
+ });
112
+ serverManager = new ServerManager(infoSettings);
113
+ commandManager = new CommandManager(serverManager, infoSettings);
85
114
 
86
115
  const ctx = {
87
116
  ui: {
@@ -97,6 +126,184 @@ describe("CommandManager", () => {
97
126
  });
98
127
  });
99
128
 
129
+ describe("settings menu helpers", () => {
130
+ it("should format milliseconds compactly", () => {
131
+ expect(formatMs(500)).toBe("500ms");
132
+ expect(formatMs(60000)).toBe("60s");
133
+ expect(formatMs(1500)).toBe("1500ms");
134
+ });
135
+
136
+ it("should build one item per editable scalar field", () => {
137
+ const items = buildSettingsItems(settingsStub);
138
+ expect(items.map((i) => i.id)).toEqual([
139
+ "reactToModelSelect",
140
+ "autoloadOnMessage",
141
+ "sortBy",
142
+ "pollingTimeout",
143
+ "serverTimeout",
144
+ ]);
145
+ // Booleans are displayed as on/off
146
+ expect(items[0].values).toEqual(["on", "off"]);
147
+ // Timeouts cycle through formatted presets
148
+ expect(items[3].values).toEqual(["15s", "30s", "60s", "120s", "300s"]);
149
+ expect(items[4].values).toEqual(["500ms", "1s", "2s", "5s", "10s"]);
150
+ });
151
+
152
+ it("should map changes to the right key and type", async () => {
153
+ const settingsStub = makeSettingsStub();
154
+
155
+ await applySettingChange("reactToModelSelect", "on", settingsStub);
156
+ expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
157
+ "reactToModelSelect",
158
+ true,
159
+ );
160
+
161
+ await applySettingChange("autoloadOnMessage", "off", settingsStub);
162
+ expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
163
+ "autoloadOnMessage",
164
+ false,
165
+ );
166
+
167
+ await applySettingChange("sortBy", "desc-name", settingsStub);
168
+ expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
169
+ "sortBy",
170
+ "desc-name",
171
+ );
172
+
173
+ await applySettingChange("pollingTimeout", "120s", settingsStub);
174
+ expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
175
+ "pollingTimeout",
176
+ 120000,
177
+ );
178
+
179
+ await applySettingChange("serverTimeout", "500ms", settingsStub);
180
+ expect(settingsStub.setLlamaSetting).toHaveBeenCalledWith(
181
+ "serverTimeout",
182
+ 500,
183
+ );
184
+ });
185
+ });
186
+
187
+ describe("/models settings menu", () => {
188
+ it("should open the settings menu without touching servers", async () => {
189
+ const updateSpy = vi
190
+ .spyOn(serverManager, "update")
191
+ .mockResolvedValue(undefined);
192
+ const ctx = createMockCtx(() => null);
193
+
194
+ await commandManager.handleCommand("settings", ctx as any, mockPi as any);
195
+
196
+ expect(updateSpy).not.toHaveBeenCalled();
197
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
198
+ expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
199
+ });
200
+
201
+ it("should notify instead of opening the menu outside the TUI", async () => {
202
+ const ctx = { ...createMockCtx(() => null), mode: "rpc" } as any;
203
+
204
+ await commandManager.handleCommand("settings", ctx, mockPi as any);
205
+
206
+ expect(ctx.ui.custom).not.toHaveBeenCalled();
207
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
208
+ "/models settings requires an interactive session (TUI)",
209
+ "warning",
210
+ );
211
+ });
212
+ });
213
+
214
+ describe("/models servers editor", () => {
215
+ const ESC = "\x1b";
216
+ const ENTER = "\r";
217
+
218
+ const createMockKeybindings = (): KeybindingsManager =>
219
+ ({
220
+ matches: vi.fn(
221
+ (data: string, name: string) =>
222
+ (data === ENTER && name === "tui.select.confirm") ||
223
+ (data === ESC && name === "tui.select.cancel"),
224
+ ),
225
+ }) as unknown as KeybindingsManager;
226
+
227
+ const createMockTheme = (): Theme =>
228
+ ({
229
+ fg: (_color: string, text: string) => text,
230
+ bold: (text: string) => text,
231
+ }) as unknown as Theme;
232
+
233
+ it("should open the editor without touching servers", async () => {
234
+ const updateSpy = vi
235
+ .spyOn(serverManager, "update")
236
+ .mockResolvedValue(undefined);
237
+ const ctx = createMockCtx(() => null);
238
+
239
+ await commandManager.handleCommand("servers", ctx as any, mockPi as any);
240
+
241
+ expect(updateSpy).not.toHaveBeenCalled();
242
+ expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
243
+ });
244
+
245
+ it("should notify instead of opening the editor outside the TUI", async () => {
246
+ const ctx = { ...createMockCtx(() => null), mode: "rpc" } as any;
247
+
248
+ await commandManager.handleCommand("servers", ctx, mockPi as any);
249
+
250
+ expect(ctx.ui.custom).not.toHaveBeenCalled();
251
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
252
+ "/models servers requires an interactive session (TUI)",
253
+ "warning",
254
+ );
255
+ });
256
+
257
+ it("should wire the editor to the merged servers and the write path", async () => {
258
+ const editorSettings = makeSettingsStub({
259
+ llamaServers: [{ url: "http://seed:1" }],
260
+ });
261
+ commandManager = new CommandManager(serverManager, editorSettings);
262
+ const ctx = createMockCtx(() => null);
263
+
264
+ await commandManager.handleCommand("servers", ctx as any, mockPi as any);
265
+
266
+ // Invoke the factory captured from ctx.ui.custom
267
+ const factory = vi.mocked(ctx.ui.custom).mock.calls[0][0] as (
268
+ tui: TUI,
269
+ theme: Theme,
270
+ kb: KeybindingsManager,
271
+ done: (result: undefined) => void,
272
+ ) => ServerListEditor;
273
+ const done = vi.fn();
274
+ const editor = factory(
275
+ { requestRender: vi.fn() } as unknown as TUI,
276
+ createMockTheme(),
277
+ {
278
+ matches: vi.fn(
279
+ (data: string, name: string) =>
280
+ (data === ENTER && name === "tui.select.confirm") ||
281
+ (data === ESC && name === "tui.select.cancel"),
282
+ ),
283
+ } as unknown as KeybindingsManager,
284
+ done,
285
+ );
286
+
287
+ expect(editor).toBeInstanceOf(ServerListEditor);
288
+ // Seeded with the merged snapshot
289
+ expect(editor.render(80).join("\n")).toContain("http://seed:1");
290
+
291
+ // Add a server through the editor → setLlamaSetting("servers", …)
292
+ editor.handleInput("a");
293
+ for (const ch of "http://new:2") editor.handleInput(ch);
294
+ editor.handleInput(ENTER);
295
+ await new Promise((resolve) => setTimeout(resolve, 0));
296
+ expect(editorSettings.setLlamaSetting).toHaveBeenCalledWith("servers", [
297
+ { url: "http://seed:1" },
298
+ { url: "http://new:2" },
299
+ ]);
300
+
301
+ // Esc closes the editor
302
+ editor.handleInput(ESC);
303
+ expect(done).toHaveBeenCalledTimes(1);
304
+ });
305
+ });
306
+
100
307
  describe("/models interactive menu", () => {
101
308
  const CHOICE = "model-a [Server: http://127.0.0.1:8080]";
102
309
 
@@ -113,10 +320,15 @@ describe("CommandManager", () => {
113
320
  models: [model],
114
321
  }),
115
322
  );
116
- const serverManager = new ServerManager(servers as any);
323
+ const settingsStub = makeSettingsStub({
324
+ resolveServers: vi.fn((): Server[] => servers),
325
+ });
326
+ const serverManager = new ServerManager(settingsStub);
117
327
  return {
118
- commandManager: new CommandManager(serverManager),
328
+ commandManager: new CommandManager(serverManager, settingsStub),
119
329
  serverManager,
330
+ settingsStub,
331
+ servers,
120
332
  mockPi,
121
333
  };
122
334
  };
@@ -198,5 +410,38 @@ describe("CommandManager", () => {
198
410
  expect(ctx.ui.select).toHaveBeenCalledTimes(3);
199
411
  expect(ctx.ui.notify).not.toHaveBeenCalled();
200
412
  });
413
+
414
+ it("should pick up servers added via the editor on the next /models scan", async () => {
415
+ const { commandManager, settingsStub, servers } = createCommandManager([
416
+ createMockModel("model-a"),
417
+ ]);
418
+
419
+ const ctx = createMockCtx(() => null);
420
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
421
+
422
+ // First scan: only model-a is offered
423
+ let choices = vi.mocked(ctx.ui.select).mock.calls[0][1] as string[];
424
+ expect(choices).toHaveLength(1);
425
+ expect(choices[0]).toContain("model-a");
426
+
427
+ // Editor persist → settings change → resolveServers now returns both
428
+ const modelB = createMockModel("model-b", {
429
+ serverUrl: "http://127.0.0.1:8081",
430
+ });
431
+ const serverB = createMockServer({
432
+ baseUrl: "http://127.0.0.1:8081",
433
+ models: [modelB],
434
+ });
435
+ vi.mocked(settingsStub.resolveServers).mockReturnValue([
436
+ ...servers,
437
+ serverB,
438
+ ]);
439
+
440
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
441
+
442
+ choices = vi.mocked(ctx.ui.select).mock.calls[1][1] as string[];
443
+ expect(choices).toHaveLength(2);
444
+ expect(choices[1]).toContain("model-b");
445
+ });
201
446
  });
202
447
  });