@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.
@@ -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
+ };
@@ -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;AAqZrB;;GAEG;AACH,wBAAgB,cAAc,IAAI,QAAQ,CA8FzC"}
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 p.autocomplete({
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.35",
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/prompts": "^1.0.0"
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.35"
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 p.autocomplete({
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
- })) as Parameters<typeof p.autocomplete>[0]['options'],
509
+ })),
509
510
  placeholder: options.placeholder,
510
511
  maxItems: options.maxItems,
511
512
  });