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.
Files changed (65) hide show
  1. package/README.md +7 -8
  2. package/package.json +2 -2
  3. package/src/api/client.ts +25 -0
  4. package/src/constants.ts +0 -5
  5. package/src/enums/status.ts +0 -1
  6. package/src/interfaces/endpoints/models.ts +1 -1
  7. package/src/interfaces/settings.ts +1 -1
  8. package/src/interfaces/sortBy.ts +4 -0
  9. package/src/managers/command/models.ts +247 -0
  10. package/src/managers/command.ts +29 -461
  11. package/src/managers/events.ts +2 -2
  12. package/src/managers/server.ts +27 -3
  13. package/src/managers/settings.ts +12 -88
  14. package/src/models/baseModel.ts +27 -10
  15. package/src/models/legacyModel.ts +2 -2
  16. package/src/models/routerModel.ts +2 -2
  17. package/src/models/singleModel.ts +1 -1
  18. package/src/server.ts +11 -29
  19. package/src/sse/client.ts +113 -59
  20. package/src/sse/fetch.ts +43 -0
  21. package/src/sse/manager.ts +9 -27
  22. package/src/sse/types.ts +0 -4
  23. package/src/ui/dialog/base.ts +118 -0
  24. package/src/ui/dialog/confirm.ts +45 -0
  25. package/src/ui/dialog/factory.ts +111 -0
  26. package/src/ui/dialog/input.ts +63 -0
  27. package/src/ui/dialog/options.ts +23 -0
  28. package/src/ui/editors/editorOptions.ts +43 -0
  29. package/src/ui/editors/itemBuilder.ts +47 -0
  30. package/src/ui/editors/listEditor.ts +291 -0
  31. package/src/ui/editors/override/entry.ts +24 -0
  32. package/src/ui/editors/override/entryEditor.ts +166 -0
  33. package/src/ui/editors/override/fields.ts +294 -0
  34. package/src/ui/editors/override/handlers.ts +118 -0
  35. package/src/ui/editors/override/itemBuilder.ts +42 -0
  36. package/src/ui/editors/override/overrideList.ts +127 -0
  37. package/src/ui/editors/server/builder.ts +60 -0
  38. package/src/ui/editors/server/fields.ts +84 -0
  39. package/src/ui/editors/server/handlers.ts +32 -0
  40. package/src/ui/editors/server/itemBuilder.ts +68 -0
  41. package/src/ui/editors/server/serverEditor.ts +161 -0
  42. package/src/ui/editors/server/utils.ts +45 -0
  43. package/src/ui/editors/server/wizard.ts +110 -0
  44. package/src/ui/editors/settingField.ts +37 -0
  45. package/src/ui/editors/settingsListFactory.ts +33 -0
  46. package/src/ui/settings/index.ts +237 -0
  47. package/src/ui/strings.ts +8 -4
  48. package/src/utils/serverIds.ts +21 -0
  49. package/src/utils/settingsStore.ts +1 -1
  50. package/src/utils/urlResolver.ts +129 -0
  51. package/src/utils/urls.ts +33 -13
  52. package/tests/commandManager.test.ts +14 -9
  53. package/tests/dialog.test.ts +55 -4
  54. package/tests/legacyModel.test.ts +34 -28
  55. package/tests/overrides.test.ts +111 -88
  56. package/tests/routerModel.test.ts +67 -68
  57. package/tests/server.test.ts +0 -12
  58. package/tests/settings.test.ts +10 -8
  59. package/tests/singleModel.test.ts +12 -12
  60. package/tests/sseManager.test.ts +6 -24
  61. package/src/ui/dialog.ts +0 -290
  62. package/src/ui/overrideEntryEditor.ts +0 -119
  63. package/src/ui/overrideSettingsList.ts +0 -710
  64. package/src/ui/serverListEditor.ts +0 -59
  65. 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
- export const fieldMessage = (field: Field, note = field.note): string =>
100
- note ? `${field.label} ${note}` : field.label;
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 core: trims surrounding whitespace and strips trailing
3
- * slashes. Used by the settings parser (`parseUrls`) and the editor
4
- * validator (`normalizeServerUrl`) so the two can't drift.
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 const normalizeUrl = (raw: string): string =>
7
- raw.trim().replace(/\/+$/, "");
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
- * True when the URL carries an http(s) scheme — the only schemes
11
- * llama-server endpoints use. Part of the shared validation applied by
12
- * both the settings parser (`parseUrls`) and the editor validator
13
- * (`normalizeServerUrl`).
14
- */
15
- export const isValidServerUrl = (url: string): boolean =>
16
- /^https?:\/\//i.test(url);
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/managers/command";
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(serverListEditor, "getServerHealthEmoji").mockResolvedValue("🟢");
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 open the editor without touching servers", async () => {
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 () => {
@@ -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, InputDialog } from "../src/ui/dialog";
9
- import { createOverrideSettingsList } from "../src/ui/overrideSettingsList";
10
- import { ServerSettingsList } from "../src/ui/serverSettingsList";
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 = createOverrideSettingsList({
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
+ });