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
package/src/server.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  import { ApiClient } from "./api/client";
2
2
  import {
3
3
  API_KEY_PLACEHOLDER,
4
- POLLING_TIMEOUT,
5
4
  PROVIDER_NAME,
6
5
  PROVIDER_PREFIX,
7
- SERVER_TIMEOUT,
8
6
  } from "./constants";
9
7
  import { Mode } from "./enums/mode";
10
8
  import { ServerStatus } from "./enums/serverStatus";
@@ -14,25 +12,68 @@ import {
14
12
  PropsEndpoint,
15
13
  PropsModelEndpoint,
16
14
  } from "./interfaces/endpoints/props";
17
- import { settings } from "./managers/settings";
15
+ import type { ServerOptions } from "./interfaces/server";
16
+ import type { ModelOverride } from "./interfaces/settings";
17
+ import type { LlamaSettingsManager } from "./managers/settings";
18
18
  import { BaseModel } from "./models/baseModel";
19
19
  import { LegacyModel } from "./models/legacyModel";
20
20
  import { RouterModel } from "./models/routerModel";
21
21
  import { SingleModel } from "./models/singleModel";
22
22
  import { SSEManager } from "./sse/manager";
23
23
 
24
+ /**
25
+ * Optional constructor collaborators for {@link Server} — the seam tests use
26
+ * to run the real Server against fake clients.
27
+ *
28
+ * Both are factories because their arguments only exist around construction:
29
+ * the API key is (re-)resolved by the Server, and SSEManager needs its owner.
30
+ * Factories must stay pure functions of their arguments — `initialize()`
31
+ * re-invokes both on every scan (the ApiClient rebuild picks up a fresh key,
32
+ * by design), so captured per-server state would leak across re-scans.
33
+ */
34
+ export type ServerDeps = {
35
+ createApiClient?: (apiKey: string) => ApiClient;
36
+ createSSEManager?: (server: Server, apiKey: string) => SSEManager;
37
+ };
38
+
24
39
  export class Server {
25
40
  public readonly models: BaseModel[] = [];
26
- private apiClient!: ApiClient;
41
+ private apiClient: ApiClient;
27
42
  private sse!: SSEManager;
28
43
 
29
44
  constructor(
30
- readonly baseUrl: string,
31
- private readonly customId?: string,
32
- private readonly customName?: string,
33
- readonly serverTimeout: number = SERVER_TIMEOUT,
34
- readonly pollingTimeout: number = POLLING_TIMEOUT,
35
- ) {}
45
+ private readonly settings: LlamaSettingsManager,
46
+ private readonly options: ServerOptions,
47
+ private readonly deps: ServerDeps = {},
48
+ ) {
49
+ // Eager client: `isReady` may run before `initialize()` (health probing
50
+ // in ServerManager), so no lazy fallback is needed. initialize()
51
+ // rebuilds the client to re-resolve the API key.
52
+ this.apiClient =
53
+ deps.createApiClient?.(this.getApiKey()) ??
54
+ new ApiClient(options.baseUrl, this.getApiKey());
55
+ }
56
+
57
+ /** Base URL of this server endpoint. */
58
+ get baseUrl(): string {
59
+ return this.options.baseUrl;
60
+ }
61
+
62
+ /**
63
+ * Maximum time (ms) for server verification and SSE support probe.
64
+ * Resolved live from the injected settings manager.
65
+ */
66
+ async getServerTimeout(): Promise<number> {
67
+ return (await this.settings.resolveTimeouts()).serverTimeout;
68
+ }
69
+
70
+ /**
71
+ * Maximum time (ms) to wait for model loading before giving up.
72
+ * Resolved live from the injected settings manager.
73
+ */
74
+ async getPollingTimeout(): Promise<number> {
75
+ return (await this.settings.resolveTimeouts()).pollingTimeout;
76
+ }
36
77
 
37
78
  /**
38
79
  * Provides access to the SSE manager for direct subscriptions.
@@ -46,7 +87,7 @@ export class Server {
46
87
  * Uses custom ID if provided, otherwise falls back to URL-based ID.
47
88
  */
48
89
  get providerId(): string {
49
- return this.customId ?? `${PROVIDER_PREFIX}=${this.baseUrl}`;
90
+ return this.options.customId ?? `${PROVIDER_PREFIX}=${this.baseUrl}`;
50
91
  }
51
92
 
