@pokit/prompter-clack 0.0.35 → 0.0.38
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/dist/autocomplete-prompt.d.ts +31 -0
- package/dist/autocomplete-prompt.d.ts.map +1 -0
- package/dist/autocomplete-prompt.js +274 -0
- package/dist/prompter.d.ts.map +1 -1
- package/dist/prompter.js +2 -1
- package/package.json +5 -3
- package/src/autocomplete-prompt.ts +361 -0
- package/src/prompter.ts +3 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patched autocomplete function
|
|
3
|
+
*
|
|
4
|
+
* Copies the autocomplete function from @clack/prompts but uses a patched
|
|
5
|
+
* AutocompletePrompt that wraps the cursor instead of clamping it.
|
|
6
|
+
*
|
|
7
|
+
* Upstream issue: https://github.com/bombshell-dev/clack/issues/XXX
|
|
8
|
+
* TODO: Remove this file once the fix is released upstream
|
|
9
|
+
*/
|
|
10
|
+
import type { Writable } from 'node:stream';
|
|
11
|
+
interface Option<Value> {
|
|
12
|
+
value: Value;
|
|
13
|
+
label?: string;
|
|
14
|
+
hint?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface AutocompleteOpts<Value> {
|
|
17
|
+
message: string;
|
|
18
|
+
options: Option<Value>[];
|
|
19
|
+
maxItems?: number;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
initialValue?: Value;
|
|
22
|
+
initialUserInput?: string;
|
|
23
|
+
validate?: (value: Value | Value[] | undefined) => string | Error | undefined;
|
|
24
|
+
filter?: (search: string, option: Option<Value>) => boolean;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
input?: import('node:stream').Readable;
|
|
27
|
+
output?: Writable;
|
|
28
|
+
}
|
|
29
|
+
export declare const patchedAutocomplete: <Value>(opts: AutocompleteOpts<Value>) => Promise<Value | symbol>;
|
|
30
|
+
export {};
|
|
31
|
+
//# sourceMappingURL=autocomplete-prompt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"autocomplete-prompt.d.ts","sourceRoot":"","sources":["../src/autocomplete-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAaH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAyL5C,UAAU,MAAM,CAAC,KAAK;IACpB,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAyBD,MAAM,WAAW,gBAAgB,CAAC,KAAK;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,KAAK,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,GAAG,KAAK,EAAE,GAAG,SAAS,KAAK,MAAM,GAAG,KAAK,GAAG,SAAS,CAAC;IAC9E,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,KAAK,OAAO,CAAC;IAC5D,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,KAAK,CAAC,EAAE,OAAO,aAAa,EAAE,QAAQ,CAAC;IACvC,MAAM,CAAC,EAAE,QAAQ,CAAC;CACnB;AAED,eAAO,MAAM,mBAAmB,GAAI,KAAK,EAAE,MAAM,gBAAgB,CAAC,KAAK,CAAC,KA8G5C,OAAO,CAAC,KAAK,GAAG,MAAM,CACjD,CAAC"}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patched autocomplete function
|
|
3
|
+
*
|
|
4
|
+
* Copies the autocomplete function from @clack/prompts but uses a patched
|
|
5
|
+
* AutocompletePrompt that wraps the cursor instead of clamping it.
|
|
6
|
+
*
|
|
7
|
+
* Upstream issue: https://github.com/bombshell-dev/clack/issues/XXX
|
|
8
|
+
* TODO: Remove this file once the fix is released upstream
|
|
9
|
+
*/
|
|
10
|
+
import { Prompt } from '@clack/core';
|
|
11
|
+
import { limitOptions, symbol, S_BAR, S_BAR_END, S_RADIO_ACTIVE, S_RADIO_INACTIVE, } from '@clack/prompts';
|
|
12
|
+
import color from 'picocolors';
|
|
13
|
+
function getCursorForValue(selected, items) {
|
|
14
|
+
if (selected === undefined || items.length === 0) {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
const index = items.findIndex((item) => item.value === selected);
|
|
18
|
+
return index !== -1 ? index : 0;
|
|
19
|
+
}
|
|
20
|
+
function defaultFilter(input, option) {
|
|
21
|
+
const label = option.label ?? String(option.value);
|
|
22
|
+
return label.toLowerCase().includes(input.toLowerCase());
|
|
23
|
+
}
|
|
24
|
+
class PatchedAutocompletePrompt extends Prompt {
|
|
25
|
+
filteredOptions;
|
|
26
|
+
multiple;
|
|
27
|
+
isNavigating = false;
|
|
28
|
+
selectedValues = [];
|
|
29
|
+
focusedValue;
|
|
30
|
+
#cursor = 0;
|
|
31
|
+
#lastUserInput = '';
|
|
32
|
+
#filterFn;
|
|
33
|
+
#options;
|
|
34
|
+
get cursor() {
|
|
35
|
+
return this.#cursor;
|
|
36
|
+
}
|
|
37
|
+
get userInputWithCursor() {
|
|
38
|
+
if (!this.userInput) {
|
|
39
|
+
return color.inverse(color.hidden('_'));
|
|
40
|
+
}
|
|
41
|
+
if (this._cursor >= this.userInput.length) {
|
|
42
|
+
return `${this.userInput}█`;
|
|
43
|
+
}
|
|
44
|
+
const s1 = this.userInput.slice(0, this._cursor);
|
|
45
|
+
const [s2, ...s3] = this.userInput.slice(this._cursor);
|
|
46
|
+
return `${s1}${color.inverse(s2)}${s3.join('')}`;
|
|
47
|
+
}
|
|
48
|
+
get options() {
|
|
49
|
+
if (typeof this.#options === 'function') {
|
|
50
|
+
return this.#options();
|
|
51
|
+
}
|
|
52
|
+
return this.#options;
|
|
53
|
+
}
|
|
54
|
+
constructor(opts) {
|
|
55
|
+
super(opts);
|
|
56
|
+
this.#options = opts.options;
|
|
57
|
+
const options = this.options;
|
|
58
|
+
this.filteredOptions = [...options];
|
|
59
|
+
this.multiple = opts.multiple === true;
|
|
60
|
+
this.#filterFn = opts.filter ?? defaultFilter;
|
|
61
|
+
let initialValues;
|
|
62
|
+
if (opts.initialValue && Array.isArray(opts.initialValue)) {
|
|
63
|
+
initialValues = this.multiple
|
|
64
|
+
? opts.initialValue
|
|
65
|
+
: opts.initialValue.slice(0, 1);
|
|
66
|
+
}
|
|
67
|
+
else if (!this.multiple && this.options.length > 0) {
|
|
68
|
+
initialValues = [this.options[0].value];
|
|
69
|
+
}
|
|
70
|
+
if (initialValues) {
|
|
71
|
+
for (const selectedValue of initialValues) {
|
|
72
|
+
const selectedIndex = options.findIndex((opt) => opt.value === selectedValue);
|
|
73
|
+
if (selectedIndex !== -1) {
|
|
74
|
+
this.toggleSelected(selectedValue);
|
|
75
|
+
this.#cursor = selectedIndex;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
this.focusedValue = this.options[this.#cursor]?.value;
|
|
80
|
+
this.on('key', (char, key) => this.#onKey(char, key));
|
|
81
|
+
this.on('userInput', (value) => this.#onUserInputChanged(value));
|
|
82
|
+
}
|
|
83
|
+
_isActionKey(char, key) {
|
|
84
|
+
return (char === '\t' ||
|
|
85
|
+
(this.multiple &&
|
|
86
|
+
this.isNavigating &&
|
|
87
|
+
key.name === 'space' &&
|
|
88
|
+
char !== undefined &&
|
|
89
|
+
char !== ''));
|
|
90
|
+
}
|
|
91
|
+
#onKey(_char, key) {
|
|
92
|
+
const isUpKey = key.name === 'up';
|
|
93
|
+
const isDownKey = key.name === 'down';
|
|
94
|
+
const isReturnKey = key.name === 'return';
|
|
95
|
+
if (isUpKey || isDownKey) {
|
|
96
|
+
const length = this.filteredOptions.length;
|
|
97
|
+
if (length > 0) {
|
|
98
|
+
this.#cursor = ((this.#cursor + (isUpKey ? -1 : 1)) % length + length) % length;
|
|
99
|
+
}
|
|
100
|
+
this.focusedValue = this.filteredOptions[this.#cursor]?.value;
|
|
101
|
+
if (!this.multiple) {
|
|
102
|
+
this.selectedValues = [this.focusedValue];
|
|
103
|
+
}
|
|
104
|
+
this.isNavigating = true;
|
|
105
|
+
}
|
|
106
|
+
else if (isReturnKey) {
|
|
107
|
+
this.value = this.multiple ? this.selectedValues : this.selectedValues[0];
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
if (this.multiple) {
|
|
111
|
+
if (this.focusedValue !== undefined &&
|
|
112
|
+
(key.name === 'tab' || (this.isNavigating && key.name === 'space'))) {
|
|
113
|
+
this.toggleSelected(this.focusedValue);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
this.isNavigating = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
if (this.focusedValue) {
|
|
121
|
+
this.selectedValues = [this.focusedValue];
|
|
122
|
+
}
|
|
123
|
+
this.isNavigating = false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
deselectAll() {
|
|
128
|
+
this.selectedValues = [];
|
|
129
|
+
}
|
|
130
|
+
toggleSelected(value) {
|
|
131
|
+
if (this.filteredOptions.length === 0)
|
|
132
|
+
return;
|
|
133
|
+
if (this.multiple) {
|
|
134
|
+
if (this.selectedValues.includes(value)) {
|
|
135
|
+
this.selectedValues = this.selectedValues.filter((v) => v !== value);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
this.selectedValues = [...this.selectedValues, value];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
this.selectedValues = [value];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
#onUserInputChanged(value) {
|
|
146
|
+
if (value !== this.#lastUserInput) {
|
|
147
|
+
this.#lastUserInput = value;
|
|
148
|
+
const options = this.options;
|
|
149
|
+
this.filteredOptions = value
|
|
150
|
+
? options.filter((opt) => this.#filterFn(value, opt))
|
|
151
|
+
: [...options];
|
|
152
|
+
this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions);
|
|
153
|
+
this.focusedValue = this.filteredOptions[this.#cursor]?.value;
|
|
154
|
+
if (!this.multiple) {
|
|
155
|
+
if (this.focusedValue !== undefined) {
|
|
156
|
+
this.toggleSelected(this.focusedValue);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
this.deselectAll();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function getLabel(option) {
|
|
166
|
+
return option.label ?? String(option.value ?? '');
|
|
167
|
+
}
|
|
168
|
+
function getFilteredOption(searchText, option) {
|
|
169
|
+
if (!searchText)
|
|
170
|
+
return true;
|
|
171
|
+
const label = (option.label ?? String(option.value ?? '')).toLowerCase();
|
|
172
|
+
const hint = (option.hint ?? '').toLowerCase();
|
|
173
|
+
const value = String(option.value).toLowerCase();
|
|
174
|
+
const term = searchText.toLowerCase();
|
|
175
|
+
return label.includes(term) || hint.includes(term) || value.includes(term);
|
|
176
|
+
}
|
|
177
|
+
function getSelectedOptions(values, options) {
|
|
178
|
+
const results = [];
|
|
179
|
+
for (const option of options) {
|
|
180
|
+
if (values.includes(option.value)) {
|
|
181
|
+
results.push(option);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return results;
|
|
185
|
+
}
|
|
186
|
+
export const patchedAutocomplete = (opts) => {
|
|
187
|
+
const prompt = new PatchedAutocompletePrompt({
|
|
188
|
+
options: opts.options,
|
|
189
|
+
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
|
|
190
|
+
initialUserInput: opts.initialUserInput,
|
|
191
|
+
filter: opts.filter ??
|
|
192
|
+
((search, opt) => getFilteredOption(search, opt)),
|
|
193
|
+
signal: opts.signal,
|
|
194
|
+
input: opts.input,
|
|
195
|
+
output: opts.output,
|
|
196
|
+
validate: opts.validate,
|
|
197
|
+
render() {
|
|
198
|
+
const headings = [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`];
|
|
199
|
+
const userInput = this.userInput;
|
|
200
|
+
const options = this.options;
|
|
201
|
+
const placeholder = opts.placeholder;
|
|
202
|
+
const showPlaceholder = userInput === '' && placeholder !== undefined;
|
|
203
|
+
switch (this.state) {
|
|
204
|
+
case 'submit': {
|
|
205
|
+
const selected = getSelectedOptions(this.selectedValues, options);
|
|
206
|
+
const label = selected.length > 0 ? ` ${color.dim(selected.map(getLabel).join(', '))}` : '';
|
|
207
|
+
return `${headings.join('\n')}\n${color.gray(S_BAR)}${label}`;
|
|
208
|
+
}
|
|
209
|
+
case 'cancel': {
|
|
210
|
+
const userInputText = userInput
|
|
211
|
+
? ` ${color.strikethrough(color.dim(userInput))}`
|
|
212
|
+
: '';
|
|
213
|
+
return `${headings.join('\n')}\n${color.gray(S_BAR)}${userInputText}`;
|
|
214
|
+
}
|
|
215
|
+
default: {
|
|
216
|
+
const barColor = this.state === 'error' ? color.yellow : color.cyan;
|
|
217
|
+
const guidePrefix = `${barColor(S_BAR)} `;
|
|
218
|
+
const guidePrefixEnd = barColor(S_BAR_END);
|
|
219
|
+
let searchText = '';
|
|
220
|
+
if (this.isNavigating || showPlaceholder) {
|
|
221
|
+
const searchTextValue = showPlaceholder ? placeholder : userInput;
|
|
222
|
+
searchText = searchTextValue !== '' ? ` ${color.dim(searchTextValue)}` : '';
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
searchText = ` ${this.userInputWithCursor}`;
|
|
226
|
+
}
|
|
227
|
+
const matches = this.filteredOptions.length !== options.length
|
|
228
|
+
? color.dim(` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`)
|
|
229
|
+
: '';
|
|
230
|
+
const noResults = this.filteredOptions.length === 0 && userInput
|
|
231
|
+
? [`${guidePrefix}${color.yellow('No matches found')}`]
|
|
232
|
+
: [];
|
|
233
|
+
const validationError = this.state === 'error' ? [`${guidePrefix}${color.yellow(this.error)}`] : [];
|
|
234
|
+
headings.push(`${guidePrefix.trimEnd()}`);
|
|
235
|
+
headings.push(`${guidePrefix}${color.dim('Search:')}${searchText}${matches}`, ...noResults, ...validationError);
|
|
236
|
+
const instructions = [
|
|
237
|
+
`${color.dim('↑/↓')} to select`,
|
|
238
|
+
`${color.dim('Enter:')} confirm`,
|
|
239
|
+
`${color.dim('Type:')} to search`,
|
|
240
|
+
];
|
|
241
|
+
const footers = [
|
|
242
|
+
`${guidePrefix}${instructions.join(' • ')}`,
|
|
243
|
+
guidePrefixEnd,
|
|
244
|
+
];
|
|
245
|
+
const displayOptions = this.filteredOptions.length === 0
|
|
246
|
+
? []
|
|
247
|
+
: limitOptions({
|
|
248
|
+
cursor: this.cursor,
|
|
249
|
+
options: this.filteredOptions,
|
|
250
|
+
columnPadding: 3,
|
|
251
|
+
rowPadding: headings.length + footers.length,
|
|
252
|
+
style: (option, active) => {
|
|
253
|
+
const label = getLabel(option);
|
|
254
|
+
const hint = option.hint && option.value === this.focusedValue
|
|
255
|
+
? color.dim(` (${option.hint})`)
|
|
256
|
+
: '';
|
|
257
|
+
return active
|
|
258
|
+
? `${color.green(S_RADIO_ACTIVE)} ${label}${hint}`
|
|
259
|
+
: `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}${hint}`;
|
|
260
|
+
},
|
|
261
|
+
maxItems: opts.maxItems,
|
|
262
|
+
output: opts.output,
|
|
263
|
+
});
|
|
264
|
+
return [
|
|
265
|
+
...headings,
|
|
266
|
+
...displayOptions.map((option) => `${guidePrefix}${option}`),
|
|
267
|
+
...footers,
|
|
268
|
+
].join('\n');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
return prompt.prompt();
|
|
274
|
+
};
|
package/dist/prompter.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prompter.d.ts","sourceRoot":"","sources":["../src/prompter.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EACV,QAAQ,EAST,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"prompter.d.ts","sourceRoot":"","sources":["../src/prompter.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EACV,QAAQ,EAST,MAAM,aAAa,CAAC;AAsZrB;;GAEG;AACH,wBAAgB,cAAc,IAAI,QAAQ,CA8FzC"}
|
package/dist/prompter.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as p from '@clack/prompts';
|
|
7
7
|
import { isDynamicOptions } from '@pokit/core';
|
|
8
|
+
import { patchedAutocomplete } from './autocomplete-prompt.js';
|
|
8
9
|
// =============================================================================
|
|
9
10
|
// Constants
|
|
10
11
|
// =============================================================================
|
|
@@ -382,7 +383,7 @@ export function createPrompter() {
|
|
|
382
383
|
return result;
|
|
383
384
|
},
|
|
384
385
|
async autocomplete(options) {
|
|
385
|
-
const result = await
|
|
386
|
+
const result = await patchedAutocomplete({
|
|
386
387
|
message: options.message,
|
|
387
388
|
options: options.options.map((opt) => ({
|
|
388
389
|
value: opt.value,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pokit/prompter-clack",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.38",
|
|
4
4
|
"description": "Clack-based prompter adapter for pok CLI applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -45,13 +45,15 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@clack/
|
|
48
|
+
"@clack/core": "^1.0.0",
|
|
49
|
+
"@clack/prompts": "^1.0.0",
|
|
50
|
+
"picocolors": "^1.1.1"
|
|
49
51
|
},
|
|
50
52
|
"devDependencies": {
|
|
51
53
|
"@types/bun": "latest"
|
|
52
54
|
},
|
|
53
55
|
"peerDependencies": {
|
|
54
|
-
"@pokit/core": "0.0.
|
|
56
|
+
"@pokit/core": "0.0.38"
|
|
55
57
|
},
|
|
56
58
|
"engines": {
|
|
57
59
|
"bun": ">=1.0.0"
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patched autocomplete function
|
|
3
|
+
*
|
|
4
|
+
* Copies the autocomplete function from @clack/prompts but uses a patched
|
|
5
|
+
* AutocompletePrompt that wraps the cursor instead of clamping it.
|
|
6
|
+
*
|
|
7
|
+
* Upstream issue: https://github.com/bombshell-dev/clack/issues/XXX
|
|
8
|
+
* TODO: Remove this file once the fix is released upstream
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Key } from 'node:readline';
|
|
12
|
+
import { Prompt, type PromptOptions, settings } from '@clack/core';
|
|
13
|
+
import {
|
|
14
|
+
isCancel,
|
|
15
|
+
limitOptions,
|
|
16
|
+
symbol,
|
|
17
|
+
S_BAR,
|
|
18
|
+
S_BAR_END,
|
|
19
|
+
S_RADIO_ACTIVE,
|
|
20
|
+
S_RADIO_INACTIVE,
|
|
21
|
+
} from '@clack/prompts';
|
|
22
|
+
import type { Writable } from 'node:stream';
|
|
23
|
+
import color from 'picocolors';
|
|
24
|
+
|
|
25
|
+
interface OptionLike {
|
|
26
|
+
value: unknown;
|
|
27
|
+
label?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type FilterFunction<T extends OptionLike> = (search: string, opt: T) => boolean;
|
|
31
|
+
|
|
32
|
+
function getCursorForValue<T extends OptionLike>(
|
|
33
|
+
selected: T['value'] | undefined,
|
|
34
|
+
items: T[]
|
|
35
|
+
): number {
|
|
36
|
+
if (selected === undefined || items.length === 0) {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
const index = items.findIndex((item) => item.value === selected);
|
|
40
|
+
return index !== -1 ? index : 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultFilter<T extends OptionLike>(input: string, option: T): boolean {
|
|
44
|
+
const label = option.label ?? String(option.value);
|
|
45
|
+
return label.toLowerCase().includes(input.toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface AutocompletePromptOptions<T extends OptionLike>
|
|
49
|
+
extends PromptOptions<T['value'] | T['value'][], PatchedAutocompletePrompt<T>> {
|
|
50
|
+
options: T[] | ((this: PatchedAutocompletePrompt<T>) => T[]);
|
|
51
|
+
filter?: FilterFunction<T>;
|
|
52
|
+
multiple?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class PatchedAutocompletePrompt<T extends OptionLike> extends Prompt<
|
|
56
|
+
T['value'] | T['value'][]
|
|
57
|
+
> {
|
|
58
|
+
filteredOptions: T[];
|
|
59
|
+
multiple: boolean;
|
|
60
|
+
isNavigating = false;
|
|
61
|
+
selectedValues: Array<T['value']> = [];
|
|
62
|
+
focusedValue: T['value'] | undefined;
|
|
63
|
+
#cursor = 0;
|
|
64
|
+
#lastUserInput = '';
|
|
65
|
+
#filterFn: FilterFunction<T>;
|
|
66
|
+
#options: T[] | (() => T[]);
|
|
67
|
+
|
|
68
|
+
get cursor(): number {
|
|
69
|
+
return this.#cursor;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get userInputWithCursor() {
|
|
73
|
+
if (!this.userInput) {
|
|
74
|
+
return color.inverse(color.hidden('_'));
|
|
75
|
+
}
|
|
76
|
+
if (this._cursor >= this.userInput.length) {
|
|
77
|
+
return `${this.userInput}█`;
|
|
78
|
+
}
|
|
79
|
+
const s1 = this.userInput.slice(0, this._cursor);
|
|
80
|
+
const [s2, ...s3] = this.userInput.slice(this._cursor);
|
|
81
|
+
return `${s1}${color.inverse(s2)}${s3.join('')}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get options(): T[] {
|
|
85
|
+
if (typeof this.#options === 'function') {
|
|
86
|
+
return this.#options();
|
|
87
|
+
}
|
|
88
|
+
return this.#options;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
constructor(opts: AutocompletePromptOptions<T>) {
|
|
92
|
+
super(opts);
|
|
93
|
+
this.#options = opts.options;
|
|
94
|
+
const options = this.options;
|
|
95
|
+
this.filteredOptions = [...options];
|
|
96
|
+
this.multiple = opts.multiple === true;
|
|
97
|
+
this.#filterFn = opts.filter ?? defaultFilter;
|
|
98
|
+
|
|
99
|
+
let initialValues: unknown[] | undefined;
|
|
100
|
+
if (opts.initialValue && Array.isArray(opts.initialValue)) {
|
|
101
|
+
initialValues = this.multiple
|
|
102
|
+
? opts.initialValue
|
|
103
|
+
: opts.initialValue.slice(0, 1);
|
|
104
|
+
} else if (!this.multiple && this.options.length > 0) {
|
|
105
|
+
initialValues = [this.options[0].value];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (initialValues) {
|
|
109
|
+
for (const selectedValue of initialValues) {
|
|
110
|
+
const selectedIndex = options.findIndex((opt) => opt.value === selectedValue);
|
|
111
|
+
if (selectedIndex !== -1) {
|
|
112
|
+
this.toggleSelected(selectedValue);
|
|
113
|
+
this.#cursor = selectedIndex;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.focusedValue = this.options[this.#cursor]?.value;
|
|
119
|
+
this.on('key', (char, key) => this.#onKey(char, key));
|
|
120
|
+
this.on('userInput', (value) => this.#onUserInputChanged(value));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
protected override _isActionKey(char: string | undefined, key: Key): boolean {
|
|
124
|
+
return (
|
|
125
|
+
char === '\t' ||
|
|
126
|
+
(this.multiple &&
|
|
127
|
+
this.isNavigating &&
|
|
128
|
+
key.name === 'space' &&
|
|
129
|
+
char !== undefined &&
|
|
130
|
+
char !== '')
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#onKey(_char: string | undefined, key: Key): void {
|
|
135
|
+
const isUpKey = key.name === 'up';
|
|
136
|
+
const isDownKey = key.name === 'down';
|
|
137
|
+
const isReturnKey = key.name === 'return';
|
|
138
|
+
|
|
139
|
+
if (isUpKey || isDownKey) {
|
|
140
|
+
const length = this.filteredOptions.length;
|
|
141
|
+
if (length > 0) {
|
|
142
|
+
this.#cursor = ((this.#cursor + (isUpKey ? -1 : 1)) % length + length) % length;
|
|
143
|
+
}
|
|
144
|
+
this.focusedValue = this.filteredOptions[this.#cursor]?.value;
|
|
145
|
+
if (!this.multiple) {
|
|
146
|
+
this.selectedValues = [this.focusedValue];
|
|
147
|
+
}
|
|
148
|
+
this.isNavigating = true;
|
|
149
|
+
} else if (isReturnKey) {
|
|
150
|
+
this.value = this.multiple ? this.selectedValues : this.selectedValues[0];
|
|
151
|
+
} else {
|
|
152
|
+
if (this.multiple) {
|
|
153
|
+
if (
|
|
154
|
+
this.focusedValue !== undefined &&
|
|
155
|
+
(key.name === 'tab' || (this.isNavigating && key.name === 'space'))
|
|
156
|
+
) {
|
|
157
|
+
this.toggleSelected(this.focusedValue);
|
|
158
|
+
} else {
|
|
159
|
+
this.isNavigating = false;
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
if (this.focusedValue) {
|
|
163
|
+
this.selectedValues = [this.focusedValue];
|
|
164
|
+
}
|
|
165
|
+
this.isNavigating = false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
deselectAll() {
|
|
171
|
+
this.selectedValues = [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
toggleSelected(value: T['value']) {
|
|
175
|
+
if (this.filteredOptions.length === 0) return;
|
|
176
|
+
if (this.multiple) {
|
|
177
|
+
if (this.selectedValues.includes(value)) {
|
|
178
|
+
this.selectedValues = this.selectedValues.filter((v) => v !== value);
|
|
179
|
+
} else {
|
|
180
|
+
this.selectedValues = [...this.selectedValues, value];
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
this.selectedValues = [value];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#onUserInputChanged(value: string): void {
|
|
188
|
+
if (value !== this.#lastUserInput) {
|
|
189
|
+
this.#lastUserInput = value;
|
|
190
|
+
const options = this.options;
|
|
191
|
+
this.filteredOptions = value
|
|
192
|
+
? options.filter((opt) => this.#filterFn(value, opt))
|
|
193
|
+
: [...options];
|
|
194
|
+
this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions);
|
|
195
|
+
this.focusedValue = this.filteredOptions[this.#cursor]?.value;
|
|
196
|
+
if (!this.multiple) {
|
|
197
|
+
if (this.focusedValue !== undefined) {
|
|
198
|
+
this.toggleSelected(this.focusedValue);
|
|
199
|
+
} else {
|
|
200
|
+
this.deselectAll();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface Option<Value> {
|
|
208
|
+
value: Value;
|
|
209
|
+
label?: string;
|
|
210
|
+
hint?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getLabel<T>(option: Option<T>) {
|
|
214
|
+
return option.label ?? String(option.value ?? '');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function getFilteredOption<T>(searchText: string, option: Option<T>): boolean {
|
|
218
|
+
if (!searchText) return true;
|
|
219
|
+
const label = (option.label ?? String(option.value ?? '')).toLowerCase();
|
|
220
|
+
const hint = (option.hint ?? '').toLowerCase();
|
|
221
|
+
const value = String(option.value).toLowerCase();
|
|
222
|
+
const term = searchText.toLowerCase();
|
|
223
|
+
return label.includes(term) || hint.includes(term) || value.includes(term);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function getSelectedOptions<T>(values: T[], options: Option<T>[]): Option<T>[] {
|
|
227
|
+
const results: Option<T>[] = [];
|
|
228
|
+
for (const option of options) {
|
|
229
|
+
if (values.includes(option.value)) {
|
|
230
|
+
results.push(option);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return results;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface AutocompleteOpts<Value> {
|
|
237
|
+
message: string;
|
|
238
|
+
options: Option<Value>[];
|
|
239
|
+
maxItems?: number;
|
|
240
|
+
placeholder?: string;
|
|
241
|
+
initialValue?: Value;
|
|
242
|
+
initialUserInput?: string;
|
|
243
|
+
validate?: (value: Value | Value[] | undefined) => string | Error | undefined;
|
|
244
|
+
filter?: (search: string, option: Option<Value>) => boolean;
|
|
245
|
+
signal?: AbortSignal;
|
|
246
|
+
input?: import('node:stream').Readable;
|
|
247
|
+
output?: Writable;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const patchedAutocomplete = <Value>(opts: AutocompleteOpts<Value>) => {
|
|
251
|
+
const prompt = new PatchedAutocompletePrompt({
|
|
252
|
+
options: opts.options,
|
|
253
|
+
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
|
|
254
|
+
initialUserInput: opts.initialUserInput,
|
|
255
|
+
filter:
|
|
256
|
+
opts.filter ??
|
|
257
|
+
((search: string, opt: Option<Value>) => getFilteredOption(search, opt)),
|
|
258
|
+
signal: opts.signal,
|
|
259
|
+
input: opts.input,
|
|
260
|
+
output: opts.output,
|
|
261
|
+
validate: opts.validate,
|
|
262
|
+
render() {
|
|
263
|
+
const headings = [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`];
|
|
264
|
+
const userInput = this.userInput;
|
|
265
|
+
const options = this.options;
|
|
266
|
+
const placeholder = opts.placeholder;
|
|
267
|
+
const showPlaceholder = userInput === '' && placeholder !== undefined;
|
|
268
|
+
|
|
269
|
+
switch (this.state) {
|
|
270
|
+
case 'submit': {
|
|
271
|
+
const selected = getSelectedOptions(this.selectedValues, options);
|
|
272
|
+
const label =
|
|
273
|
+
selected.length > 0 ? ` ${color.dim(selected.map(getLabel).join(', '))}` : '';
|
|
274
|
+
return `${headings.join('\n')}\n${color.gray(S_BAR)}${label}`;
|
|
275
|
+
}
|
|
276
|
+
case 'cancel': {
|
|
277
|
+
const userInputText = userInput
|
|
278
|
+
? ` ${color.strikethrough(color.dim(userInput))}`
|
|
279
|
+
: '';
|
|
280
|
+
return `${headings.join('\n')}\n${color.gray(S_BAR)}${userInputText}`;
|
|
281
|
+
}
|
|
282
|
+
default: {
|
|
283
|
+
const barColor = this.state === 'error' ? color.yellow : color.cyan;
|
|
284
|
+
const guidePrefix = `${barColor(S_BAR)} `;
|
|
285
|
+
const guidePrefixEnd = barColor(S_BAR_END);
|
|
286
|
+
|
|
287
|
+
let searchText = '';
|
|
288
|
+
if (this.isNavigating || showPlaceholder) {
|
|
289
|
+
const searchTextValue = showPlaceholder ? placeholder : userInput;
|
|
290
|
+
searchText = searchTextValue !== '' ? ` ${color.dim(searchTextValue)}` : '';
|
|
291
|
+
} else {
|
|
292
|
+
searchText = ` ${this.userInputWithCursor}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const matches =
|
|
296
|
+
this.filteredOptions.length !== options.length
|
|
297
|
+
? color.dim(
|
|
298
|
+
` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`
|
|
299
|
+
)
|
|
300
|
+
: '';
|
|
301
|
+
|
|
302
|
+
const noResults =
|
|
303
|
+
this.filteredOptions.length === 0 && userInput
|
|
304
|
+
? [`${guidePrefix}${color.yellow('No matches found')}`]
|
|
305
|
+
: [];
|
|
306
|
+
|
|
307
|
+
const validationError =
|
|
308
|
+
this.state === 'error' ? [`${guidePrefix}${color.yellow(this.error)}`] : [];
|
|
309
|
+
|
|
310
|
+
headings.push(`${guidePrefix.trimEnd()}`);
|
|
311
|
+
headings.push(
|
|
312
|
+
`${guidePrefix}${color.dim('Search:')}${searchText}${matches}`,
|
|
313
|
+
...noResults,
|
|
314
|
+
...validationError
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const instructions = [
|
|
318
|
+
`${color.dim('↑/↓')} to select`,
|
|
319
|
+
`${color.dim('Enter:')} confirm`,
|
|
320
|
+
`${color.dim('Type:')} to search`,
|
|
321
|
+
];
|
|
322
|
+
|
|
323
|
+
const footers = [
|
|
324
|
+
`${guidePrefix}${instructions.join(' • ')}`,
|
|
325
|
+
guidePrefixEnd,
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
const displayOptions =
|
|
329
|
+
this.filteredOptions.length === 0
|
|
330
|
+
? []
|
|
331
|
+
: limitOptions({
|
|
332
|
+
cursor: this.cursor,
|
|
333
|
+
options: this.filteredOptions,
|
|
334
|
+
columnPadding: 3,
|
|
335
|
+
rowPadding: headings.length + footers.length,
|
|
336
|
+
style: (option: Option<Value>, active: boolean) => {
|
|
337
|
+
const label = getLabel(option);
|
|
338
|
+
const hint =
|
|
339
|
+
option.hint && option.value === this.focusedValue
|
|
340
|
+
? color.dim(` (${option.hint})`)
|
|
341
|
+
: '';
|
|
342
|
+
return active
|
|
343
|
+
? `${color.green(S_RADIO_ACTIVE)} ${label}${hint}`
|
|
344
|
+
: `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}${hint}`;
|
|
345
|
+
},
|
|
346
|
+
maxItems: opts.maxItems,
|
|
347
|
+
output: opts.output,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
return [
|
|
351
|
+
...headings,
|
|
352
|
+
...displayOptions.map((option: string) => `${guidePrefix}${option}`),
|
|
353
|
+
...footers,
|
|
354
|
+
].join('\n');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
return prompt.prompt() as Promise<Value | symbol>;
|
|
361
|
+
};
|
package/src/prompter.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
OptionsPage,
|
|
18
18
|
} from '@pokit/core';
|
|
19
19
|
import { isDynamicOptions } from '@pokit/core';
|
|
20
|
+
import { patchedAutocomplete } from './autocomplete-prompt.js';
|
|
20
21
|
|
|
21
22
|
// =============================================================================
|
|
22
23
|
// Constants
|
|
@@ -499,13 +500,13 @@ export function createPrompter(): Prompter {
|
|
|
499
500
|
},
|
|
500
501
|
|
|
501
502
|
async autocomplete<T>(options: AutocompleteOptions<T>): Promise<T> {
|
|
502
|
-
const result = await
|
|
503
|
+
const result = await patchedAutocomplete({
|
|
503
504
|
message: options.message,
|
|
504
505
|
options: options.options.map((opt) => ({
|
|
505
506
|
value: opt.value,
|
|
506
507
|
label: opt.label,
|
|
507
508
|
hint: opt.hint,
|
|
508
|
-
}))
|
|
509
|
+
})),
|
|
509
510
|
placeholder: options.placeholder,
|
|
510
511
|
maxItems: options.maxItems,
|
|
511
512
|
});
|