pi-better-model-select 0.2.0 → 0.3.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/CHANGELOG.md +55 -0
- package/README.md +17 -7
- package/docs/configuration.md +8 -2
- package/extensions/bms.ts +62 -50
- package/extensions/lib/defaults.ts +139 -51
- package/extensions/lib/effort-editor.ts +35 -20
- package/extensions/lib/flows.ts +126 -496
- package/extensions/lib/picker-dialog.ts +11 -19
- package/extensions/lib/picker.ts +89 -162
- package/extensions/lib/store.ts +3 -0
- package/extensions/lib/tui.ts +320 -0
- package/package.json +2 -1
|
@@ -1,21 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { ModelPicker, type PickerAction, type RankedModel } from "./picker.ts";
|
|
1
|
+
import { ModelPicker, type PickerAction, type PickerOptions, type RankedModel } from "./picker.ts";
|
|
2
|
+
import { resultPlacement } from "./tui.ts";
|
|
4
3
|
|
|
5
|
-
export async function pickerDialog(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
container.addChild(new DynamicBorder(accent));
|
|
15
|
-
return {
|
|
16
|
-
render: (width: number) => container.render(width),
|
|
17
|
-
invalidate: () => container.invalidate(),
|
|
18
|
-
handleInput: (data: string) => picker.handleInput(data),
|
|
19
|
-
};
|
|
20
|
-
});
|
|
4
|
+
export async function pickerDialog(
|
|
5
|
+
ctx: any, rows: RankedModel[], title = "Better Model Select", assignment = false,
|
|
6
|
+
options: PickerOptions = {},
|
|
7
|
+
): Promise<PickerAction | undefined> {
|
|
8
|
+
// Return the Focusable itself. Pi's custom UI installs it in the prompt area,
|
|
9
|
+
// calls setFocus on it and restores the previous editor when done is called.
|
|
10
|
+
return ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) =>
|
|
11
|
+
new ModelPicker(rows, theme, () => tui.requestRender(), done, assignment,
|
|
12
|
+
{ ...options, placement: options.placement ?? resultPlacement() }, title));
|
|
21
13
|
}
|
package/extensions/lib/picker.ts
CHANGED
|
@@ -1,20 +1,8 @@
|
|
|
1
|
-
// The /bms picker
|
|
2
|
-
//
|
|
3
|
-
// Typing always goes to the search box, so plain letters can never be stolen
|
|
4
|
-
// as action shortcuts. Actions are therefore ctrl-chords, which stay reachable
|
|
5
|
-
// while the cursor sits in the search field.
|
|
1
|
+
// The /bms prompt-area picker. Typing goes to search; ctrl-chords perform
|
|
2
|
+
// actions. The action menu shares this dialog and never replaces its input.
|
|
6
3
|
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
fuzzyFilter,
|
|
10
|
-
Input,
|
|
11
|
-
matchesKey,
|
|
12
|
-
SelectList,
|
|
13
|
-
type Focusable,
|
|
14
|
-
type SelectItem,
|
|
15
|
-
type SelectListTheme,
|
|
16
|
-
Text,
|
|
17
|
-
} from "@earendil-works/pi-tui";
|
|
4
|
+
import { matchesKey, type Focusable, type SelectItem, type SelectListTheme } from "@earendil-works/pi-tui";
|
|
5
|
+
import { TableSelect, type Placement, type SelectionState, type TableColumn } from "./tui.ts";
|
|
18
6
|
import { loadUsage, usageKey, type ModelsConfig } from "./store.ts";
|
|
19
7
|
|
|
20
8
|
export type PickerAction =
|
|
@@ -114,171 +102,110 @@ export function selectListTheme(theme: any): SelectListTheme {
|
|
|
114
102
|
};
|
|
115
103
|
}
|
|
116
104
|
|
|
117
|
-
const HINT =
|
|
118
|
-
"↑↓ move • enter load • ctrl+a add • ctrl+u update • ctrl+x delete • ctrl+d default • ctrl+g agent defaults • ctrl+o options • ctrl+r reset • esc close";
|
|
105
|
+
const HINT = "↑↓ move · enter load · ctrl+a add · ctrl+o actions · esc close";
|
|
119
106
|
|
|
120
|
-
export
|
|
121
|
-
|
|
122
|
-
|
|
107
|
+
export interface PickerOptions {
|
|
108
|
+
placement?: Placement;
|
|
109
|
+
/** Pass the same state object when reopening after an action. Updated in place. */
|
|
110
|
+
state?: SelectionState & { menuValue?: string };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const MODEL_COLUMNS: TableColumn[] = [
|
|
114
|
+
{ key: "model", label: "MODEL" }, { key: "provider", label: "PROVIDER", width: 14 },
|
|
115
|
+
{ key: "context", label: "CONTEXT", width: 7 }, { key: "uses", label: "USES", width: 5 },
|
|
116
|
+
{ key: "status", label: "STATUS", width: 17 },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
export class ModelPicker implements Focusable {
|
|
120
|
+
private table: TableSelect;
|
|
121
|
+
private menu?: TableSelect;
|
|
123
122
|
private rows: RankedModel[];
|
|
124
|
-
private filtered: RankedModel[];
|
|
125
|
-
private hintText: Text;
|
|
126
|
-
private countText: Text;
|
|
127
|
-
// Plain fields rather than constructor parameter properties, so the source
|
|
128
|
-
// also loads under type-stripping runtimes that reject that syntax.
|
|
129
123
|
private theme: any;
|
|
130
|
-
private
|
|
124
|
+
private changed: () => void;
|
|
131
125
|
private done: (action: PickerAction) => void;
|
|
132
126
|
private assignment: boolean;
|
|
133
|
-
|
|
127
|
+
private options: PickerOptions;
|
|
134
128
|
private _focused = false;
|
|
135
|
-
get focused(): boolean {
|
|
136
|
-
return this._focused;
|
|
137
|
-
}
|
|
138
|
-
// Propagate focus to the Input so the hardware cursor lands correctly for IME.
|
|
129
|
+
get focused(): boolean { return this._focused; }
|
|
139
130
|
set focused(value: boolean) {
|
|
140
131
|
this._focused = value;
|
|
141
|
-
this.
|
|
132
|
+
this.table.focused = value && !this.menu;
|
|
133
|
+
if (this.menu) this.menu.focused = value;
|
|
142
134
|
}
|
|
143
135
|
|
|
144
|
-
constructor(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
done: (action: PickerAction) => void,
|
|
149
|
-
assignment = false,
|
|
150
|
-
) {
|
|
151
|
-
super();
|
|
136
|
+
constructor(rows: RankedModel[], theme: any, changed: () => void,
|
|
137
|
+
done: (action: PickerAction) => void, assignment = false, options: PickerOptions = {},
|
|
138
|
+
title = "Better Model Select") {
|
|
139
|
+
this.rows = rows;
|
|
152
140
|
this.theme = theme;
|
|
153
|
-
this.
|
|
141
|
+
this.changed = changed;
|
|
154
142
|
this.done = done;
|
|
155
143
|
this.assignment = assignment;
|
|
156
|
-
this.
|
|
157
|
-
this.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
this.list = this.buildList(rows);
|
|
173
|
-
|
|
174
|
-
this.countText = new Text(fg("dim", this.countLabel()), 1, 0);
|
|
175
|
-
this.hintText = new Text(fg("dim", assignment ? "↑↓ move • enter assign • esc back" : HINT), 1, 0);
|
|
176
|
-
|
|
177
|
-
this.addChild(this.search);
|
|
178
|
-
this.addChild(this.list);
|
|
179
|
-
this.addChild(this.countText);
|
|
180
|
-
this.addChild(this.hintText);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** SelectList has no public API to replace its items, so rebuild it. */
|
|
184
|
-
private buildList(rows: RankedModel[]): SelectList {
|
|
185
|
-
const list = new SelectList(
|
|
186
|
-
rows.map(toSelectItem),
|
|
187
|
-
Math.min(Math.max(rows.length, 1), 12),
|
|
188
|
-
selectListTheme(this.theme),
|
|
189
|
-
);
|
|
190
|
-
list.onSelect = () => this.loadSelected();
|
|
191
|
-
list.onCancel = () => this.done({ kind: "close" });
|
|
192
|
-
return list;
|
|
144
|
+
this.options = options;
|
|
145
|
+
this.table = new TableSelect({ title, columns: MODEL_COLUMNS,
|
|
146
|
+
rows: rows.map(row => ({ value: toSelectItem(row).value, label: row.label,
|
|
147
|
+
// IDs remain searchable but are not repeated under the display name.
|
|
148
|
+
description: row.modelId,
|
|
149
|
+
cells: { model: row.label, provider: row.provider, context: formatContext(row.contextWindow),
|
|
150
|
+
uses: String(row.count), status: [row.current ? "current" : "", row.default ? "default" : ""].filter(Boolean).join(" · ") },
|
|
151
|
+
})), placement: options.placement,
|
|
152
|
+
hint: assignment ? "↑↓ move · enter assign · esc back" : HINT,
|
|
153
|
+
}, theme, () => { this.saveState(); changed(); }, value => {
|
|
154
|
+
this.saveState();
|
|
155
|
+
if (value === undefined) done({ kind: "close" });
|
|
156
|
+
else this.action("load");
|
|
157
|
+
}, options.state);
|
|
158
|
+
if (options.state?.menuValue) this.openMenu();
|
|
193
159
|
}
|
|
194
|
-
|
|
195
|
-
private
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
return shown === total ? `${total} models` : `${shown} of ${total} models`;
|
|
160
|
+
private saveState(): void { if (this.options.state) Object.assign(this.options.state, this.table.state); }
|
|
161
|
+
private currentRow(): RankedModel | undefined {
|
|
162
|
+
const value = this.table.currentItem()?.value;
|
|
163
|
+
return this.rows.find(row => toSelectItem(row).value === value);
|
|
199
164
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const item = this.list.getSelectedItem();
|
|
203
|
-
if (!item) return null;
|
|
204
|
-
const decoded = decodeValue(item.value);
|
|
205
|
-
if (!decoded) return null;
|
|
206
|
-
return (
|
|
207
|
-
this.filtered.find(
|
|
208
|
-
(r) => r.provider === decoded.provider && r.modelId === decoded.modelId,
|
|
209
|
-
) ?? null
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
private loadSelected(): void {
|
|
165
|
+
private action(kind: PickerAction["kind"]): void {
|
|
166
|
+
this.saveState();
|
|
214
167
|
const row = this.currentRow();
|
|
215
|
-
if (
|
|
216
|
-
|
|
168
|
+
if (kind === "load" || kind === "set-default") {
|
|
169
|
+
if (row) this.done({ kind, provider: row.provider, modelId: row.modelId });
|
|
170
|
+
} else if (kind === "update" || kind === "delete") {
|
|
171
|
+
this.done({ kind, provider: row?.provider, modelId: row?.modelId });
|
|
172
|
+
} else this.done({ kind });
|
|
217
173
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
this.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
: (_role: string, t: string) => t;
|
|
241
|
-
this.countText.setText(fg("dim", this.countLabel()));
|
|
174
|
+
private openMenu(): void {
|
|
175
|
+
const row = this.currentRow();
|
|
176
|
+
const actions: { value: PickerAction["kind"]; label: string }[] = [
|
|
177
|
+
...(row ? [{ value: "load" as const, label: "Load selected model" },
|
|
178
|
+
{ value: "set-default" as const, label: "Set selected model as default" }] : []),
|
|
179
|
+
{ value: "add", label: "Add model" }, { value: "update", label: "Update model" },
|
|
180
|
+
{ value: "delete", label: "Delete model" }, { value: "defaults", label: "Model and agent defaults" },
|
|
181
|
+
{ value: "options", label: "Options" }, { value: "reset", label: "Reset usage ranking" },
|
|
182
|
+
];
|
|
183
|
+
this.menu = new TableSelect({ title: "Models / Actions", rows: actions, search: false, initialValue: this.options.state?.menuValue,
|
|
184
|
+
hint: "↑↓ move · enter choose · esc back" }, this.theme, this.changed, value => {
|
|
185
|
+
if (this.options.state) {
|
|
186
|
+
if (value === undefined) delete this.options.state.menuValue;
|
|
187
|
+
else this.options.state.menuValue = value;
|
|
188
|
+
}
|
|
189
|
+
this.menu = undefined;
|
|
190
|
+
this.focused = this._focused;
|
|
191
|
+
if (value !== undefined) this.action(value as PickerAction["kind"]);
|
|
192
|
+
this.changed();
|
|
193
|
+
});
|
|
194
|
+
this.focused = this._focused;
|
|
195
|
+
this.changed();
|
|
242
196
|
}
|
|
243
|
-
|
|
244
197
|
handleInput(data: string): void {
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (matchesKey(data, "ctrl+a")) return this.done({ kind: "add" });
|
|
254
|
-
if (matchesKey(data, "ctrl+o")) return this.done({ kind: "options" });
|
|
255
|
-
if (matchesKey(data, "ctrl+r")) return this.done({ kind: "reset" });
|
|
256
|
-
|
|
257
|
-
if (matchesKey(data, "ctrl+u")) {
|
|
258
|
-
const row = this.currentRow();
|
|
259
|
-
return this.done({ kind: "update", provider: row?.provider, modelId: row?.modelId });
|
|
260
|
-
}
|
|
261
|
-
if (matchesKey(data, "ctrl+x")) {
|
|
262
|
-
const row = this.currentRow();
|
|
263
|
-
return this.done({ kind: "delete", provider: row?.provider, modelId: row?.modelId });
|
|
198
|
+
if (this.menu) { this.menu.handleInput(data); return; }
|
|
199
|
+
const shortcuts: [string, PickerAction["kind"]][] = [
|
|
200
|
+
["ctrl+a", "add"], ["ctrl+u", "update"], ["ctrl+x", "delete"], ["ctrl+d", "set-default"],
|
|
201
|
+
["ctrl+g", "defaults"], ["ctrl+r", "reset"],
|
|
202
|
+
];
|
|
203
|
+
if (matchesKey(data, "ctrl+o")) { if (!this.assignment) this.openMenu(); return; }
|
|
204
|
+
for (const [key, kind] of shortcuts) {
|
|
205
|
+
if (matchesKey(data, key as any)) { if (!this.assignment) this.action(kind); return; }
|
|
264
206
|
}
|
|
265
|
-
|
|
266
|
-
// Navigation and submit belong to the list; everything else is search text.
|
|
267
|
-
if (
|
|
268
|
-
matchesKey(data, "up") ||
|
|
269
|
-
matchesKey(data, "down") ||
|
|
270
|
-
matchesKey(data, "pageUp") ||
|
|
271
|
-
matchesKey(data, "pageDown") ||
|
|
272
|
-
matchesKey(data, "enter")
|
|
273
|
-
) {
|
|
274
|
-
this.list.handleInput(data);
|
|
275
|
-
this.onChange();
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const before = this.search.getValue();
|
|
280
|
-
this.search.handleInput(data);
|
|
281
|
-
if (this.search.getValue() !== before) this.applyFilter();
|
|
282
|
-
this.onChange();
|
|
207
|
+
this.table.handleInput(data);
|
|
283
208
|
}
|
|
209
|
+
render(width: number): string[] { return (this.menu ?? this.table).render(width); }
|
|
210
|
+
invalidate(): void { this.table.invalidate(); this.menu?.invalidate(); }
|
|
284
211
|
}
|
package/extensions/lib/store.ts
CHANGED
|
@@ -20,12 +20,14 @@ export const USAGE_PATH = (): string => join(getAgentDir(), "bms-usage.json");
|
|
|
20
20
|
export interface BmsSettings {
|
|
21
21
|
version: 1;
|
|
22
22
|
writeMode: WriteMode;
|
|
23
|
+
resultPlacement: "above" | "below";
|
|
23
24
|
disabledProviders: string[];
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const DEFAULT_SETTINGS: BmsSettings = {
|
|
27
28
|
version: 1,
|
|
28
29
|
writeMode: "overlay",
|
|
30
|
+
resultPlacement: "below",
|
|
29
31
|
disabledProviders: [],
|
|
30
32
|
};
|
|
31
33
|
|
|
@@ -72,6 +74,7 @@ export function loadSettings(): BmsSettings {
|
|
|
72
74
|
return {
|
|
73
75
|
version: 1,
|
|
74
76
|
writeMode: raw.writeMode === "direct" ? "direct" : "overlay",
|
|
77
|
+
resultPlacement: raw.resultPlacement === "above" ? "above" : "below",
|
|
75
78
|
disabledProviders: Array.isArray(raw.disabledProviders)
|
|
76
79
|
? raw.disabledProviders.filter((value): value is string => typeof value === "string")
|
|
77
80
|
: [],
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Input, fuzzyFilter, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi,
|
|
3
|
+
type Component, type Focusable,
|
|
4
|
+
} from "@earendil-works/pi-tui";
|
|
5
|
+
|
|
6
|
+
import { loadSettings } from "./store.ts";
|
|
7
|
+
|
|
8
|
+
export type Placement = "above" | "below";
|
|
9
|
+
|
|
10
|
+
// Also works with older settings files while the store migration is rolled out.
|
|
11
|
+
export function resultPlacement(): Placement {
|
|
12
|
+
return (loadSettings() as { resultPlacement?: Placement }).resultPlacement ?? "below";
|
|
13
|
+
}
|
|
14
|
+
export interface TableColumn { key: string; label: string; width?: number }
|
|
15
|
+
export interface TableRow {
|
|
16
|
+
value: string;
|
|
17
|
+
label: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
cells?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
export interface TableSelectOptions {
|
|
22
|
+
title: string;
|
|
23
|
+
columns?: TableColumn[];
|
|
24
|
+
rows: TableRow[];
|
|
25
|
+
hint?: string;
|
|
26
|
+
search?: boolean;
|
|
27
|
+
placement?: Placement;
|
|
28
|
+
initialValue?: string;
|
|
29
|
+
state?: SelectionState;
|
|
30
|
+
}
|
|
31
|
+
export interface SelectionState { query?: string; selectedValue?: string }
|
|
32
|
+
|
|
33
|
+
/** Distinguish keys from their actions; omit whole hints rather than cut a key. */
|
|
34
|
+
export function shortcutLine(theme: any, hint: string, width: number): string {
|
|
35
|
+
const chunks = hint.split(/\s*·\s*/);
|
|
36
|
+
const rendered: string[] = [];
|
|
37
|
+
let used = 0;
|
|
38
|
+
for (const chunk of chunks) {
|
|
39
|
+
const match = chunk.match(/^(↑↓|enter|esc|space|ctrl\+[a-z])\s+(.*)$/i);
|
|
40
|
+
const key = match?.[1].replace(/^enter$/i, "Enter").replace(/^esc$/i, "Esc").replace(/^space$/i, "Space").replace(/^ctrl\+([a-z])$/i, (_, letter: string) => `Ctrl+${letter.toUpperCase()}`);
|
|
41
|
+
const plain = match ? `[${key}] ${match[2]}` : chunk;
|
|
42
|
+
if (used + plain.length + (rendered.length ? 3 : 0) > width) break;
|
|
43
|
+
rendered.push(match ? color(theme, "accent", `[${key}]`) + " " + color(theme, "muted", match[2]) : color(theme, "muted", chunk));
|
|
44
|
+
used += plain.length + (rendered.length > 1 ? 3 : 0);
|
|
45
|
+
}
|
|
46
|
+
return rendered.join(color(theme, "dim", " "));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function color(theme: any, role: string, text: string): string {
|
|
50
|
+
return typeof theme?.fg === "function" ? theme.fg(role, text) : text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Keep each cell on one terminal line. Widths are terminal cells, not JS length.
|
|
54
|
+
function singleLine(text: string): string { return text.replace(/[\r\n\t]/g, " "); }
|
|
55
|
+
export function fitCell(text: string, width: number): string {
|
|
56
|
+
const clipped = truncateToWidth(singleLine(text), Math.max(0, width), "…");
|
|
57
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A prompt-area dialog. Pi focuses this object, which forwards focus to Input. */
|
|
61
|
+
export class TableSelect implements Component, Focusable {
|
|
62
|
+
protected input: Input;
|
|
63
|
+
protected options: TableSelectOptions;
|
|
64
|
+
protected theme: any;
|
|
65
|
+
protected changed: () => void;
|
|
66
|
+
protected done: (value: string | undefined) => void;
|
|
67
|
+
protected filtered: TableRow[] = [];
|
|
68
|
+
protected selected = 0;
|
|
69
|
+
private start = 0;
|
|
70
|
+
private _focused = false;
|
|
71
|
+
get focused(): boolean { return this._focused; }
|
|
72
|
+
set focused(value: boolean) {
|
|
73
|
+
this._focused = value;
|
|
74
|
+
this.input.focused = value && this.options.search !== false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
constructor(options: TableSelectOptions, theme: any, changed: () => void,
|
|
78
|
+
done: (value: string | undefined) => void, state: SelectionState = {}) {
|
|
79
|
+
this.options = options;
|
|
80
|
+
this.theme = theme;
|
|
81
|
+
this.changed = changed;
|
|
82
|
+
this.done = done;
|
|
83
|
+
this.input = new Input({ prompt: "> ", placeholder: "Search…",
|
|
84
|
+
placeholderStyle: text => color(this.theme, "dim", text) });
|
|
85
|
+
this.input.setValue(state.query ?? "");
|
|
86
|
+
this.filter(state.selectedValue ?? options.initialValue);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get state(): SelectionState {
|
|
90
|
+
return { query: this.input.getValue(), selectedValue: this.currentItem()?.value };
|
|
91
|
+
}
|
|
92
|
+
currentItem(): TableRow | undefined { return this.filtered[this.selected]; }
|
|
93
|
+
private filter(preferred?: string): void {
|
|
94
|
+
const query = this.input.getValue().trim();
|
|
95
|
+
this.filtered = query ? fuzzyFilter(this.options.rows, query,
|
|
96
|
+
row => `${row.label} ${row.value} ${row.description ?? ""} ${Object.values(row.cells ?? {}).join(" ")}`)
|
|
97
|
+
: this.options.rows;
|
|
98
|
+
const index = this.filtered.findIndex(row => row.value === preferred);
|
|
99
|
+
this.selected = index < 0 ? 0 : index;
|
|
100
|
+
this.start = 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
handleInput(data: string): void {
|
|
104
|
+
if (matchesKey(data, "escape")) { this.done(undefined); return; }
|
|
105
|
+
if (matchesKey(data, "enter")) {
|
|
106
|
+
const item = this.currentItem();
|
|
107
|
+
if (item) this.done(item.value);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const up = matchesKey(data, "up") || matchesKey(data, "pageUp");
|
|
111
|
+
const down = matchesKey(data, "down") || matchesKey(data, "pageDown");
|
|
112
|
+
if (up || down) {
|
|
113
|
+
const direction = (up ? -1 : 1) * (this.options.search !== false && this.options.placement === "above" ? -1 : 1);
|
|
114
|
+
const step = matchesKey(data, "pageUp") || matchesKey(data, "pageDown") ? 6 : 1;
|
|
115
|
+
this.selected = Math.max(0, Math.min(this.filtered.length - 1, this.selected + direction * step));
|
|
116
|
+
} else if (this.options.search !== false) {
|
|
117
|
+
const before = this.input.getValue();
|
|
118
|
+
const selectedValue = this.currentItem()?.value;
|
|
119
|
+
this.input.handleInput(data);
|
|
120
|
+
if (this.input.getValue() !== before) this.filter(selectedValue);
|
|
121
|
+
}
|
|
122
|
+
this.changed();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
protected tableLines(width: number): string[] {
|
|
126
|
+
const columns = this.options.columns ?? [
|
|
127
|
+
{ key: "label", label: "", width: 28 }, { key: "description", label: "" },
|
|
128
|
+
];
|
|
129
|
+
const cell = (row: TableRow, key: string) => row.cells?.[key]
|
|
130
|
+
?? (key === "label" ? row.label : key === "description" ? row.description ?? "" : "");
|
|
131
|
+
// Reserve natural widths for metadata, give remaining room to the main label.
|
|
132
|
+
const sizes = columns.map(column => Math.max(1, column.width ?? Math.max(
|
|
133
|
+
visibleWidth(column.label), ...this.options.rows.map(row => visibleWidth(singleLine(cell(row, column.key)))))));
|
|
134
|
+
const available = Math.max(0, width - 2 - Math.max(0, columns.length - 1) * 2);
|
|
135
|
+
if (sizes.length) sizes[0] = Math.max(1, Math.min(24, Math.floor(width * 0.4)),
|
|
136
|
+
available - sizes.slice(1).reduce((a, b) => a + b, 0));
|
|
137
|
+
const line = (values: string[]) => values.map((value, i) => fitCell(value, sizes[i]!)).join(" ");
|
|
138
|
+
const header = columns.some(column => column.label)
|
|
139
|
+
? [color(this.theme, "muted", truncateToWidth(" " + line(columns.map(column => column.label)), width, ""))] : [];
|
|
140
|
+
if (!this.filtered.length) return [...header, color(this.theme, "warning", truncateToWidth(" No matches", width, ""))];
|
|
141
|
+
this.start = Math.min(this.start, Math.max(0, this.filtered.length - 6));
|
|
142
|
+
if (this.selected < this.start) this.start = this.selected;
|
|
143
|
+
if (this.selected >= this.start + 6) this.start = this.selected - 5;
|
|
144
|
+
let indices = Array.from({ length: Math.min(6, this.filtered.length - this.start) }, (_, i) => this.start + i);
|
|
145
|
+
if (this.options.search !== false && this.options.placement === "above") indices = indices.reverse();
|
|
146
|
+
return [...header, ...indices.map(index => {
|
|
147
|
+
const row = this.filtered[index]!;
|
|
148
|
+
const text = fitCell((index === this.selected ? "› " : " ") + line(columns.map(column => cell(row, column.key))), width);
|
|
149
|
+
if (index !== this.selected) return text;
|
|
150
|
+
const selected = color(this.theme, "accent", text);
|
|
151
|
+
return typeof this.theme?.bg === "function" ? this.theme.bg("selectedBg", selected) : selected;
|
|
152
|
+
})];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
render(width: number): string[] {
|
|
156
|
+
width = Math.max(1, width);
|
|
157
|
+
const border = color(this.theme, "borderAccent", "─".repeat(width));
|
|
158
|
+
const prompt = this.options.search === false ? [] : [border, ...this.input.render(width), border];
|
|
159
|
+
const results = this.tableLines(width);
|
|
160
|
+
const count = this.filtered.length === this.options.rows.length
|
|
161
|
+
? `${this.filtered.length} results` : `${this.filtered.length} of ${this.options.rows.length} results`;
|
|
162
|
+
const summary = color(this.theme, "dim", truncateToWidth(count, width, ""));
|
|
163
|
+
const shortcuts = shortcutLine(this.theme, this.options.hint ?? "↑↓ move · enter select · esc back", width);
|
|
164
|
+
return [color(this.theme, "accent", truncateToWidth(singleLine(this.options.title), width, "…")),
|
|
165
|
+
...(this.options.placement === "above" && prompt.length
|
|
166
|
+
? [shortcuts, summary, ...results, ...prompt]
|
|
167
|
+
: [...prompt, ...results, summary, shortcuts])];
|
|
168
|
+
}
|
|
169
|
+
invalidate(): void { this.input.invalidate(); }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function tableSelect(ctx: any, options: TableSelectOptions): Promise<string | undefined> {
|
|
173
|
+
return ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => {
|
|
174
|
+
const table = new TableSelect({ ...options, placement: options.placement ?? resultPlacement() }, theme, () => tui.requestRender(), value => {
|
|
175
|
+
if (options.state) Object.assign(options.state, table.state);
|
|
176
|
+
done(value);
|
|
177
|
+
}, options.state);
|
|
178
|
+
return table;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface ConfirmActionOptions { title: string; lines: string[]; confirmLabel: string }
|
|
183
|
+
export class ConfirmAction extends TableSelect {
|
|
184
|
+
private lines: string[];
|
|
185
|
+
constructor(options: ConfirmActionOptions, theme: any, changed: () => void, done: (confirmed: boolean) => void) {
|
|
186
|
+
super({ title: options.title, search: false,
|
|
187
|
+
rows: [{ value: "cancel", label: "Cancel" }, { value: "confirm", label: options.confirmLabel }],
|
|
188
|
+
}, theme, changed, value => done(value === "confirm"));
|
|
189
|
+
this.lines = options.lines;
|
|
190
|
+
}
|
|
191
|
+
override render(width: number): string[] {
|
|
192
|
+
const [title, ...choices] = super.render(width);
|
|
193
|
+
return [title!, ...this.lines.flatMap(line => wrapTextWithAnsi(line, Math.max(1, width))), ...choices];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export async function confirmAction(ctx: any, options: ConfirmActionOptions): Promise<boolean> {
|
|
197
|
+
return (await ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) =>
|
|
198
|
+
new ConfirmAction(options, theme, () => tui.requestRender(), done))) === true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface FormField {
|
|
202
|
+
key: string; label: string; value: string; description?: string;
|
|
203
|
+
edit: () => Promise<string | undefined>;
|
|
204
|
+
}
|
|
205
|
+
export interface EditFormOptions {
|
|
206
|
+
title: string; fields: FormField[]; onSave: () => Promise<boolean | void>; hint?: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Field callbacks edit the caller's draft. Only onSave may persist it. */
|
|
210
|
+
export class Form implements Component {
|
|
211
|
+
private options: EditFormOptions;
|
|
212
|
+
private theme: any;
|
|
213
|
+
private changed: () => void;
|
|
214
|
+
private done: (saved: boolean) => void;
|
|
215
|
+
private fields: FormField[];
|
|
216
|
+
private originals: string[];
|
|
217
|
+
private selected = 0;
|
|
218
|
+
private busy = false;
|
|
219
|
+
private discard = false;
|
|
220
|
+
private discardSelected = false;
|
|
221
|
+
private error = "";
|
|
222
|
+
private editField: (field: FormField) => Promise<string | undefined>;
|
|
223
|
+
private save: () => Promise<boolean | void>;
|
|
224
|
+
constructor(options: EditFormOptions, theme: any, changed: () => void, done: (saved: boolean) => void,
|
|
225
|
+
editField: (field: FormField) => Promise<string | undefined> = field => field.edit(),
|
|
226
|
+
save: () => Promise<boolean | void> = options.onSave) {
|
|
227
|
+
this.editField = editField;
|
|
228
|
+
this.save = save;
|
|
229
|
+
this.options = options;
|
|
230
|
+
this.theme = theme;
|
|
231
|
+
this.changed = changed;
|
|
232
|
+
this.done = done;
|
|
233
|
+
this.fields = options.fields.map(field => ({ ...field }));
|
|
234
|
+
this.originals = this.fields.map(field => field.value);
|
|
235
|
+
}
|
|
236
|
+
private get dirty(): boolean { return this.fields.some((field, i) => field.value !== this.originals[i]); }
|
|
237
|
+
private async run(action: () => Promise<void>): Promise<void> {
|
|
238
|
+
this.busy = true;
|
|
239
|
+
this.error = "";
|
|
240
|
+
this.changed();
|
|
241
|
+
try { await action(); } catch (error) { this.error = error instanceof Error ? error.message : String(error); }
|
|
242
|
+
finally { this.busy = false; this.changed(); }
|
|
243
|
+
}
|
|
244
|
+
handleInput(data: string): void {
|
|
245
|
+
if (this.busy) return;
|
|
246
|
+
if (this.discard) {
|
|
247
|
+
if (matchesKey(data, "escape")) this.discard = false;
|
|
248
|
+
else if (matchesKey(data, "up") || matchesKey(data, "down")) this.discardSelected = !this.discardSelected;
|
|
249
|
+
else if (matchesKey(data, "enter")) {
|
|
250
|
+
if (this.discardSelected) this.done(false);
|
|
251
|
+
else this.discard = false;
|
|
252
|
+
}
|
|
253
|
+
} else if (matchesKey(data, "escape")) {
|
|
254
|
+
if (!this.dirty) this.done(false);
|
|
255
|
+
else { this.discard = true; this.discardSelected = false; }
|
|
256
|
+
} else if (matchesKey(data, "ctrl+s") || (matchesKey(data, "enter") && this.selected === this.fields.length)) {
|
|
257
|
+
void this.run(async () => { if (await this.save() !== false) this.done(true); });
|
|
258
|
+
} else if (matchesKey(data, "enter") && this.selected === this.fields.length + 1) {
|
|
259
|
+
if (!this.dirty) this.done(false);
|
|
260
|
+
else { this.discard = true; this.discardSelected = false; }
|
|
261
|
+
} else if (matchesKey(data, "up")) this.selected = Math.max(0, this.selected - 1);
|
|
262
|
+
else if (matchesKey(data, "down")) this.selected = Math.min(this.fields.length + 1, this.selected + 1);
|
|
263
|
+
else if (matchesKey(data, "enter")) {
|
|
264
|
+
const field = this.fields[this.selected];
|
|
265
|
+
if (field) void this.run(async () => {
|
|
266
|
+
const value = await this.editField(field);
|
|
267
|
+
if (value !== undefined) field.value = value;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
this.changed();
|
|
271
|
+
}
|
|
272
|
+
render(width: number): string[] {
|
|
273
|
+
const start = Math.max(0, this.selected - 5);
|
|
274
|
+
const labelWidth = Math.min(28, Math.max(18, ...this.fields.map(field => visibleWidth(field.label))));
|
|
275
|
+
const formRows = [...this.fields, { label: "Save configuration", value: "", key: "__save" }, { label: "Back", value: "", key: "__back" }];
|
|
276
|
+
const rows = this.discard ? ["Discard unsaved changes?",
|
|
277
|
+
`${!this.discardSelected ? "›" : " "} Keep editing`, `${this.discardSelected ? "›" : " "} Discard`]
|
|
278
|
+
: formRows.slice(start, start + 6).map((field, index) => {
|
|
279
|
+
const text = fitCell(`${index + start === this.selected ? "›" : " "} ${fitCell(field.label, labelWidth)} ${field.value}`, width);
|
|
280
|
+
return index + start === this.selected && typeof this.theme?.bg === "function"
|
|
281
|
+
? this.theme.bg("selectedBg", color(this.theme, "accent", text)) : text;
|
|
282
|
+
});
|
|
283
|
+
return [color(this.theme, "accent", this.options.title + (this.dirty ? " *" : "")),
|
|
284
|
+
color(this.theme, "muted", ` ${fitCell("FIELD", labelWidth)} VALUE`), ...rows,
|
|
285
|
+
...(this.fields[this.selected]?.description ? [color(this.theme, "dim", this.fields[this.selected]!.description!)] : []),
|
|
286
|
+
...(this.options.hint ? this.options.hint.split("\n").map(line => color(this.theme, "dim", line)) : []),
|
|
287
|
+
...(this.busy || this.error ? [this.busy ? "Working…" : color(this.theme, "error", this.error)] : []),
|
|
288
|
+
shortcutLine(this.theme, this.discard ? "↑↓ move · enter choose · esc back" : "↑↓ move · enter choose · ctrl+s save · esc back", width)]
|
|
289
|
+
.map(line => truncateToWidth(line, Math.max(1, width), "…"));
|
|
290
|
+
}
|
|
291
|
+
invalidate(): void {}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function editForm(ctx: any, options: EditFormOptions): Promise<boolean> {
|
|
295
|
+
// Pi restores its editor when a custom dialog closes. Suspend the form before
|
|
296
|
+
// opening child dialogs, then remount the same draft/selection afterwards.
|
|
297
|
+
let form: Form | undefined;
|
|
298
|
+
let finish: (value: any) => void = () => {};
|
|
299
|
+
let render = () => {};
|
|
300
|
+
let completed: boolean | undefined;
|
|
301
|
+
for (;;) {
|
|
302
|
+
const result: any = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => {
|
|
303
|
+
finish = done;
|
|
304
|
+
render = () => tui.requestRender();
|
|
305
|
+
form ??= new Form(options, theme, () => render(), saved => { completed = saved; finish(saved); },
|
|
306
|
+
field => new Promise<string | undefined>((resolve, reject) => {
|
|
307
|
+
finish({ edit: field.edit, resolve, reject });
|
|
308
|
+
}), () => new Promise<boolean | void>((resolve, reject) => {
|
|
309
|
+
finish({ edit: options.onSave, resolve, reject });
|
|
310
|
+
}));
|
|
311
|
+
return form;
|
|
312
|
+
});
|
|
313
|
+
if (typeof result === "boolean" || !result?.edit) return result === true;
|
|
314
|
+
try { result.resolve(await result.edit()); } catch (error) { result.reject(error); }
|
|
315
|
+
// Let the form's pending edit callback update its display before remounting.
|
|
316
|
+
await Promise.resolve();
|
|
317
|
+
await Promise.resolve();
|
|
318
|
+
if (completed !== undefined) return completed;
|
|
319
|
+
}
|
|
320
|
+
}
|