@maheidem/model-discovery 0.7.0 → 0.8.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/LICENSE +21 -0
- package/README.md +111 -12
- package/application.ts +104 -0
- package/commands.ts +70 -0
- package/index.ts +531 -448
- package/package.json +28 -5
- package/schema-repair.ts +473 -0
- package/storage.ts +265 -0
- package/ui/wizard-shell.ts +442 -0
- package/ui-model.ts +127 -0
package/storage.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
migrateLegacyProfileRouting,
|
|
6
|
+
validateModelProfile,
|
|
7
|
+
type ModelProfile,
|
|
8
|
+
type ModelProfileRouting,
|
|
9
|
+
} from "./profiles.ts";
|
|
10
|
+
import { redactSecret } from "./providers.ts";
|
|
11
|
+
|
|
12
|
+
export interface ModelOverride {
|
|
13
|
+
contextWindow?: number;
|
|
14
|
+
maxTokens?: number;
|
|
15
|
+
reasoning?: boolean;
|
|
16
|
+
input?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DiscoveredProvider {
|
|
20
|
+
name: string;
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
serverType?: string;
|
|
24
|
+
defaultContextWindow?: number;
|
|
25
|
+
defaultMaxTokens?: number;
|
|
26
|
+
modelOverrides?: Record<string, ModelOverride>;
|
|
27
|
+
modelProfiles?: Record<string, ModelProfile[]>;
|
|
28
|
+
modelProfileRouting?: Record<string, ModelProfileRouting>;
|
|
29
|
+
profileSchemaVersion?: number;
|
|
30
|
+
cachedModels?: Record<string, unknown>[];
|
|
31
|
+
compat?: Record<string, unknown>;
|
|
32
|
+
/**
|
|
33
|
+
* Inline $defs/$ref in outgoing tool schemas for this endpoint (default: true for
|
|
34
|
+
* local/self-hosted endpoints, where llama.cpp-style grammar converters reject any
|
|
35
|
+
* $ref that is not resolvable at the document root). Set false to send verbatim.
|
|
36
|
+
*/
|
|
37
|
+
repairToolSchemas?: boolean;
|
|
38
|
+
/** Last successful live catalogue refresh (legacy name retained in storage). */
|
|
39
|
+
lastScanned?: number;
|
|
40
|
+
lastScanAttempt?: number;
|
|
41
|
+
lastScanError?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StorageDiagnostic {
|
|
45
|
+
kind: "warning";
|
|
46
|
+
message: string;
|
|
47
|
+
preservedPath?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const STORAGE_PATH = join(os.homedir(), ".pi", "agent", "model-discovery.json");
|
|
51
|
+
let latestStorageDiagnostic: StorageDiagnostic | undefined;
|
|
52
|
+
|
|
53
|
+
export function getStorageDiagnostic(): StorageDiagnostic | undefined {
|
|
54
|
+
return latestStorageDiagnostic;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function errorMessage(error: unknown): string {
|
|
58
|
+
return error instanceof Error ? error.message : String(error);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function corruptBackupPath(): string {
|
|
62
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
63
|
+
const base = `${STORAGE_PATH}.corrupt-${stamp}`;
|
|
64
|
+
let candidate = base;
|
|
65
|
+
let suffix = 1;
|
|
66
|
+
while (existsSync(candidate)) candidate = `${base}-${suffix++}`;
|
|
67
|
+
return candidate;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function preserveCorruptStorage(error: unknown): void {
|
|
71
|
+
const backupPath = corruptBackupPath();
|
|
72
|
+
try {
|
|
73
|
+
renameSync(STORAGE_PATH, backupPath);
|
|
74
|
+
latestStorageDiagnostic = {
|
|
75
|
+
kind: "warning",
|
|
76
|
+
message: `Invalid configuration was preserved as ${backupPath}.`,
|
|
77
|
+
preservedPath: backupPath,
|
|
78
|
+
};
|
|
79
|
+
} catch (backupError) {
|
|
80
|
+
latestStorageDiagnostic = {
|
|
81
|
+
kind: "warning",
|
|
82
|
+
message: `Configuration could not be read (${errorMessage(error)}) or preserved (${errorMessage(backupError)}).`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeProvidersAtomic(providers: DiscoveredProvider[]): void {
|
|
88
|
+
mkdirSync(dirname(STORAGE_PATH), { recursive: true, mode: 0o700 });
|
|
89
|
+
const tempPath = `${STORAGE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
90
|
+
try {
|
|
91
|
+
writeFileSync(tempPath, JSON.stringify(providers, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
92
|
+
renameSync(tempPath, STORAGE_PATH);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
try {
|
|
95
|
+
if (existsSync(tempPath)) unlinkSync(tempPath);
|
|
96
|
+
} catch {
|
|
97
|
+
/* best-effort cleanup */
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function loadProviders(): DiscoveredProvider[] {
|
|
104
|
+
if (!existsSync(STORAGE_PATH)) return [];
|
|
105
|
+
let raw: string;
|
|
106
|
+
try {
|
|
107
|
+
raw = readFileSync(STORAGE_PATH, "utf-8");
|
|
108
|
+
} catch (error) {
|
|
109
|
+
latestStorageDiagnostic = {
|
|
110
|
+
kind: "warning",
|
|
111
|
+
message: `Configuration could not be read: ${errorMessage(error)}`,
|
|
112
|
+
};
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let parsed: unknown;
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(raw);
|
|
119
|
+
if (!Array.isArray(parsed)) throw new Error("Expected the top-level value to be an array of providers.");
|
|
120
|
+
if (!parsed.every((provider) =>
|
|
121
|
+
provider !== null &&
|
|
122
|
+
typeof provider === "object" &&
|
|
123
|
+
typeof (provider as { name?: unknown }).name === "string" &&
|
|
124
|
+
typeof (provider as { baseUrl?: unknown }).baseUrl === "string"
|
|
125
|
+
)) {
|
|
126
|
+
throw new Error("Every provider requires string name and baseUrl fields.");
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
preserveCorruptStorage(error);
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const providers = parsed as DiscoveredProvider[];
|
|
134
|
+
let migrated = false;
|
|
135
|
+
for (const provider of providers) {
|
|
136
|
+
if (!provider || typeof provider !== "object") continue;
|
|
137
|
+
if ((provider.profileSchemaVersion ?? 0) >= 2) continue;
|
|
138
|
+
for (const [modelId, rawProfiles] of Object.entries(provider.modelProfiles ?? {})) {
|
|
139
|
+
if (!Array.isArray(rawProfiles)) continue;
|
|
140
|
+
const result = migrateLegacyProfileRouting(rawProfiles, provider.modelProfileRouting?.[modelId]);
|
|
141
|
+
if (!result.changed || !result.routing) continue;
|
|
142
|
+
provider.modelProfiles = { ...provider.modelProfiles, [modelId]: result.profiles };
|
|
143
|
+
provider.modelProfileRouting = { ...provider.modelProfileRouting, [modelId]: result.routing };
|
|
144
|
+
migrated = true;
|
|
145
|
+
}
|
|
146
|
+
provider.profileSchemaVersion = 2;
|
|
147
|
+
migrated = true;
|
|
148
|
+
}
|
|
149
|
+
if (migrated) writeProvidersAtomic(providers);
|
|
150
|
+
return providers;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function saveProviders(providers: DiscoveredProvider[]): void {
|
|
154
|
+
writeProvidersAtomic(providers);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function upsertProvider(provider: DiscoveredProvider): void {
|
|
158
|
+
const all = loadProviders();
|
|
159
|
+
const index = all.findIndex((candidate) => candidate.name === provider.name);
|
|
160
|
+
if (index >= 0) all[index] = provider;
|
|
161
|
+
else all.push(provider);
|
|
162
|
+
saveProviders(all);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function deleteProvider(name: string): void {
|
|
166
|
+
saveProviders(loadProviders().filter((provider) => provider.name !== name));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function renameProvider(oldName: string, newName: string): boolean {
|
|
170
|
+
const all = loadProviders();
|
|
171
|
+
const index = all.findIndex((provider) => provider.name === oldName);
|
|
172
|
+
if (index < 0 || all.some((provider) => provider.name === newName)) return false;
|
|
173
|
+
all[index].name = newName;
|
|
174
|
+
saveProviders(all);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function persistProviderScanState(provider: DiscoveredProvider): void {
|
|
179
|
+
try {
|
|
180
|
+
const providers = loadProviders();
|
|
181
|
+
const stored = providers.find(
|
|
182
|
+
(candidate) => candidate.name === provider.name && candidate.baseUrl === provider.baseUrl,
|
|
183
|
+
);
|
|
184
|
+
if (!stored) return;
|
|
185
|
+
stored.serverType = provider.serverType;
|
|
186
|
+
stored.cachedModels = provider.cachedModels;
|
|
187
|
+
stored.lastScanned = provider.lastScanned;
|
|
188
|
+
stored.lastScanAttempt = provider.lastScanAttempt;
|
|
189
|
+
stored.lastScanError = provider.lastScanError;
|
|
190
|
+
saveProviders(providers);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// Runtime registration must not fail merely because scan metadata could not be persisted.
|
|
193
|
+
console.error(`[model-discovery] ${provider.name}: could not persist catalogue state (${errorMessage(error)}).`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function recordSuccessfulScan(
|
|
198
|
+
provider: DiscoveredProvider,
|
|
199
|
+
models: Record<string, unknown>[],
|
|
200
|
+
serverType: string,
|
|
201
|
+
persist = true,
|
|
202
|
+
): void {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
provider.serverType = serverType;
|
|
205
|
+
provider.cachedModels = models;
|
|
206
|
+
provider.lastScanned = now;
|
|
207
|
+
provider.lastScanAttempt = now;
|
|
208
|
+
provider.lastScanError = undefined;
|
|
209
|
+
if (persist) persistProviderScanState(provider);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function recordFailedScan(provider: DiscoveredProvider, error: unknown, persist = true): void {
|
|
213
|
+
provider.lastScanAttempt = Date.now();
|
|
214
|
+
provider.lastScanError = redactSecret(errorMessage(error), provider.apiKey);
|
|
215
|
+
if (persist) persistProviderScanState(provider);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function getModelProfiles(provider: DiscoveredProvider, modelId: string): ModelProfile[] {
|
|
219
|
+
const profiles: unknown = provider.modelProfiles?.[modelId];
|
|
220
|
+
if (!Array.isArray(profiles)) return [];
|
|
221
|
+
return profiles.filter((profile): profile is ModelProfile => validateModelProfile(profile) === null);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function saveModelProfile(
|
|
225
|
+
provider: DiscoveredProvider,
|
|
226
|
+
modelId: string,
|
|
227
|
+
profile: ModelProfile,
|
|
228
|
+
previousSlug?: string,
|
|
229
|
+
): void {
|
|
230
|
+
const profiles = getModelProfiles(provider, modelId);
|
|
231
|
+
const index = previousSlug === undefined ? -1 : profiles.findIndex((item) => item.slug === previousSlug);
|
|
232
|
+
const next = [...profiles];
|
|
233
|
+
if (index >= 0) next[index] = profile;
|
|
234
|
+
else next.push(profile);
|
|
235
|
+
provider.modelProfiles = { ...provider.modelProfiles, [modelId]: next };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function deleteModelProfile(provider: DiscoveredProvider, modelId: string, slug: string): void {
|
|
239
|
+
const nextProfiles = getModelProfiles(provider, modelId).filter((profile) => profile.slug !== slug);
|
|
240
|
+
const modelProfiles = { ...provider.modelProfiles };
|
|
241
|
+
if (nextProfiles.length > 0) modelProfiles[modelId] = nextProfiles;
|
|
242
|
+
else delete modelProfiles[modelId];
|
|
243
|
+
provider.modelProfiles = Object.keys(modelProfiles).length > 0 ? modelProfiles : undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function getModelProfileRouting(
|
|
247
|
+
provider: DiscoveredProvider,
|
|
248
|
+
modelId: string,
|
|
249
|
+
): ModelProfileRouting | undefined {
|
|
250
|
+
return provider.modelProfileRouting?.[modelId];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function saveModelProfileRouting(
|
|
254
|
+
provider: DiscoveredProvider,
|
|
255
|
+
modelId: string,
|
|
256
|
+
routing: ModelProfileRouting,
|
|
257
|
+
): void {
|
|
258
|
+
provider.modelProfileRouting = { ...provider.modelProfileRouting, [modelId]: routing };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function deleteModelProfileRouting(provider: DiscoveredProvider, modelId: string): void {
|
|
262
|
+
const routing = { ...provider.modelProfileRouting };
|
|
263
|
+
delete routing[modelId];
|
|
264
|
+
provider.modelProfileRouting = Object.keys(routing).length > 0 ? routing : undefined;
|
|
265
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
CURSOR_MARKER,
|
|
4
|
+
Input,
|
|
5
|
+
SelectList,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
wrapTextWithAnsi,
|
|
9
|
+
type Component,
|
|
10
|
+
type Focusable,
|
|
11
|
+
type SelectItem,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
export interface WizardSelectHost {
|
|
15
|
+
theme: Theme;
|
|
16
|
+
keybindings: KeybindingsManager;
|
|
17
|
+
title: string;
|
|
18
|
+
items: readonly SelectItem[];
|
|
19
|
+
headerLines?: readonly string[];
|
|
20
|
+
initialValue?: string;
|
|
21
|
+
requestRender(): void;
|
|
22
|
+
done(value: string | null): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WizardTextHost {
|
|
26
|
+
theme: Theme;
|
|
27
|
+
keybindings: KeybindingsManager;
|
|
28
|
+
title: string;
|
|
29
|
+
lines: readonly string[];
|
|
30
|
+
requestRender(): void;
|
|
31
|
+
done(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WizardInputHost {
|
|
35
|
+
theme: Theme;
|
|
36
|
+
keybindings: KeybindingsManager;
|
|
37
|
+
title: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
initialValue?: string;
|
|
40
|
+
validate?(value: string): string | null;
|
|
41
|
+
requestRender(): void;
|
|
42
|
+
done(value: string | undefined): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface WizardSecretHost {
|
|
46
|
+
theme: Theme;
|
|
47
|
+
keybindings: KeybindingsManager;
|
|
48
|
+
title: string;
|
|
49
|
+
description: string;
|
|
50
|
+
requestRender(): void;
|
|
51
|
+
done(value: string | undefined): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function bindingText(keys: readonly string[], fallback: string): string {
|
|
55
|
+
const first = keys[0];
|
|
56
|
+
if (!first) return fallback;
|
|
57
|
+
return first
|
|
58
|
+
.replace(/^up$/, "↑")
|
|
59
|
+
.replace(/^down$/, "↓")
|
|
60
|
+
.replace(/^left$/, "←")
|
|
61
|
+
.replace(/^right$/, "→")
|
|
62
|
+
.replace(/^escape$/, "esc")
|
|
63
|
+
.replace(/^return$/, "enter")
|
|
64
|
+
.replace(/^pageUp$/, "pgup")
|
|
65
|
+
.replace(/^pageDown$/, "pgdn");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function boxLine(theme: Theme, content: string, width: number): string {
|
|
69
|
+
if (width <= 1) return truncateToWidth(content, Math.max(1, width), "", true);
|
|
70
|
+
const innerWidth = Math.max(0, width - 2);
|
|
71
|
+
const clipped = truncateToWidth(content, innerWidth, "…", true);
|
|
72
|
+
const padded = clipped + " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
|
|
73
|
+
return theme.fg("border", "│") + padded + theme.fg("border", "│");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function topBorder(theme: Theme, width: number, title: string): string {
|
|
77
|
+
if (width <= 1) return theme.fg("borderAccent", "─".repeat(Math.max(1, width)));
|
|
78
|
+
const innerWidth = Math.max(0, width - 2);
|
|
79
|
+
const styledTitle = theme.fg("accent", theme.bold(` ${title} `));
|
|
80
|
+
const clippedTitle = truncateToWidth(styledTitle, innerWidth, "", false);
|
|
81
|
+
const tail = "─".repeat(Math.max(0, innerWidth - visibleWidth(clippedTitle)));
|
|
82
|
+
return theme.fg("border", "╭") + clippedTitle + theme.fg("border", `${tail}╮`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function bottomBorder(theme: Theme, width: number): string {
|
|
86
|
+
if (width <= 1) return theme.fg("border", "─".repeat(Math.max(1, width)));
|
|
87
|
+
return theme.fg("border", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function semanticHeader(theme: Theme, line: string): string {
|
|
91
|
+
const lower = line.toLowerCase();
|
|
92
|
+
if (lower.includes("failed") || lower.includes("offline") || lower.includes("warning") || lower.includes("invalid")) {
|
|
93
|
+
return theme.fg("warning", ` ${line}`);
|
|
94
|
+
}
|
|
95
|
+
if (lower.includes("online") || lower.includes("ready") || lower.includes("saved")) {
|
|
96
|
+
return theme.fg("success", ` ${line}`);
|
|
97
|
+
}
|
|
98
|
+
return theme.fg("muted", ` ${line}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isPrintableInput(data: string): boolean {
|
|
102
|
+
if (!data || data.includes("\x1b")) return false;
|
|
103
|
+
return [...data].every((character) => {
|
|
104
|
+
const code = character.codePointAt(0) ?? 0;
|
|
105
|
+
return code >= 0x20 && code !== 0x7f;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Reusable wizard step around Pi's SelectList. It adds the shared bordered
|
|
111
|
+
* shell, injected-keybinding navigation, responsive footer, and real filtering.
|
|
112
|
+
*/
|
|
113
|
+
export class WizardSelect implements Component {
|
|
114
|
+
private readonly host: WizardSelectHost;
|
|
115
|
+
private query = "";
|
|
116
|
+
private filteredItems: SelectItem[] = [];
|
|
117
|
+
private selectedIndex = 0;
|
|
118
|
+
private selectList!: SelectList;
|
|
119
|
+
private readonly maxVisible = 10;
|
|
120
|
+
|
|
121
|
+
constructor(host: WizardSelectHost) {
|
|
122
|
+
this.host = host;
|
|
123
|
+
this.rebuildList(host.initialValue);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
render(width: number): string[] {
|
|
127
|
+
const t = this.host.theme;
|
|
128
|
+
const lines = [topBorder(t, width, this.host.title)];
|
|
129
|
+
const headers = this.host.headerLines ?? [];
|
|
130
|
+
const visibleHeaders = headers.slice(0, 6);
|
|
131
|
+
for (const line of visibleHeaders) lines.push(boxLine(t, semanticHeader(t, line), width));
|
|
132
|
+
if (headers.length > visibleHeaders.length) {
|
|
133
|
+
lines.push(boxLine(t, t.fg("dim", ` … ${headers.length - visibleHeaders.length} more detail lines`), width));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const innerWidth = Math.max(1, width - 2);
|
|
137
|
+
if (width < 6) {
|
|
138
|
+
lines.push(boxLine(t, t.fg("accent", "…"), width));
|
|
139
|
+
} else {
|
|
140
|
+
for (const line of this.selectList.render(innerWidth)) lines.push(boxLine(t, line, width));
|
|
141
|
+
}
|
|
142
|
+
if (this.query) {
|
|
143
|
+
lines.push(boxLine(t, t.fg("accent", ` Filter: ${this.query}`), width));
|
|
144
|
+
}
|
|
145
|
+
lines.push(boxLine(t, t.fg("dim", ` ${this.navigationFooter(innerWidth)}`), width));
|
|
146
|
+
lines.push(bottomBorder(t, width));
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
invalidate(): void {
|
|
151
|
+
this.selectList.invalidate();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
handleInput(data: string): void {
|
|
155
|
+
const kb = this.host.keybindings;
|
|
156
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
157
|
+
this.host.done(null);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (kb.matches(data, "tui.select.confirm")) {
|
|
161
|
+
const selected = this.filteredItems[this.selectedIndex];
|
|
162
|
+
if (selected) this.host.done(selected.value);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
166
|
+
this.move(-1);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (kb.matches(data, "tui.select.down")) {
|
|
170
|
+
this.move(1);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (kb.matches(data, "tui.select.pageUp")) {
|
|
174
|
+
this.move(-this.maxVisible, false);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (kb.matches(data, "tui.select.pageDown")) {
|
|
178
|
+
this.move(this.maxVisible, false);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (data === "\x7f" || data === "\b") {
|
|
182
|
+
if (this.query) {
|
|
183
|
+
this.query = [...this.query].slice(0, -1).join("");
|
|
184
|
+
this.rebuildList();
|
|
185
|
+
this.host.requestRender();
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (data === "\x15") {
|
|
190
|
+
if (this.query) {
|
|
191
|
+
this.query = "";
|
|
192
|
+
this.rebuildList();
|
|
193
|
+
this.host.requestRender();
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (isPrintableInput(data)) {
|
|
198
|
+
this.query += data;
|
|
199
|
+
this.rebuildList();
|
|
200
|
+
this.host.requestRender();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private move(delta: number, wrap = true): void {
|
|
205
|
+
const count = this.filteredItems.length;
|
|
206
|
+
if (!count) return;
|
|
207
|
+
this.selectedIndex = wrap
|
|
208
|
+
? (this.selectedIndex + delta + count) % count
|
|
209
|
+
: Math.max(0, Math.min(count - 1, this.selectedIndex + delta));
|
|
210
|
+
this.selectList.setSelectedIndex(this.selectedIndex);
|
|
211
|
+
this.host.requestRender();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private rebuildList(preferredValue?: string): void {
|
|
215
|
+
const previousValue = preferredValue ?? this.filteredItems[this.selectedIndex]?.value;
|
|
216
|
+
const needle = this.query.trim().toLocaleLowerCase();
|
|
217
|
+
this.filteredItems = this.host.items.filter((item) => {
|
|
218
|
+
if (!needle) return true;
|
|
219
|
+
return [item.label, item.value, item.description]
|
|
220
|
+
.filter((value): value is string => typeof value === "string")
|
|
221
|
+
.some((value) => value.toLocaleLowerCase().includes(needle));
|
|
222
|
+
});
|
|
223
|
+
const preferredIndex = previousValue
|
|
224
|
+
? this.filteredItems.findIndex((item) => item.value === previousValue)
|
|
225
|
+
: -1;
|
|
226
|
+
this.selectedIndex = preferredIndex >= 0 ? preferredIndex : 0;
|
|
227
|
+
this.selectList = new SelectList(
|
|
228
|
+
this.filteredItems,
|
|
229
|
+
Math.min(Math.max(1, this.filteredItems.length), this.maxVisible),
|
|
230
|
+
{
|
|
231
|
+
selectedPrefix: (text: string) => this.host.theme.fg("accent", text),
|
|
232
|
+
selectedText: (text: string) => this.host.theme.fg("accent", text),
|
|
233
|
+
description: (text: string) => this.host.theme.fg("muted", text),
|
|
234
|
+
scrollInfo: (text: string) => this.host.theme.fg("dim", text),
|
|
235
|
+
noMatch: (text: string) => this.host.theme.fg("warning", text),
|
|
236
|
+
},
|
|
237
|
+
{ minPrimaryColumnWidth: 18, maxPrimaryColumnWidth: 48 },
|
|
238
|
+
);
|
|
239
|
+
this.selectList.setSelectedIndex(this.selectedIndex);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private navigationFooter(innerWidth: number): string {
|
|
243
|
+
const kb = this.host.keybindings;
|
|
244
|
+
const up = bindingText(kb.getKeys("tui.select.up"), "↑");
|
|
245
|
+
const down = bindingText(kb.getKeys("tui.select.down"), "↓");
|
|
246
|
+
const confirm = bindingText(kb.getKeys("tui.select.confirm"), "enter");
|
|
247
|
+
const cancel = bindingText(kb.getKeys("tui.select.cancel"), "esc");
|
|
248
|
+
if (innerWidth < 24) return `${up}/${down} ${confirm} ${cancel}`;
|
|
249
|
+
if (innerWidth < 34) return `${up}/${down} · ${confirm} · ${cancel}`;
|
|
250
|
+
if (innerWidth < 58) return `${up}/${down} move · ${confirm} select · ${cancel} back`;
|
|
251
|
+
return `${up}/${down} move · ${confirm} select · ${cancel} back · type to filter`;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Scrollable, width-safe secondary screen for diagnostics and request previews. */
|
|
256
|
+
export class WizardTextView implements Component {
|
|
257
|
+
private readonly host: WizardTextHost;
|
|
258
|
+
private offset = 0;
|
|
259
|
+
private wrappedCount = 0;
|
|
260
|
+
private readonly viewportRows = 14;
|
|
261
|
+
|
|
262
|
+
constructor(host: WizardTextHost) {
|
|
263
|
+
this.host = host;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
render(width: number): string[] {
|
|
267
|
+
const t = this.host.theme;
|
|
268
|
+
const innerWidth = Math.max(1, width - 4);
|
|
269
|
+
const wrapped = this.host.lines.flatMap((line) => line ? wrapTextWithAnsi(line, innerWidth) : [""]);
|
|
270
|
+
this.wrappedCount = wrapped.length;
|
|
271
|
+
this.offset = Math.max(0, Math.min(this.offset, Math.max(0, wrapped.length - this.viewportRows)));
|
|
272
|
+
const visible = wrapped.slice(this.offset, this.offset + this.viewportRows);
|
|
273
|
+
const lines = [topBorder(t, width, this.host.title)];
|
|
274
|
+
const rangeEnd = Math.min(wrapped.length, this.offset + visible.length);
|
|
275
|
+
lines.push(boxLine(t, t.fg("muted", ` Lines ${wrapped.length ? this.offset + 1 : 0}–${rangeEnd} of ${wrapped.length}`), width));
|
|
276
|
+
for (const line of visible) lines.push(boxLine(t, ` ${line}`, width));
|
|
277
|
+
if (!visible.length) lines.push(boxLine(t, t.fg("muted", " No details available"), width));
|
|
278
|
+
lines.push(boxLine(t, t.fg("dim", ` ${this.footer(Math.max(1, width - 2))}`), width));
|
|
279
|
+
lines.push(bottomBorder(t, width));
|
|
280
|
+
return lines;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
invalidate(): void {}
|
|
284
|
+
|
|
285
|
+
handleInput(data: string): void {
|
|
286
|
+
const kb = this.host.keybindings;
|
|
287
|
+
if (kb.matches(data, "tui.select.cancel") || kb.matches(data, "tui.select.confirm")) {
|
|
288
|
+
this.host.done();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (kb.matches(data, "tui.select.up")) this.scroll(-1);
|
|
292
|
+
else if (kb.matches(data, "tui.select.down")) this.scroll(1);
|
|
293
|
+
else if (kb.matches(data, "tui.select.pageUp")) this.scroll(-this.viewportRows);
|
|
294
|
+
else if (kb.matches(data, "tui.select.pageDown")) this.scroll(this.viewportRows);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private scroll(delta: number): void {
|
|
298
|
+
this.offset = Math.max(0, Math.min(Math.max(0, this.wrappedCount - this.viewportRows), this.offset + delta));
|
|
299
|
+
this.host.requestRender();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private footer(innerWidth: number): string {
|
|
303
|
+
const kb = this.host.keybindings;
|
|
304
|
+
const up = bindingText(kb.getKeys("tui.select.up"), "↑");
|
|
305
|
+
const down = bindingText(kb.getKeys("tui.select.down"), "↓");
|
|
306
|
+
const pageUp = bindingText(kb.getKeys("tui.select.pageUp"), "pgup");
|
|
307
|
+
const pageDown = bindingText(kb.getKeys("tui.select.pageDown"), "pgdn");
|
|
308
|
+
const cancel = bindingText(kb.getKeys("tui.select.cancel"), "esc");
|
|
309
|
+
return innerWidth < 34 ? `${up}/${down} scroll · ${cancel}` : `${up}/${down} scroll · ${pageUp}/${pageDown} page · ${cancel} back`;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Focusable text/number wizard step with retained inline validation. */
|
|
314
|
+
export class WizardInput implements Component, Focusable {
|
|
315
|
+
private readonly host: WizardInputHost;
|
|
316
|
+
private readonly input = new Input();
|
|
317
|
+
private error?: string;
|
|
318
|
+
|
|
319
|
+
constructor(host: WizardInputHost) {
|
|
320
|
+
this.host = host;
|
|
321
|
+
this.input.focused = true;
|
|
322
|
+
if (host.initialValue) {
|
|
323
|
+
this.input.setValue(host.initialValue);
|
|
324
|
+
// Input.setValue() puts the cursor at the start; End keeps edits intuitive.
|
|
325
|
+
this.input.handleInput("\x1b[F");
|
|
326
|
+
}
|
|
327
|
+
this.input.onSubmit = (value) => {
|
|
328
|
+
const error = this.host.validate?.(value) ?? null;
|
|
329
|
+
if (error) {
|
|
330
|
+
this.error = error;
|
|
331
|
+
this.host.requestRender();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
this.host.done(value);
|
|
335
|
+
};
|
|
336
|
+
this.input.onEscape = () => this.host.done(undefined);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
get focused(): boolean {
|
|
340
|
+
return this.input.focused;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
set focused(value: boolean) {
|
|
344
|
+
this.input.focused = value;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
render(width: number): string[] {
|
|
348
|
+
const t = this.host.theme;
|
|
349
|
+
const lines = [topBorder(t, width, this.host.title)];
|
|
350
|
+
if (this.host.description) {
|
|
351
|
+
for (const line of wrapTextWithAnsi(this.host.description, Math.max(1, width - 4)).slice(0, 3)) {
|
|
352
|
+
lines.push(boxLine(t, t.fg("muted", ` ${line}`), width));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
for (const line of this.input.render(Math.max(1, width - 4))) lines.push(boxLine(t, ` ${line}`, width));
|
|
356
|
+
if (this.error) lines.push(boxLine(t, t.fg("error", ` ${this.error}`), width));
|
|
357
|
+
const innerWidth = Math.max(1, width - 2);
|
|
358
|
+
const submit = bindingText(this.host.keybindings.getKeys("tui.input.submit"), "enter");
|
|
359
|
+
const cancel = bindingText(this.host.keybindings.getKeys("tui.select.cancel"), "esc");
|
|
360
|
+
lines.push(boxLine(t, t.fg("dim", ` ${innerWidth < 24 ? `${submit} ${cancel}` : `${submit} submit · ${cancel} cancel`}`), width));
|
|
361
|
+
lines.push(bottomBorder(t, width));
|
|
362
|
+
return lines;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
invalidate(): void {
|
|
366
|
+
this.input.invalidate();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
handleInput(data: string): void {
|
|
370
|
+
if (this.error) this.error = undefined;
|
|
371
|
+
this.input.handleInput(data);
|
|
372
|
+
this.host.requestRender();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Focusable masked secret step. The raw value is never included in render output. */
|
|
377
|
+
export class WizardSecretInput implements Component, Focusable {
|
|
378
|
+
private readonly host: WizardSecretHost;
|
|
379
|
+
private readonly input = new Input();
|
|
380
|
+
|
|
381
|
+
constructor(host: WizardSecretHost) {
|
|
382
|
+
this.host = host;
|
|
383
|
+
this.input.focused = true;
|
|
384
|
+
this.input.onSubmit = (value) => this.host.done(value);
|
|
385
|
+
this.input.onEscape = () => this.host.done(undefined);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
get focused(): boolean {
|
|
389
|
+
return this.input.focused;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
set focused(value: boolean) {
|
|
393
|
+
this.input.focused = value;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
render(width: number): string[] {
|
|
397
|
+
const t = this.host.theme;
|
|
398
|
+
const lines = [topBorder(t, width, this.host.title)];
|
|
399
|
+
const descriptionWidth = Math.max(1, width - 4);
|
|
400
|
+
for (const line of wrapTextWithAnsi(this.host.description, descriptionWidth).slice(0, 3)) {
|
|
401
|
+
lines.push(boxLine(t, t.fg("muted", ` ${line}`), width));
|
|
402
|
+
}
|
|
403
|
+
const available = Math.max(1, width - 6);
|
|
404
|
+
lines.push(boxLine(t, ` ${t.fg("accent", "> ")}${this.maskedInputLine(available)}`, width));
|
|
405
|
+
const innerWidth = Math.max(1, width - 2);
|
|
406
|
+
const submit = bindingText(this.host.keybindings.getKeys("tui.input.submit"), "enter");
|
|
407
|
+
const cancel = bindingText(this.host.keybindings.getKeys("tui.select.cancel"), "esc");
|
|
408
|
+
lines.push(boxLine(t, t.fg("dim", ` ${innerWidth < 34 ? `${submit} · ${cancel} · masked` : `${submit} submit · ${cancel} cancel · value is masked`}`), width));
|
|
409
|
+
lines.push(bottomBorder(t, width));
|
|
410
|
+
return lines;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
invalidate(): void {
|
|
414
|
+
this.input.invalidate();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private maskedInputLine(width: number): string {
|
|
418
|
+
const value = this.input.getValue();
|
|
419
|
+
const characters = [...value];
|
|
420
|
+
const cursorOffset = (this.input as unknown as { cursor: number }).cursor;
|
|
421
|
+
const cursorIndex = [...value.slice(0, cursorOffset)].length;
|
|
422
|
+
const reserveEndCursor = cursorIndex === characters.length ? 1 : 0;
|
|
423
|
+
const visibleCapacity = Math.max(0, width - reserveEndCursor);
|
|
424
|
+
let start = Math.max(0, cursorIndex - Math.floor(visibleCapacity / 2));
|
|
425
|
+
start = Math.min(start, Math.max(0, characters.length - visibleCapacity));
|
|
426
|
+
const end = Math.min(characters.length, start + visibleCapacity);
|
|
427
|
+
const visible = characters.slice(start, end).map(() => "•");
|
|
428
|
+
if (start > 0 && visible.length) visible[0] = "…";
|
|
429
|
+
if (end < characters.length && visible.length) visible[visible.length - 1] = "…";
|
|
430
|
+
const relativeCursor = Math.max(0, Math.min(visible.length, cursorIndex - start));
|
|
431
|
+
const before = visible.slice(0, relativeCursor).join("");
|
|
432
|
+
const atCursor = visible[relativeCursor] ?? " ";
|
|
433
|
+
const after = visible.slice(relativeCursor + 1).join("");
|
|
434
|
+
const marker = this.input.focused ? CURSOR_MARKER : "";
|
|
435
|
+
return `${before}${marker}\x1b[7m${atCursor}\x1b[27m${after}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
handleInput(data: string): void {
|
|
439
|
+
this.input.handleInput(data);
|
|
440
|
+
this.host.requestRender();
|
|
441
|
+
}
|
|
442
|
+
}
|