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.
- package/README.md +187 -10
- package/package.json +6 -6
- package/src/api/client.ts +76 -32
- package/src/constants.ts +15 -0
- package/src/index.ts +3 -5
- package/src/interfaces/events.ts +14 -3
- package/src/interfaces/server.ts +27 -0
- package/src/interfaces/settings.ts +76 -2
- package/src/managers/command.ts +369 -50
- package/src/managers/events.ts +28 -10
- package/src/managers/server.ts +90 -16
- package/src/managers/settings.ts +181 -44
- package/src/models/baseModel.ts +37 -15
- package/src/models/routerModel.ts +2 -1
- package/src/server.ts +103 -28
- package/src/sse/client.ts +28 -16
- package/src/sse/manager.ts +26 -13
- package/src/ui/dialog.ts +287 -0
- package/src/ui/overrideEntryEditor.ts +119 -0
- package/src/ui/overrideSettingsList.ts +682 -0
- package/src/ui/serverListEditor.ts +32 -0
- package/src/ui/serverSettingsList.ts +466 -0
- package/src/ui/strings.ts +127 -0
- package/src/utils/errors.ts +5 -0
- package/src/utils/settingsStore.ts +56 -0
- package/src/utils/urls.ts +16 -0
- package/tests/commandManager.test.ts +346 -11
- package/tests/dialog.test.ts +186 -0
- package/tests/events.test.ts +120 -88
- package/tests/legacyModel.test.ts +4 -19
- package/tests/mocks.ts +149 -32
- package/tests/overrides.test.ts +352 -0
- package/tests/server.test.ts +42 -40
- package/tests/serverManager.test.ts +264 -55
- package/tests/settings.test.ts +654 -51
- package/tests/settingsStore.test.ts +190 -0
- package/tests/singleModel.test.ts +32 -0
- package/tests/sseManager.test.ts +88 -11
- package/src/interfaces/auth.ts +0 -6
- package/src/utils/cache.ts +0 -39
- 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 {
|
|
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
|
|
41
|
+
private apiClient: ApiClient;
|
|
27
42
|
private sse!: SSEManager;
|
|
28
43
|
|
|
29
44
|
constructor(
|
|
30
|
-
readonly
|
|
31
|
-
private readonly
|
|
32
|
-
private readonly
|
|
33
|
-
|
|
34
|
-
|
|
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 =
|
|
85
|
-
this.apiClient =
|
|
86
|
-
|
|
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.
|
|
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
|
*/
|
package/src/sse/manager.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
187
|
+
pollingTimeout,
|
|
175
188
|
);
|
|
176
189
|
|
|
177
190
|
this.subscribeToSSE(modelId, (event: SSEEvent) => {
|
package/src/ui/dialog.ts
ADDED
|
@@ -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
|
+
}
|