pi-llama-cpp 0.12.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.
- package/README.md +20 -9
- package/package.json +3 -3
- package/src/api/client.ts +25 -0
- package/src/constants.ts +8 -5
- package/src/enums/status.ts +0 -1
- package/src/interfaces/endpoints/models.ts +1 -1
- package/src/interfaces/settings.ts +3 -2
- package/src/interfaces/sortBy.ts +4 -0
- package/src/managers/command/models.ts +247 -0
- package/src/managers/command.ts +29 -459
- package/src/managers/events.ts +2 -2
- package/src/managers/server.ts +29 -5
- package/src/managers/settings.ts +12 -88
- package/src/models/baseModel.ts +27 -10
- package/src/models/legacyModel.ts +2 -2
- package/src/models/routerModel.ts +2 -2
- package/src/models/singleModel.ts +1 -1
- package/src/server.ts +35 -48
- package/src/sse/client.ts +113 -59
- package/src/sse/fetch.ts +43 -0
- package/src/sse/manager.ts +9 -27
- package/src/sse/types.ts +0 -4
- package/src/ui/dialog/base.ts +118 -0
- package/src/ui/dialog/confirm.ts +45 -0
- package/src/ui/dialog/factory.ts +111 -0
- package/src/ui/dialog/input.ts +63 -0
- package/src/ui/dialog/options.ts +23 -0
- package/src/ui/editors/editorOptions.ts +43 -0
- package/src/ui/editors/itemBuilder.ts +47 -0
- package/src/ui/editors/listEditor.ts +291 -0
- package/src/ui/editors/override/entry.ts +24 -0
- package/src/ui/editors/override/entryEditor.ts +166 -0
- package/src/ui/editors/override/fields.ts +294 -0
- package/src/ui/editors/override/handlers.ts +118 -0
- package/src/ui/editors/override/itemBuilder.ts +42 -0
- package/src/ui/editors/override/overrideList.ts +127 -0
- package/src/ui/editors/server/builder.ts +60 -0
- package/src/ui/editors/server/fields.ts +84 -0
- package/src/ui/editors/server/handlers.ts +32 -0
- package/src/ui/editors/server/itemBuilder.ts +68 -0
- package/src/ui/editors/server/serverEditor.ts +161 -0
- package/src/ui/editors/server/utils.ts +45 -0
- package/src/ui/editors/server/wizard.ts +110 -0
- package/src/ui/editors/settingField.ts +37 -0
- package/src/ui/editors/settingsListFactory.ts +33 -0
- package/src/ui/settings/index.ts +237 -0
- package/src/ui/strings.ts +8 -4
- package/src/utils/health.ts +48 -0
- package/src/utils/serverIds.ts +21 -0
- package/src/utils/settingsStore.ts +1 -1
- package/src/utils/urlResolver.ts +129 -0
- package/src/utils/urls.ts +33 -13
- package/tests/commandManager.test.ts +38 -7
- package/tests/dialog.test.ts +95 -2
- package/tests/health.test.ts +116 -0
- package/tests/legacyModel.test.ts +34 -28
- package/tests/overrides.test.ts +123 -68
- package/tests/routerModel.test.ts +67 -68
- package/tests/server.test.ts +47 -16
- package/tests/serverManager.test.ts +4 -4
- package/tests/settings.test.ts +10 -8
- package/tests/singleModel.test.ts +12 -12
- package/tests/sseManager.test.ts +6 -24
- package/src/ui/dialog.ts +0 -287
- package/src/ui/overrideEntryEditor.ts +0 -119
- package/src/ui/overrideSettingsList.ts +0 -682
- package/src/ui/serverListEditor.ts +0 -32
- package/src/ui/serverSettingsList.ts +0 -466
package/src/sse/fetch.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth and stream plumbing for llama-server's SSE endpoint.
|
|
3
|
+
*
|
|
4
|
+
* llama-server validates the API key from the `Authorization` (or
|
|
5
|
+
* `X-Api-Key`) header only — it no longer accepts it as a query parameter —
|
|
6
|
+
* and `EventSource` cannot send custom headers. The stream is therefore
|
|
7
|
+
* consumed with `fetch` and parsed manually by {@link SSEClient}.
|
|
8
|
+
*
|
|
9
|
+
* `buildAuthHeaders` is shared by {@link SSEClient} and
|
|
10
|
+
* {@link SSEManager.probeSSE} so the two can't drift.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Builds the auth headers for a llama-server request, including the API key
|
|
15
|
+
* as an `Authorization: Bearer` header when one is set.
|
|
16
|
+
*/
|
|
17
|
+
export const buildAuthHeaders = (apiKey?: string): Record<string, string> =>
|
|
18
|
+
apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Opens the SSE stream at the given endpoint, authenticating via headers.
|
|
22
|
+
*
|
|
23
|
+
* @returns the response body as a stream of raw bytes
|
|
24
|
+
* @throws if the request fails or the server responds with an error status
|
|
25
|
+
*/
|
|
26
|
+
export const openSSEStream = async (
|
|
27
|
+
endpoint: string,
|
|
28
|
+
apiKey?: string,
|
|
29
|
+
signal?: AbortSignal,
|
|
30
|
+
): Promise<ReadableStream<Uint8Array>> => {
|
|
31
|
+
const response = await fetch(endpoint, {
|
|
32
|
+
method: "GET",
|
|
33
|
+
headers: buildAuthHeaders(apiKey),
|
|
34
|
+
signal,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!response.ok || !response.body) {
|
|
38
|
+
await response.body?.cancel();
|
|
39
|
+
throw new Error(`SSE connection failed: HTTP ${response.status}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return response.body;
|
|
43
|
+
};
|
package/src/sse/manager.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Server } from "../server";
|
|
2
|
-
import { SSEClient
|
|
2
|
+
import { SSEClient } from "./client";
|
|
3
|
+
import { buildAuthHeaders } from "./fetch";
|
|
3
4
|
import {
|
|
4
5
|
DownloadProgressData,
|
|
5
6
|
ProgressData,
|
|
@@ -14,7 +15,7 @@ import {
|
|
|
14
15
|
* Manages SSE connections and event routing for a single llama-server instance.
|
|
15
16
|
*
|
|
16
17
|
* Handles:
|
|
17
|
-
* - Shared
|
|
18
|
+
* - Shared SSE connection
|
|
18
19
|
* - Model-based event subscription with callback aggregation
|
|
19
20
|
* - Progress parsing and callback dispatch
|
|
20
21
|
*/
|
|
@@ -24,26 +25,7 @@ export class SSEManager {
|
|
|
24
25
|
private modelCallbacks: Map<string, SSECallback[]> = new Map();
|
|
25
26
|
private sseSupported: boolean | null = null;
|
|
26
27
|
|
|
27
|
-
constructor(
|
|
28
|
-
private readonly server: Server,
|
|
29
|
-
private readonly apiKey: string,
|
|
30
|
-
) {}
|
|
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
|
-
}
|
|
28
|
+
constructor(private readonly server: Server) {}
|
|
47
29
|
|
|
48
30
|
/**
|
|
49
31
|
* The SSE endpoint URL.
|
|
@@ -62,10 +44,10 @@ export class SSEManager {
|
|
|
62
44
|
if (this.sseSupported !== null) return this.sseSupported;
|
|
63
45
|
|
|
64
46
|
try {
|
|
65
|
-
const
|
|
66
|
-
const response = await fetch(url, {
|
|
47
|
+
const response = await fetch(this.sseEndpoint, {
|
|
67
48
|
method: "GET",
|
|
68
|
-
|
|
49
|
+
headers: buildAuthHeaders(this.server.getApiKey()),
|
|
50
|
+
signal: AbortSignal.timeout(await this.server.getServerTimeout()),
|
|
69
51
|
});
|
|
70
52
|
this.sseSupported =
|
|
71
53
|
response.ok &&
|
|
@@ -96,7 +78,7 @@ export class SSEManager {
|
|
|
96
78
|
this.modelCallbacks.set(modelId, callbacks);
|
|
97
79
|
|
|
98
80
|
// Create SSE client if not already created
|
|
99
|
-
this.sseClient ??= new SSEClient(this.sseEndpoint, this.
|
|
81
|
+
this.sseClient ??= new SSEClient(this.sseEndpoint, this.server.getApiKey());
|
|
100
82
|
|
|
101
83
|
// Subscribe a single dispatching callback to the SSE client
|
|
102
84
|
if (!this.sseSubscribers.has(modelId)) {
|
|
@@ -181,7 +163,7 @@ export class SSEManager {
|
|
|
181
163
|
*/
|
|
182
164
|
async subscribeToStatus(modelId: string): Promise<StatusChangeData> {
|
|
183
165
|
return new Promise(async (resolve, reject) => {
|
|
184
|
-
const pollingTimeout = await this.getPollingTimeout();
|
|
166
|
+
const pollingTimeout = await this.server.getPollingTimeout();
|
|
185
167
|
const timeout = setTimeout(
|
|
186
168
|
() => reject(new Error(`SSE status timeout for model: ${modelId}`)),
|
|
187
169
|
pollingTimeout,
|
package/src/sse/types.ts
CHANGED
|
@@ -8,10 +8,6 @@
|
|
|
8
8
|
export const SSEEventType = {
|
|
9
9
|
status_change: "status_change",
|
|
10
10
|
download_progress: "download_progress",
|
|
11
|
-
download_finished: "download_finished",
|
|
12
|
-
download_failed: "download_failed",
|
|
13
|
-
models_reload: "models_reload",
|
|
14
|
-
model_remove: "model_remove",
|
|
15
11
|
} as const;
|
|
16
12
|
|
|
17
13
|
export type SSEEventType = (typeof SSEEventType)[keyof typeof SSEEventType];
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
Spacer,
|
|
12
|
+
Text,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Base dialog class encapsulating the shared Component plumbing (framed
|
|
17
|
+
* container, input delegation, themes) so concrete dialogs only declare
|
|
18
|
+
* their body and their input target — they don't repeat the
|
|
19
|
+
* `invalidate`/`render`/`handleInput` delegation code.
|
|
20
|
+
*/
|
|
21
|
+
export abstract class BaseDialog {
|
|
22
|
+
protected theme: Theme;
|
|
23
|
+
protected tui: TUI;
|
|
24
|
+
/** The framed container every concrete dialog renders into. */
|
|
25
|
+
private readonly frame = new Container();
|
|
26
|
+
|
|
27
|
+
constructor(theme: Theme, tui: TUI) {
|
|
28
|
+
this.theme = theme;
|
|
29
|
+
this.tui = tui;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// -- Component -------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
invalidate(): void {
|
|
35
|
+
this.frame.invalidate();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Routes input to the concrete dialog's interactive child. */
|
|
39
|
+
handleInput(data: string): void {
|
|
40
|
+
this.inputTarget.handleInput(data);
|
|
41
|
+
this.tui.requestRender();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
render(width: number): string[] {
|
|
45
|
+
return this.frame.render(width);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// -- Focusable -------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
private isFocused = false;
|
|
51
|
+
|
|
52
|
+
get focused(): boolean {
|
|
53
|
+
return this.isFocused;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
set focused(value: boolean) {
|
|
57
|
+
this.isFocused = value;
|
|
58
|
+
// Forward to the interactive child when it manages focus (e.g. the
|
|
59
|
+
// text `Input`); plain select lists don't have a `focused` flag.
|
|
60
|
+
const target = this.inputTarget as { focused?: boolean };
|
|
61
|
+
if ("focused" in target) target.focused = value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// -- shared utilities ------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** The component keystrokes are routed to (e.g. the text input or the
|
|
67
|
+
* option list). */
|
|
68
|
+
protected abstract get inputTarget(): {
|
|
69
|
+
handleInput(data: string): void;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Builds the framed chrome (borders, title, footer) around `body`.
|
|
74
|
+
* Concrete dialogs call this once from their constructor, after
|
|
75
|
+
* assembling the body.
|
|
76
|
+
*/
|
|
77
|
+
protected setFrame(title: string, body: Component[], footer?: string): void {
|
|
78
|
+
this.frame.addChild(
|
|
79
|
+
new DynamicBorder((text) => this.theme.fg("accent", text)),
|
|
80
|
+
);
|
|
81
|
+
this.frame.addChild(
|
|
82
|
+
new Text(this.theme.fg("accent", this.theme.bold(title)), 1, 0),
|
|
83
|
+
);
|
|
84
|
+
for (const child of body) this.frame.addChild(child);
|
|
85
|
+
if (footer) {
|
|
86
|
+
this.frame.addChild(new Spacer(1));
|
|
87
|
+
this.frame.addChild(new Text(this.theme.fg("dim", footer), 1, 0));
|
|
88
|
+
}
|
|
89
|
+
this.frame.addChild(
|
|
90
|
+
new DynamicBorder((text) => this.theme.fg("accent", text)),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
protected selectListTheme(): SelectListTheme {
|
|
95
|
+
return {
|
|
96
|
+
selectedPrefix: (text) => this.theme.fg("accent", text),
|
|
97
|
+
selectedText: (text) => this.theme.fg("accent", text),
|
|
98
|
+
description: (text) => this.theme.fg("muted", text),
|
|
99
|
+
scrollInfo: (text) => this.theme.fg("dim", text),
|
|
100
|
+
noMatch: (text) => this.theme.fg("warning", text),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
protected hint(action: Keybinding, description: string): string {
|
|
105
|
+
const keys = getKeybindings().getKeys(action).join("/");
|
|
106
|
+
return (
|
|
107
|
+
this.theme.fg("dim", keys) + this.theme.fg("muted", ` ${description}`)
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
protected inputFooter(): string {
|
|
112
|
+
return `${this.hint("tui.select.confirm", "save")} • ${this.hint("tui.select.cancel", "cancel")}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
protected selectFooter(): string {
|
|
116
|
+
return `${this.hint("tui.select.confirm", "select")} • ${this.hint("tui.select.cancel", "cancel")}`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { SelectList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { BaseDialog } from "./base";
|
|
3
|
+
import type { ConfirmDialogOptions } from "./options";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A framed confirmation dialog with a two-option `SelectList`
|
|
7
|
+
* (Delete / Cancel), matching the confirm pattern of pi's `/llama` view.
|
|
8
|
+
*/
|
|
9
|
+
export class ConfirmDialog extends BaseDialog {
|
|
10
|
+
private readonly list: SelectList;
|
|
11
|
+
|
|
12
|
+
constructor(options: ConfirmDialogOptions) {
|
|
13
|
+
super(options.theme, options.tui);
|
|
14
|
+
|
|
15
|
+
this.list = new SelectList(
|
|
16
|
+
[
|
|
17
|
+
{ value: "confirm", label: "Delete" },
|
|
18
|
+
{ value: "cancel", label: "Cancel" },
|
|
19
|
+
],
|
|
20
|
+
2,
|
|
21
|
+
this.selectListTheme(),
|
|
22
|
+
);
|
|
23
|
+
this.list.onSelect = (item) => {
|
|
24
|
+
if (item.value === "confirm") options.onConfirm();
|
|
25
|
+
else options.onCancel();
|
|
26
|
+
};
|
|
27
|
+
this.list.onCancel = () => options.onCancel();
|
|
28
|
+
|
|
29
|
+
this.setFrame(
|
|
30
|
+
options.title,
|
|
31
|
+
[
|
|
32
|
+
new Text(this.theme.fg("text", options.message), 1, 0),
|
|
33
|
+
new Spacer(1),
|
|
34
|
+
this.list,
|
|
35
|
+
],
|
|
36
|
+
this.selectFooter(),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// -- helpers -------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
protected get inputTarget() {
|
|
43
|
+
return this.list;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SettingItem, TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { ConfirmDialog } from "./confirm";
|
|
4
|
+
import { InputDialog } from "./input";
|
|
5
|
+
import type { ConfirmDialogOptions, InputDialogOptions } from "./options";
|
|
6
|
+
|
|
7
|
+
/** `InputDialog` options minus the deps the factory injects. */
|
|
8
|
+
type InputOptions = Omit<InputDialogOptions, "theme" | "tui">;
|
|
9
|
+
|
|
10
|
+
/** `ConfirmDialog` options minus the deps the factory injects. */
|
|
11
|
+
type ConfirmOptions = Omit<ConfirmDialogOptions, "theme" | "tui">;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Single factory for constructing dialogs. Holds `theme` and `tui` once
|
|
15
|
+
* in the constructor so callers never thread the `{ theme, tui }` deps
|
|
16
|
+
* tuple by hand, and so all dialog construction idioms (raw constructors,
|
|
17
|
+
* static factories, per-field submenu builders) live in one place.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* ```ts
|
|
21
|
+
* const dialogs = new DialogFactory(theme, tui);
|
|
22
|
+
* dialogs.input({ title, message, onSubmit, onCancel });
|
|
23
|
+
* dialogs.confirmDelete(TITLES.deleteServer, name, onConfirm, onCancel);
|
|
24
|
+
* dialogs.inputSubmenu(TITLES.edit(label), message, placeholder, validate);
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export class DialogFactory {
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly theme: Theme,
|
|
30
|
+
/** Used internally to request re-renders after async dialog commits
|
|
31
|
+
* (e.g. the wizard). */
|
|
32
|
+
private readonly tui: TUI,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
/** Requests a UI re-render. Used by callers that need to update the
|
|
36
|
+
* display asynchronously after a dialog commit. */
|
|
37
|
+
requestRender(): void {
|
|
38
|
+
this.tui.requestRender();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Creates a framed text-input dialog: Enter commits, Esc cancels. */
|
|
42
|
+
input(options: InputOptions): InputDialog {
|
|
43
|
+
return new InputDialog({ ...options, theme: this.theme, tui: this.tui });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Creates a framed confirmation dialog with a two-option select list. */
|
|
47
|
+
confirm(options: ConfirmOptions): ConfirmDialog {
|
|
48
|
+
return new ConfirmDialog({
|
|
49
|
+
...options,
|
|
50
|
+
theme: this.theme,
|
|
51
|
+
tui: this.tui,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates a delete confirmation dialog.
|
|
57
|
+
*
|
|
58
|
+
* @param title — Dialog title (e.g. `TITLES.deleteServer` or `TITLES.deleteOverride`).
|
|
59
|
+
* @param name — The name of the entry being deleted.
|
|
60
|
+
* @param onConfirm — Callback when the user confirms.
|
|
61
|
+
* @param onCancel — Callback when the user cancels.
|
|
62
|
+
*/
|
|
63
|
+
confirmDelete(
|
|
64
|
+
title: string,
|
|
65
|
+
name: string,
|
|
66
|
+
onConfirm: () => void,
|
|
67
|
+
onCancel: () => void,
|
|
68
|
+
): ConfirmDialog {
|
|
69
|
+
return this.confirm({
|
|
70
|
+
title,
|
|
71
|
+
message: `Delete "${name}"?`,
|
|
72
|
+
onConfirm,
|
|
73
|
+
onCancel,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Builds a `SettingItem.submenu` factory that opens an `InputDialog`
|
|
79
|
+
* (absorbs the former `InputSubmenuFactory`).
|
|
80
|
+
*
|
|
81
|
+
* The produced submenu prefills from the `currentValue` the containing
|
|
82
|
+
* `SettingsList` passes at activation time — not a value captured at
|
|
83
|
+
* build time — so re-entering a field after a commit shows the
|
|
84
|
+
* just-saved value.
|
|
85
|
+
*
|
|
86
|
+
* @param title — Dialog title (e.g. `TITLES.edit(field.label)`).
|
|
87
|
+
* @param message — Prompt shown inside the dialog.
|
|
88
|
+
* @param placeholder — Optional example value shown dim in the dialog.
|
|
89
|
+
* @param validate — Optional validation function for committed values.
|
|
90
|
+
*/
|
|
91
|
+
inputSubmenu(
|
|
92
|
+
title: string,
|
|
93
|
+
message: string,
|
|
94
|
+
placeholder?: string,
|
|
95
|
+
validate?: (raw: string) => string | null,
|
|
96
|
+
): SettingItem["submenu"] {
|
|
97
|
+
return (currentValue, done) => {
|
|
98
|
+
const dialog = this.input({
|
|
99
|
+
title,
|
|
100
|
+
message,
|
|
101
|
+
placeholder,
|
|
102
|
+
initialValue: currentValue,
|
|
103
|
+
validate,
|
|
104
|
+
onSubmit: (value) => done(value),
|
|
105
|
+
onCancel: () => done(undefined),
|
|
106
|
+
});
|
|
107
|
+
dialog.focused = true;
|
|
108
|
+
return dialog;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Container, Input, Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { BaseDialog } from "./base";
|
|
3
|
+
import type { InputDialogOptions } from "./options";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Framed text-input dialog: Enter commits the validated value, Esc cancels.
|
|
7
|
+
*/
|
|
8
|
+
export class InputDialog extends BaseDialog {
|
|
9
|
+
private readonly options: InputDialogOptions;
|
|
10
|
+
private readonly body = new Container();
|
|
11
|
+
private readonly input = new Input();
|
|
12
|
+
private errorText: Text | undefined;
|
|
13
|
+
|
|
14
|
+
constructor(options: InputDialogOptions) {
|
|
15
|
+
super(options.theme, options.tui);
|
|
16
|
+
this.options = options;
|
|
17
|
+
|
|
18
|
+
const initial = options.initialValue ?? "";
|
|
19
|
+
this.input.setValue(initial);
|
|
20
|
+
for (let i = 0; i < [...initial].length; i++) {
|
|
21
|
+
this.input.handleInput("\x1b[C");
|
|
22
|
+
}
|
|
23
|
+
this.input.onSubmit = (value) => this.submit(value);
|
|
24
|
+
this.input.onEscape = () => options.onCancel();
|
|
25
|
+
|
|
26
|
+
this.body.addChild(new Text(this.theme.fg("text", options.message), 1, 0));
|
|
27
|
+
if (options.placeholder) {
|
|
28
|
+
this.body.addChild(
|
|
29
|
+
new Text(this.theme.fg("dim", `e.g., ${options.placeholder}`), 1, 0),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
this.body.addChild(this.input);
|
|
33
|
+
|
|
34
|
+
this.setFrame(options.title, [this.body], this.inputFooter());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// -- helpers -------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
protected get inputTarget() {
|
|
40
|
+
return this.input;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private submit(raw: string): void {
|
|
44
|
+
const validated = this.options.validate ? this.options.validate(raw) : raw;
|
|
45
|
+
if (validated === null) {
|
|
46
|
+
this.setError(`Invalid value "${raw}"`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.options.onSubmit(validated);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private setError(message: string | undefined): void {
|
|
53
|
+
if (this.errorText) {
|
|
54
|
+
this.body.removeChild(this.errorText);
|
|
55
|
+
this.errorText = undefined;
|
|
56
|
+
}
|
|
57
|
+
if (message) {
|
|
58
|
+
this.errorText = new Text(this.theme.fg("error", message), 1, 0);
|
|
59
|
+
this.body.addChild(this.errorText);
|
|
60
|
+
}
|
|
61
|
+
this.tui.requestRender();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export interface ConfirmDialogOptions {
|
|
5
|
+
theme: Theme;
|
|
6
|
+
tui: TUI;
|
|
7
|
+
title: string;
|
|
8
|
+
message: string;
|
|
9
|
+
onConfirm: () => void;
|
|
10
|
+
onCancel: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface InputDialogOptions {
|
|
14
|
+
theme: Theme;
|
|
15
|
+
tui: TUI;
|
|
16
|
+
title: string;
|
|
17
|
+
message: string;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
initialValue?: string;
|
|
20
|
+
validate?: (raw: string) => string | null;
|
|
21
|
+
onSubmit: (value: string) => void;
|
|
22
|
+
onCancel: () => void;
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { KeybindingsManager, TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { LlamaServer } from "../../interfaces/settings";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared options for SettingsList-based editors that operate on
|
|
7
|
+
* `LlamaServer[]`. Subtypes add editor-specific fields.
|
|
8
|
+
*/
|
|
9
|
+
export interface SettingsListEditorOptions {
|
|
10
|
+
/** TUI instance, used to request re-renders after async persists */
|
|
11
|
+
tui: TUI;
|
|
12
|
+
/** Theme for dialogs (from the ctx.ui.custom factory) */
|
|
13
|
+
theme: Theme;
|
|
14
|
+
/** App keybindings manager (injected by ctx.ui.custom) */
|
|
15
|
+
keybindings: KeybindingsManager;
|
|
16
|
+
/**
|
|
17
|
+
* Snapshot of the merged `llamaSettings.servers` to edit. Replaced
|
|
18
|
+
* in place (`options.servers = next`) after every successful persist.
|
|
19
|
+
*/
|
|
20
|
+
servers: LlamaServer[];
|
|
21
|
+
/** Persists a new server list; a rejection keeps the current list */
|
|
22
|
+
persist: (next: LlamaServer[]) => Promise<void>;
|
|
23
|
+
/** Closes the editor (called on Esc in the server list) */
|
|
24
|
+
done: () => void;
|
|
25
|
+
/** Notifies about persistence errors */
|
|
26
|
+
onError: (message: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Options for the SettingsList-based server editor.
|
|
31
|
+
*/
|
|
32
|
+
export interface ServerSettingsListOptions extends SettingsListEditorOptions {
|
|
33
|
+
/** Timeout (ms) for health checks. Defaults to 1000ms if not provided. */
|
|
34
|
+
serverTimeout?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options for the SettingsList-based overrides editor.
|
|
39
|
+
*/
|
|
40
|
+
export interface OverrideSettingsListOptions extends SettingsListEditorOptions {
|
|
41
|
+
/** Called after a successful add/edit/delete */
|
|
42
|
+
onChanged: () => void;
|
|
43
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { DialogFactory } from "../dialog/factory";
|
|
3
|
+
import type { SettingField } from "./settingField";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Base class for building `SettingItem` arrays from a declarative field
|
|
7
|
+
* registry (Template Method): {@link buildFieldItems} maps the ordered
|
|
8
|
+
* {@link fields} registry over {@link buildItem}, which fills the common
|
|
9
|
+
* columns from the field and delegates field-type-specific columns (e.g.
|
|
10
|
+
* submenu or fixed values) to {@link decorate}.
|
|
11
|
+
*
|
|
12
|
+
* Injects a {@link DialogFactory} once in the constructor so subclasses
|
|
13
|
+
* can build input submenus without threading `theme`/`tui` around.
|
|
14
|
+
*/
|
|
15
|
+
export abstract class ItemBuilder<TEntry, TField extends SettingField<TEntry>> {
|
|
16
|
+
constructor(protected readonly dialogs: DialogFactory) {}
|
|
17
|
+
|
|
18
|
+
/** The ordered field registry; order defines the row order in the list. */
|
|
19
|
+
protected abstract get fields(): readonly TField[];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Builds the `SettingItem` for one field of an entry: fills the common
|
|
23
|
+
* columns from the field, then hands the partial item to
|
|
24
|
+
* {@link decorate}.
|
|
25
|
+
*/
|
|
26
|
+
private buildItem(def: TField, entry: TEntry): SettingItem {
|
|
27
|
+
const base: SettingItem = {
|
|
28
|
+
id: def.id,
|
|
29
|
+
label: def.label,
|
|
30
|
+
description: def.description,
|
|
31
|
+
currentValue: def.currentValue(entry),
|
|
32
|
+
};
|
|
33
|
+
return this.decorate(def, entry, base);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Builds the `SettingItem` array for one entry's editable fields. */
|
|
37
|
+
buildFieldItems(entry: TEntry): SettingItem[] {
|
|
38
|
+
return this.fields.map((def) => this.buildItem(def, entry));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Adds field-type-specific columns (e.g. submenu or fixed values). */
|
|
42
|
+
protected abstract decorate(
|
|
43
|
+
def: TField,
|
|
44
|
+
entry: TEntry,
|
|
45
|
+
base: SettingItem,
|
|
46
|
+
): SettingItem;
|
|
47
|
+
}
|