52
93
  /**
@@ -54,8 +95,8 @@ export class Server {
54
95
  * Uses custom name as suffix if provided.
55
96
  */
56
97
  get providerName(): string {
57
- if (this.customName) {
58
- return `${PROVIDER_NAME} (${this.customName})`;
98
+ if (this.options.customName) {
99
+ return `${PROVIDER_NAME} (${this.options.customName})`;
59
100
  }
60
101
  return `${PROVIDER_NAME} (${this.baseUrl})`;
61
102
  }
@@ -68,12 +109,12 @@ export class Server {
68
109
  */
69
110
  getApiKey(): string {
70
111
  // Try custom ID first
71
- if (this.customId) {
72
- const key = settings.resolveApiKey(this.customId);
112
+ if (this.options.customId) {
113
+ const key = this.settings.resolveApiKey(this.options.customId);
73
114
  if (key !== API_KEY_PLACEHOLDER) return key;
74
115
  }
75
116
  // Fall back to URL-based ID
76
- return settings.resolveApiKey(`${PROVIDER_PREFIX}=${this.baseUrl}`);
117
+ return this.settings.resolveApiKey(`${PROVIDER_PREFIX}=${this.baseUrl}`);
77
118
  }
78
119
 
79
120
  /**
@@ -81,11 +122,15 @@ export class Server {
81
122
  * Clears the cache first so we always fetch fresh data.
82
123
  */
83
124
  async initialize() {
84
- const apiKey = await this.getApiKey();
85
- this.apiClient = new ApiClient(this.baseUrl, apiKey);
86
- this.sse = new SSEManager(this.baseUrl, apiKey, this.serverTimeout);
125
+ const apiKey = this.getApiKey();
126
+ this.apiClient =
127
+ this.deps.createApiClient?.(apiKey) ??
128
+ new ApiClient(this.baseUrl, apiKey);
129
+ this.sse =
130
+ this.deps.createSSEManager?.(this, apiKey) ??
131
+ new SSEManager(this, apiKey);
87
132
  const { data } = await this.fetchModels();
88
- const mode = await this.detectServerMode();
133
+ const mode = await this.detectServerMode(data);
89
134
 
90
135
  // Setup models
91
136
  const modelCtor = {
@@ -94,22 +139,21 @@ export class Server {
94
139
  [Mode.SINGLE]: SingleModel,
95
140
  }[mode];
96
141
 
97
- const models: BaseModel[] = data
98
- .map((m) => new modelCtor(m, this))
99
- .sort((a, b) => (a.id > b.id ? 1 : a.id === b.id ? 0 : -1));
142
+ const models: BaseModel[] = data.map((m) => new modelCtor(m, this));
100
143
 
101
144
  this.models.length = 0;
102
145
  this.models.push(...models);
103
146
  }
104
147
 
105
148
  /**
106
- * Detects the mode of the server
149
+ * Detects the mode of the server from the models data already fetched by
150
+ * {@link initialize} — no second /v1/models round-trip.
107
151
  *
152
+ * @param data Models endpoint data fetched by initialize()
108
153
  * @returns The detected mode
109
154
  */
110
- private async detectServerMode(): Promise<Mode> {
155
+ private async detectServerMode(data: ModelsEndpoint["data"]): Promise<Mode> {
111
156
  const { role } = await this.fetchServerProps();
112
- const { data } = await this.fetchModels();
113
157
 
114
158
  if (role === "router") return Mode.ROUTER;
115
159
  if ("max_model_len" in data[0]) return Mode.LEGACY;
@@ -123,8 +167,6 @@ export class Server {
123
167
  * @returns The server status
124
168
  */
125
169
  async isReady(timeout: number): Promise<ServerStatus> {
126
- this.apiClient ??= new ApiClient(this.baseUrl, await this.getApiKey());
127
-
128
170
  try {
129
171
  const timeoutPromise = new Promise<never>((_, reject) =>
130
172
  setTimeout(() => reject(new Error("timeout")), timeout),
@@ -184,6 +226,39 @@ export class Server {
184
226
  );
185
227
  }
186
228
 
229
+ /**
230
+ * Returns the per-model override configuration for this server.
231
+ */
232
+ getOverrides(): Record<string, ModelOverride> {
233
+ return this.options.overrides ?? {};
234
+ }
235
+
236
+ /**
237
+ * Resolves the override for a given model ID using prefix matching.
238
+ *
239
+ * Keys in the overrides map are treated as prefix filters — a model ID
240
+ * matches if it starts with the key. When multiple keys match, the
241
+ * longest (most specific) key wins. Empty keys are ignored.
242
+ *
243
+ * @param modelId — The model ID to look up.
244
+ * @returns The matching override, or `undefined` if no key matches.
245
+ */
246
+ findOverrideForModel(modelId: string): ModelOverride | undefined {
247
+ const overrides = this.getOverrides();
248
+ let best: ModelOverride | undefined;
249
+ let bestLen = 0;
250
+
251
+ for (const [key, override] of Object.entries(overrides)) {
252
+ if (!key) continue;
253
+ if (modelId.startsWith(key) && key.length > bestLen) {
254
+ best = override;
255
+ bestLen = key.length;
256
+ }
257
+ }
258
+
259
+ return best;
260
+ }
261
+
187
262
  /**
188
263
  * Sends a request associated to a specific model from the server
189
264
  *
package/src/sse/client.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  import { POLLING_INTERVAL } from "../constants";
2
2
  import type { SSECallback, SSECleanup, SSEEvent } from "./types";
3
3
 
4
+ /**
5
+ * Builds the full SSE endpoint URL, appending the API key as a query
6
+ * parameter when one is set. Shared by {@link SSEClient} and
7
+ * {@link SSEManager.probeSSE} so the two can't drift.
8
+ */
9
+ export const buildSSEUrl = (endpoint: string, apiKey?: string): string => {
10
+ if (apiKey) {
11
+ return `${endpoint}?api_key=${encodeURIComponent(apiKey)}`;
12
+ }
13
+ return endpoint;
14
+ };
15
+
4
16
  /**
5
17
  * SSE client for llama-server's /models/sse endpoint.
6
18
  *
@@ -13,6 +25,10 @@ export class SSEClient {
13
25
  private subscribers: Map<string, SSECallback> = new Map();
14
26
  private connected: boolean = false;
15
27
  private reconnecting: boolean = false; // tracks if EventSource auto-reconnect is in progress
28
+ /**
29
+ * Single shared slot — each setOnConnectFailed call overwrites the
30
+ * previous callback (see there for the constraint this imposes).
31
+ */
16
32
  private _onConnectFailed: (() => void) | null = null;
17
33
  private _hasReceivedEvents: boolean = false;
18
34
 
@@ -28,12 +44,16 @@ export class SSEClient {
28
44
  /**
29
45
  * Connects to the SSE endpoint.
30
46
  *
47
+ * No current caller consumes the result: `subscribe()` triggers the
48
+ * connection without awaiting it, and connection failures before the
49
+ * first event are surfaced through the `setOnConnectFailed` callback.
50
+ *
31
51
  * @returns true if the connection was established successfully
32
52
  */
33
53
  async connect(): Promise<boolean> {
34
54
  if (this.connected) return true;
35
55
 
36
- const url = this.buildUrl();
56
+ const url = buildSSEUrl(this.sseEndpoint, this.apiKey);
37
57
 
38
58
  try {
39
59
  this.eventSource = new EventSource(url);
@@ -42,11 +62,6 @@ export class SSEClient {
42
62
  return false;
43
63
  }
44
64
 
45
- this.eventSource.onopen = () => {
46
- this.connected = true;
47
- this.reconnecting = false;
48
- };
49
-
50
65
  this.eventSource.onerror = () => {
51
66
  // EventSource will auto-reconnect; we just track state
52
67
  this.connected = false;
@@ -91,6 +106,13 @@ export class SSEClient {
91
106
  * Sets a callback to be called when the connection fails before
92
107
  * any event is received. Useful for rejecting promises early.
93
108
  *
109
+ * Single shared slot: each call overwrites the previous callback, so at
110
+ * most one caller may depend on it at a time. The only caller today is
111
+ * `SSEManager.subscribeToStatus`, which must therefore not be invoked
112
+ * twice concurrently on the same client — the second registration would
113
+ * take over the failure signal and the first promise would only reject
114
+ * via its own timeout.
115
+ *
94
116
  * @param callback - Called once when connection fails
95
117
  */
96
118
  setOnConnectFailed(callback: () => void): void {
@@ -129,16 +151,6 @@ export class SSEClient {
129
151
  this.subscribers.clear();
130
152
  }
131
153
 
132
- /**
133
- * Builds the full URL with optional API key query param.
134
- */
135
- private buildUrl(): string {
136
- if (this.apiKey) {
137
- return `${this.sseEndpoint}?api_key=${encodeURIComponent(this.apiKey)}`;
138
- }
139
- return this.sseEndpoint;
140
- }
141
-
142
154
  /**
143
155
  * Dispatches an SSE event to all matching subscribers.
144
156
  */
@@ -1,5 +1,5 @@
1
- import { POLLING_TIMEOUT, SERVER_TIMEOUT } from "../constants";
2
- import { SSEClient } from "./client";
1
+ import type { Server } from "../server";
2
+ import { SSEClient, buildSSEUrl } from "./client";
3
3
  import {
4
4
  DownloadProgressData,
5
5
  ProgressData,
@@ -25,16 +25,31 @@ export class SSEManager {
25
25
  private sseSupported: boolean | null = null;
26
26
 
27
27
  constructor(
28
- private readonly baseUrl: string,
28
+ private readonly server: Server,
29
29
  private readonly apiKey: string,
30
- readonly serverTimeout: number = SERVER_TIMEOUT,
31
30
  ) {}
32
31
 
32
+ /**
33
+ * Maximum time (ms) for server verification and SSE support probe.
34
+ * Delegates to the owning {@link Server}.
35
+ */
36
+ async getServerTimeout(): Promise<number> {
37
+ return this.server.getServerTimeout();
38
+ }
39
+
40
+ /**
41
+ * Maximum time (ms) to wait for model loading before giving up.
42
+ * Delegates to the owning {@link Server}.
43
+ */
44
+ async getPollingTimeout(): Promise<number> {
45
+ return this.server.getPollingTimeout();
46
+ }
47
+
33
48
  /**
34
49
  * The SSE endpoint URL.
35
50
  */
36
51
  private get sseEndpoint(): string {
37
- return `${this.baseUrl}/models/sse`;
52
+ return `${this.server.baseUrl}/models/sse`;
38
53
  }
39
54
 
40
55
  /**
@@ -47,13 +62,10 @@ export class SSEManager {
47
62
  if (this.sseSupported !== null) return this.sseSupported;
48
63
 
49
64
  try {
50
- let url = this.sseEndpoint;
51
- if (this.apiKey) {
52
- url = `${url}?api_key=${encodeURIComponent(this.apiKey)}`;
53
- }
65
+ const url = buildSSEUrl(this.sseEndpoint, this.apiKey);
54
66
  const response = await fetch(url, {
55
67
  method: "GET",
56
- signal: AbortSignal.timeout(this.serverTimeout),
68
+ signal: AbortSignal.timeout(await this.getServerTimeout()),
57
69
  });
58
70
  this.sseSupported =
59
71
  response.ok &&
@@ -167,11 +179,12 @@ export class SSEManager {
167
179
  * @param modelId - The model ID to subscribe to
168
180
  * @returns Promise that resolves with the final status string
169
181
  */
170
- subscribeToStatus(modelId: string): Promise<StatusChangeData> {
171
- return new Promise((resolve, reject) => {
182
+ async subscribeToStatus(modelId: string): Promise<StatusChangeData> {
183
+ return new Promise(async (resolve, reject) => {
184
+ const pollingTimeout = await this.getPollingTimeout();
172
185
  const timeout = setTimeout(
173
186
  () => reject(new Error(`SSE status timeout for model: ${modelId}`)),
174
- POLLING_TIMEOUT,
187
+ pollingTimeout,
175
188
  );
176
189
 
177
190
  this.subscribeToSSE(modelId, (event: SSEEvent) => {
@@ -0,0 +1,287 @@
1
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ Component,
4
+ Keybinding,
5
+ SelectListTheme,
6
+ TUI,
7
+ } from "@earendil-works/pi-tui";
8
+ import {
9
+ Container,
10
+ getKeybindings,
11
+ Input,
12
+ SelectList,
13
+ Spacer,
14
+ Text,
15
+ } from "@earendil-works/pi-tui";
16
+
17
+ /**
18
+ * Base dialog class encapsulating shared UI utilities (frame, hints,
19
+ * themes) so concrete dialogs don't duplicate code.
20
+ */
21
+ abstract class BaseDialog {
22
+ protected theme: Theme;
23
+ protected tui: TUI;
24
+
25
+ constructor(theme: Theme, tui: TUI) {
26
+ this.theme = theme;
27
+ this.tui = tui;
28
+ }
29
+
30
+ // -- shared utilities ------------------------------------------------------
31
+
32
+ protected selectListTheme(): SelectListTheme {
33
+ return {
34
+ selectedPrefix: (text) => this.theme.fg("accent", text),
35
+ selectedText: (text) => this.theme.fg("accent", text),
36
+ description: (text) => this.theme.fg("muted", text),
37
+ scrollInfo: (text) => this.theme.fg("dim", text),
38
+ noMatch: (text) => this.theme.fg("warning", text),
39
+ };
40
+ }
41
+
42
+ protected hint(action: Keybinding, description: string): string {
43
+ const keys = getKeybindings().getKeys(action).join("/");
44
+ return (
45
+ this.theme.fg("dim", keys) + this.theme.fg("muted", ` ${description}`)
46
+ );
47
+ }
48
+
49
+ protected inputFooter(): string {
50
+ return `${this.hint("tui.select.confirm", "save")} • ${this.hint("tui.select.cancel", "cancel")}`;
51
+ }
52
+
53
+ protected selectFooter(): string {
54
+ return `${this.hint("tui.select.confirm", "select")} • ${this.hint("tui.select.cancel", "cancel")}`;
55
+ }
56
+
57
+ protected createFrame(
58
+ title: string,
59
+ body: Component[],
60
+ footer?: string,
61
+ ): Container {
62
+ const container = new Container();
63
+ container.addChild(
64
+ new DynamicBorder((text) => this.theme.fg("accent", text)),
65
+ );
66
+ container.addChild(
67
+ new Text(this.theme.fg("accent", this.theme.bold(title)), 1, 0),
68
+ );
69
+ for (const child of body) container.addChild(child);
70
+ if (footer) {
71
+ container.addChild(new Spacer(1));
72
+ container.addChild(new Text(this.theme.fg("dim", footer), 1, 0));
73
+ }
74
+ container.addChild(
75
+ new DynamicBorder((text) => this.theme.fg("accent", text)),
76
+ );
77
+ return container;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Shared `SettingItem.submenu` factory for the text fields of both editors.
83
+ * Opens an `InputDialog` pre-filled with `initialValue`; Enter commits the
84
+ * validated value via `done(value)`, Esc cancels via `done(undefined)`.
85
+ */
86
+ export class InputDialog extends BaseDialog {
87
+ private readonly options: InputDialogOptions;
88
+ private readonly body = new Container();
89
+ private readonly container: Container;
90
+ private readonly input = new Input();
91
+ private errorText: Text | undefined;
92
+
93
+ constructor(options: InputDialogOptions) {
94
+ super(options.theme, options.tui);
95
+ this.options = options;
96
+
97
+ const initial = options.initialValue ?? "";
98
+ this.input.setValue(initial);
99
+ for (let i = 0; i < [...initial].length; i++) {
100
+ this.input.handleInput("\x1b[C");
101
+ }
102
+ this.input.onSubmit = (value) => this.submit(value);
103
+ this.input.onEscape = () => options.onCancel();
104
+
105
+ this.body.addChild(new Text(this.theme.fg("text", options.message), 1, 0));
106
+ if (options.placeholder) {
107
+ this.body.addChild(
108
+ new Text(this.theme.fg("dim", `e.g., ${options.placeholder}`), 1, 0),
109
+ );
110
+ }
111
+ this.body.addChild(this.input);
112
+
113
+ this.container = this.createFrame(
114
+ options.title,
115
+ [this.body],
116
+ this.inputFooter(),
117
+ );
118
+ }
119
+
120
+ // -- Component -------------------------------------------------------------
121
+
122
+ invalidate(): void {
123
+ this.container.invalidate();
124
+ }
125
+
126
+ handleInput(data: string): void {
127
+ this.input.handleInput(data);
128
+ this.tui.requestRender();
129
+ }
130
+
131
+ render(width: number): string[] {
132
+ return this.container.render(width);
133
+ }
134
+
135
+ // -- Focusable ---------------------------------------------------------------
136
+
137
+ get focused(): boolean {
138
+ return this.input.focused;
139
+ }
140
+
141
+ set focused(value: boolean) {
142
+ this.input.focused = value;
143
+ }
144
+
145
+ // -- helpers -------------------------------------------------------------------
146
+
147
+ private submit(raw: string): void {
148
+ const validated = this.options.validate ? this.options.validate(raw) : raw;
149
+ if (validated === null) {
150
+ this.setError(`Invalid value "${raw}"`);
151
+ return;
152
+ }
153
+ this.options.onSubmit(validated);
154
+ }
155
+
156
+ private setError(message: string | undefined): void {
157
+ if (this.errorText) {
158
+ this.body.removeChild(this.errorText);
159
+ this.errorText = undefined;
160
+ }
161
+ if (message) {
162
+ this.errorText = new Text(this.theme.fg("error", message), 1, 0);
163
+ this.body.addChild(this.errorText);
164
+ }
165
+ this.tui.requestRender();
166
+ }
167
+
168
+ /**
169
+ * Builds a `SettingItem.submenu` factory that opens an `InputDialog`.
170
+ */
171
+ static inputSubmenu =
172
+ (
173
+ theme: Theme,
174
+ tui: TUI,
175
+ title: string,
176
+ message: string,
177
+ placeholder: string | undefined,
178
+ initialValue: string,
179
+ validate?: (raw: string) => string | null,
180
+ ) =>
181
+ (
182
+ _currentValue: string,
183
+ done: (selectedValue?: string) => void,
184
+ ): Component => {
185
+ const dialog = new InputDialog({
186
+ theme,
187
+ tui,
188
+ title,
189
+ message,
190
+ placeholder,
191
+ initialValue,
192
+ validate,
193
+ onSubmit: (value) => done(value),
194
+ onCancel: () => done(undefined),
195
+ });
196
+ dialog.focused = true;
197
+ return dialog;
198
+ };
199
+ }
200
+
201
+ export interface InputDialogOptions {
202
+ theme: Theme;
203
+ tui: TUI;
204
+ title: string;
205
+ message: string;
206
+ placeholder?: string;
207
+ initialValue?: string;
208
+ validate?: (raw: string) => string | null;
209
+ onSubmit: (value: string) => void;
210
+ onCancel: () => void;
211
+ }
212
+
213
+ /**
214
+ * A framed confirmation dialog with a two-option `SelectList`
215
+ * (Delete / Cancel), matching the confirm pattern of pi's `/llama` view.
216
+ */
217
+ export class ConfirmDialog extends BaseDialog {
218
+ private readonly options: ConfirmDialogOptions;
219
+ private readonly container: Container;
220
+ private readonly list: SelectList;
221
+ private isFocused = false;
222
+
223
+ constructor(options: ConfirmDialogOptions) {
224
+ super(options.theme, options.tui);
225
+ this.options = options;
226
+
227
+ this.list = new SelectList(
228
+ [
229
+ { value: "confirm", label: options.confirmLabel ?? "Delete" },
230
+ { value: "cancel", label: options.cancelLabel ?? "Cancel" },
231
+ ],
232
+ 2,
233
+ this.selectListTheme(),
234
+ );
235
+ this.list.onSelect = (item) => {
236
+ if (item.value === "confirm") options.onConfirm();
237
+ else options.onCancel();
238
+ };
239
+ this.list.onCancel = () => options.onCancel();
240
+
241
+ this.container = this.createFrame(
242
+ options.title,
243
+ [
244
+ new Text(this.theme.fg("text", options.message), 1, 0),
245
+ new Spacer(1),
246
+ this.list,
247
+ ],
248
+ this.selectFooter(),
249
+ );
250
+ }
251
+
252
+ // -- Component -------------------------------------------------------------
253
+
254
+ invalidate(): void {
255
+ this.container.invalidate();
256
+ }
257
+
258
+ handleInput(data: string): void {
259
+ this.list.handleInput(data);
260
+ this.tui.requestRender();
261
+ }
262
+
263
+ render(width: number): string[] {
264
+ return this.container.render(width);
265
+ }
266
+
267
+ // -- Focusable ---------------------------------------------------------------
268
+
269
+ get focused(): boolean {
270
+ return this.isFocused;
271
+ }
272
+
273
+ set focused(value: boolean) {
274
+ this.isFocused = value;
275
+ }
276
+ }
277
+
278
+ export interface ConfirmDialogOptions {
279
+ theme: Theme;
280
+ tui: TUI;
281
+ title: string;
282
+ message: string;
283
+ confirmLabel?: string;
284
+ cancelLabel?: string;
285
+ onConfirm: () => void;
286
+ onCancel: () => void;
287
+ }