@maheidem/model-discovery 0.7.1 → 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 +49 -12
- package/application.ts +104 -0
- package/commands.ts +70 -0
- package/index.ts +475 -452
- package/package.json +28 -5
- package/storage.ts +265 -0
- package/ui/wizard-shell.ts +442 -0
- package/ui-model.ts +127 -0
- package/scripts/bisect-grammar.ts +0 -65
- package/scripts/live-schema-repair-check.ts +0 -86
|
@@ -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
|
+
}
|
package/ui-model.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import { redactSecret } from "./providers.ts";
|
|
3
|
+
import { STORAGE_PATH, getStorageDiagnostic, type DiscoveredProvider } from "./storage.ts";
|
|
4
|
+
|
|
5
|
+
export type SourceAvailability = "ready" | "degraded" | "unavailable" | "unscanned";
|
|
6
|
+
|
|
7
|
+
export interface DiscoverySummary {
|
|
8
|
+
sourceCount: number;
|
|
9
|
+
modelCount: number;
|
|
10
|
+
readyCount: number;
|
|
11
|
+
degradedCount: number;
|
|
12
|
+
unavailableCount: number;
|
|
13
|
+
unscannedCount: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function homeRelativePath(path: string, home = process.env.HOME): string {
|
|
17
|
+
if (!home) return path;
|
|
18
|
+
return path === home ? "~" : path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function sourceAvailability(provider: DiscoveredProvider): SourceAvailability {
|
|
22
|
+
if (provider.lastScanError) return provider.cachedModels?.length ? "degraded" : "unavailable";
|
|
23
|
+
if (provider.lastScanned) return "ready";
|
|
24
|
+
return "unscanned";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function summarizeSources(providers: readonly DiscoveredProvider[]): DiscoverySummary {
|
|
28
|
+
const summary: DiscoverySummary = {
|
|
29
|
+
sourceCount: providers.length,
|
|
30
|
+
modelCount: 0,
|
|
31
|
+
readyCount: 0,
|
|
32
|
+
degradedCount: 0,
|
|
33
|
+
unavailableCount: 0,
|
|
34
|
+
unscannedCount: 0,
|
|
35
|
+
};
|
|
36
|
+
for (const provider of providers) {
|
|
37
|
+
summary.modelCount += provider.cachedModels?.length ?? 0;
|
|
38
|
+
summary[`${sourceAvailability(provider)}Count`]++;
|
|
39
|
+
}
|
|
40
|
+
return summary;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildHomeSummary(providers: readonly DiscoveredProvider[]): string[] {
|
|
44
|
+
const summary = summarizeSources(providers);
|
|
45
|
+
if (!summary.sourceCount) {
|
|
46
|
+
return ["No sources configured", "Add a source to discover models from a local OpenAI-compatible endpoint."];
|
|
47
|
+
}
|
|
48
|
+
const health: string[] = [];
|
|
49
|
+
if (summary.readyCount) health.push(`${summary.readyCount} ready`);
|
|
50
|
+
if (summary.degradedCount) health.push(`${summary.degradedCount} cached`);
|
|
51
|
+
if (summary.unavailableCount) health.push(`${summary.unavailableCount} unavailable`);
|
|
52
|
+
if (summary.unscannedCount) health.push(`${summary.unscannedCount} not scanned`);
|
|
53
|
+
return [
|
|
54
|
+
`${summary.sourceCount} source${summary.sourceCount === 1 ? "" : "s"} · ${summary.modelCount} cached model${summary.modelCount === 1 ? "" : "s"}`,
|
|
55
|
+
health.join(" · "),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildHomeItems(providers: readonly DiscoveredProvider[]): SelectItem[] {
|
|
60
|
+
const sourceItems = providers.map((provider) => {
|
|
61
|
+
const availability = sourceAvailability(provider);
|
|
62
|
+
const modelCount = provider.cachedModels?.length ?? 0;
|
|
63
|
+
return {
|
|
64
|
+
value: `provider:${provider.name}`,
|
|
65
|
+
label: provider.name,
|
|
66
|
+
description: `${availability} · ${provider.serverType ?? "unknown server"} · ${modelCount} model${modelCount === 1 ? "" : "s"} · ${provider.baseUrl}`,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
return [
|
|
70
|
+
...sourceItems,
|
|
71
|
+
{ value: "add", label: "Add source", description: "Discover models from an OpenAI-compatible endpoint" },
|
|
72
|
+
...(providers.length
|
|
73
|
+
? [{ value: "rescan-all", label: "Re-scan all sources", description: "Refresh every catalogue; cached models remain on failure" }]
|
|
74
|
+
: []),
|
|
75
|
+
{ value: "diagnostics", label: "Diagnostics", description: "Inspect storage, health, and cached models" },
|
|
76
|
+
{ value: "quit", label: "Close" },
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sourceStatusLine(provider: DiscoveredProvider): string {
|
|
81
|
+
const status = sourceAvailability(provider);
|
|
82
|
+
const modelCount = provider.cachedModels?.length ?? 0;
|
|
83
|
+
return `- ${provider.name}: ${status} · ${provider.serverType ?? "unknown"} · ${modelCount} model${modelCount === 1 ? "" : "s"} · ${provider.baseUrl}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatDiscoveryStatus(
|
|
87
|
+
providers: readonly DiscoveredProvider[],
|
|
88
|
+
options: { storagePath?: string; home?: string } = {},
|
|
89
|
+
): string {
|
|
90
|
+
const summary = summarizeSources(providers);
|
|
91
|
+
const path = homeRelativePath(options.storagePath ?? STORAGE_PATH, options.home ?? process.env.HOME);
|
|
92
|
+
const lines = [
|
|
93
|
+
"Model Discovery",
|
|
94
|
+
`Sources: ${summary.sourceCount}`,
|
|
95
|
+
`Cached models: ${summary.modelCount}`,
|
|
96
|
+
`Health: ${summary.readyCount} ready, ${summary.degradedCount} cached/degraded, ${summary.unavailableCount} unavailable, ${summary.unscannedCount} not scanned`,
|
|
97
|
+
`Storage: ${path}`,
|
|
98
|
+
];
|
|
99
|
+
if (providers.length) lines.push(...providers.map(sourceStatusLine));
|
|
100
|
+
else lines.push("No sources configured. In TUI mode, run /discover and choose Add source.");
|
|
101
|
+
const diagnostic = getStorageDiagnostic();
|
|
102
|
+
if (diagnostic) lines.push(`Warning: ${diagnostic.message}`);
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildDiagnosticsLines(providers: readonly DiscoveredProvider[]): string[] {
|
|
107
|
+
const summary = summarizeSources(providers);
|
|
108
|
+
const lines = [
|
|
109
|
+
`Configuration: ${homeRelativePath(STORAGE_PATH)}`,
|
|
110
|
+
`Sources: ${summary.sourceCount} · cached models: ${summary.modelCount}`,
|
|
111
|
+
`Health: ${summary.readyCount} ready · ${summary.degradedCount} cached/degraded · ${summary.unavailableCount} unavailable · ${summary.unscannedCount} not scanned`,
|
|
112
|
+
];
|
|
113
|
+
const diagnostic = getStorageDiagnostic();
|
|
114
|
+
if (diagnostic) lines.push(`Warning: ${diagnostic.message}`);
|
|
115
|
+
if (!providers.length) {
|
|
116
|
+
lines.push("", "No sources configured.");
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
|
119
|
+
lines.push("", "Configured sources");
|
|
120
|
+
for (const provider of providers) {
|
|
121
|
+
lines.push(sourceStatusLine(provider));
|
|
122
|
+
lines.push(` authentication: ${provider.apiKey ? "API key configured" : "anonymous"}`);
|
|
123
|
+
if (provider.lastScanned) lines.push(` last successful scan: ${new Date(provider.lastScanned).toLocaleString()}`);
|
|
124
|
+
if (provider.lastScanError) lines.push(` latest error: ${redactSecret(provider.lastScanError, provider.apiKey)}`);
|
|
125
|
+
}
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Identify individual MCP tool schemas a llama.cpp endpoint cannot compile.
|
|
3
|
-
* node --experimental-strip-types scripts/bisect-grammar.ts BASE_URL [MODEL] [TOOL_FILTER]
|
|
4
|
-
*
|
|
5
|
-
* The repair is applied first; a failure here therefore isolates an additional
|
|
6
|
-
* converter limitation rather than the known $defs/$ref issue.
|
|
7
|
-
*/
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
9
|
-
import { homedir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { repairRequestToolSchemas } from "../schema-repair.ts";
|
|
12
|
-
|
|
13
|
-
const BASE = process.argv[2];
|
|
14
|
-
const MODEL = process.argv[3] ?? "qwen3.8-27b";
|
|
15
|
-
const FILTER = process.argv[4];
|
|
16
|
-
const CACHE = process.env.PI_MCP_CACHE ?? join(homedir(), ".pi", "agent", "mcp-cache.json");
|
|
17
|
-
|
|
18
|
-
if (!BASE) {
|
|
19
|
-
console.error("usage: node --experimental-strip-types scripts/bisect-grammar.ts BASE_URL [MODEL] [TOOL_FILTER]");
|
|
20
|
-
process.exit(2);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const cache = JSON.parse(readFileSync(CACHE, "utf8")) as {
|
|
24
|
-
servers: Record<string, { tools?: Record<string, unknown>[] }>;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const tools: Record<string, unknown>[] = [];
|
|
28
|
-
for (const [server, spec] of Object.entries(cache.servers)) {
|
|
29
|
-
for (const tool of spec.tools ?? []) {
|
|
30
|
-
const fn = (tool.function ?? tool) as Record<string, unknown>;
|
|
31
|
-
const parameters = fn.parameters ?? fn.inputSchema;
|
|
32
|
-
if (!fn.name || !parameters) continue;
|
|
33
|
-
tools.push({ type: "function", function: { name: `${server}__${String(fn.name).replace(/[^A-Za-z0-9_-]/g, "_")}`, description: String(fn.description ?? ""), parameters } });
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const repaired = repairRequestToolSchemas({ model: MODEL, messages: [{ role: "user", content: "x" }], max_tokens: 8, tools }).payload as { tools: Record<string, unknown>[] };
|
|
38
|
-
let candidates = repaired.tools;
|
|
39
|
-
if (FILTER) candidates = candidates.filter((t) => String((t.function as Record<string, unknown>).name).includes(FILTER));
|
|
40
|
-
|
|
41
|
-
async function post(subset: Record<string, unknown>[]): Promise<{ status: number; msg: string }> {
|
|
42
|
-
const res = await fetch(`${BASE}/v1/chat/completions`, {
|
|
43
|
-
method: "POST",
|
|
44
|
-
headers: { "Content-Type": "application/json" },
|
|
45
|
-
body: JSON.stringify({ model: MODEL, messages: [{ role: "user", content: "reply OK" }], max_tokens: 8, tools: subset }),
|
|
46
|
-
});
|
|
47
|
-
const body = await res.text();
|
|
48
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
49
|
-
return { status: res.status, msg: body.slice(0, 160) };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
console.log(`probing ${candidates.length} tool(s) against ${BASE} (${MODEL})`);
|
|
53
|
-
|
|
54
|
-
// 1. individual probe
|
|
55
|
-
const failing: string[] = [];
|
|
56
|
-
for (const tool of candidates) {
|
|
57
|
-
const name = String((tool.function as Record<string, unknown>).name);
|
|
58
|
-
const { status, msg } = await post([tool]);
|
|
59
|
-
if (status !== 200) {
|
|
60
|
-
failing.push(name);
|
|
61
|
-
console.log(`FAIL ${status} ${name}\n ${msg}`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
console.log(`\nindividually failing: ${failing.length === 0 ? "none" : failing.join(", ")}`);
|
|
65
|
-
process.exit(0);
|