@pokit/prompter-clack 0.0.2 → 0.0.6
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/prompter.d.ts.map +1 -1
- package/dist/prompter.js +325 -0
- package/package.json +2 -2
- package/src/prompter.ts +428 -0
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,
|
|
1
|
+
{"version":3,"file":"prompter.d.ts","sourceRoot":"","sources":["../src/prompter.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EACV,QAAQ,EAQT,MAAM,aAAa,CAAC;AA6ZrB;;GAEG;AACH,wBAAgB,cAAc,IAAI,QAAQ,CAyEzC"}
|
package/dist/prompter.js
CHANGED
|
@@ -4,12 +4,337 @@
|
|
|
4
4
|
* Implements the Prompter interface using @clack/prompts.
|
|
5
5
|
*/
|
|
6
6
|
import * as p from '@clack/prompts';
|
|
7
|
+
import { isDynamicOptions } from '@pokit/core';
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// Constants
|
|
10
|
+
// =============================================================================
|
|
11
|
+
/** Symbol value for the "Load more" option */
|
|
12
|
+
const LOAD_MORE_SYMBOL = Symbol('__pok_load_more__');
|
|
13
|
+
/** Default debounce time for filter requests */
|
|
14
|
+
const DEFAULT_FILTER_DEBOUNCE_MS = 150;
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Utility Functions
|
|
17
|
+
// =============================================================================
|
|
18
|
+
/**
|
|
19
|
+
* Creates a debounced version of a function
|
|
20
|
+
*/
|
|
21
|
+
function debounce(fn, ms) {
|
|
22
|
+
let timeoutId = null;
|
|
23
|
+
return (...args) => {
|
|
24
|
+
if (timeoutId)
|
|
25
|
+
clearTimeout(timeoutId);
|
|
26
|
+
timeoutId = setTimeout(() => fn(...args), ms);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Client-side filter for options
|
|
31
|
+
*/
|
|
32
|
+
function filterOptionsClientSide(options, filter) {
|
|
33
|
+
const lowerFilter = filter.toLowerCase();
|
|
34
|
+
return options.filter((opt) => opt.label.toLowerCase().includes(lowerFilter) ||
|
|
35
|
+
(opt.hint && opt.hint.toLowerCase().includes(lowerFilter)));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Format progress text (e.g., "10 of 50")
|
|
39
|
+
*/
|
|
40
|
+
function formatProgress(loaded, total) {
|
|
41
|
+
if (total !== undefined) {
|
|
42
|
+
return `${loaded} of ${total}`;
|
|
43
|
+
}
|
|
44
|
+
return `${loaded} loaded`;
|
|
45
|
+
}
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Error Recovery
|
|
48
|
+
// =============================================================================
|
|
49
|
+
/**
|
|
50
|
+
* Show error recovery prompt
|
|
51
|
+
* Returns 'retry' or 'cancel'
|
|
52
|
+
*/
|
|
53
|
+
async function showErrorRecovery(errorMessage) {
|
|
54
|
+
const result = await p.select({
|
|
55
|
+
message: `Error: ${errorMessage}`,
|
|
56
|
+
options: [
|
|
57
|
+
{ value: 'retry', label: 'Retry', hint: 'Try loading again' },
|
|
58
|
+
{ value: 'cancel', label: 'Cancel', hint: 'Abort selection' },
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
if (p.isCancel(result)) {
|
|
62
|
+
return 'cancel';
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
// =============================================================================
|
|
67
|
+
// Dynamic Select Implementation
|
|
68
|
+
// =============================================================================
|
|
69
|
+
/**
|
|
70
|
+
* Handle dynamic select with pagination, filtering, and error recovery
|
|
71
|
+
*/
|
|
72
|
+
async function handleDynamicSelect(dynamicOptions) {
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const provider = dynamicOptions.provider;
|
|
75
|
+
const capabilities = provider.capabilities;
|
|
76
|
+
const supportsFilter = capabilities?.supportsFilter ?? false;
|
|
77
|
+
const filterDebounceMs = capabilities?.filterDebounceMs ?? DEFAULT_FILTER_DEBOUNCE_MS;
|
|
78
|
+
// State
|
|
79
|
+
const state = {
|
|
80
|
+
options: [],
|
|
81
|
+
nextCursor: null,
|
|
82
|
+
isLoading: true,
|
|
83
|
+
filter: undefined,
|
|
84
|
+
};
|
|
85
|
+
// Load options from provider
|
|
86
|
+
async function loadOptions(cursor, filter, append = false) {
|
|
87
|
+
const result = await provider({
|
|
88
|
+
cursor,
|
|
89
|
+
filter,
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
});
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
// Initial load with spinner
|
|
95
|
+
const loadingMessage = dynamicOptions.loadingMessage ?? 'Loading...';
|
|
96
|
+
const spinner = p.spinner();
|
|
97
|
+
spinner.start(loadingMessage);
|
|
98
|
+
try {
|
|
99
|
+
const initialPage = await loadOptions();
|
|
100
|
+
state.options = initialPage.options;
|
|
101
|
+
state.nextCursor = initialPage.nextCursor ?? null;
|
|
102
|
+
state.totalCount = initialPage.totalCount;
|
|
103
|
+
state.isLoading = false;
|
|
104
|
+
spinner.stop(loadingMessage);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
spinner.stop('Failed to load options');
|
|
108
|
+
const errorMessage = dynamicOptions.errorMessage ??
|
|
109
|
+
(error instanceof Error ? error.message : 'Failed to load options');
|
|
110
|
+
// Show error recovery
|
|
111
|
+
const action = await showErrorRecovery(errorMessage);
|
|
112
|
+
if (action === 'cancel') {
|
|
113
|
+
p.cancel('Cancelled');
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
// Retry - recursive call
|
|
117
|
+
return handleDynamicSelect(dynamicOptions);
|
|
118
|
+
}
|
|
119
|
+
// Check for empty results
|
|
120
|
+
if (state.options.length === 0) {
|
|
121
|
+
p.cancel('No options available');
|
|
122
|
+
process.exit(0);
|
|
123
|
+
}
|
|
124
|
+
// Main selection loop - handles "Load more" and re-selection
|
|
125
|
+
while (true) {
|
|
126
|
+
// Build options list
|
|
127
|
+
const displayOptions = [];
|
|
128
|
+
// Determine which options to show (filter if needed)
|
|
129
|
+
let optionsToShow = state.options;
|
|
130
|
+
if (state.filter && !supportsFilter) {
|
|
131
|
+
// Client-side filtering
|
|
132
|
+
optionsToShow = filterOptionsClientSide(state.options, state.filter);
|
|
133
|
+
}
|
|
134
|
+
// Add user options
|
|
135
|
+
for (const opt of optionsToShow) {
|
|
136
|
+
displayOptions.push({
|
|
137
|
+
value: opt.value,
|
|
138
|
+
label: opt.label,
|
|
139
|
+
hint: opt.hint,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Add "Load more" if there are more pages
|
|
143
|
+
if (state.nextCursor) {
|
|
144
|
+
const loadMoreLabel = dynamicOptions.loadMoreLabel ?? 'Load more...';
|
|
145
|
+
const progress = formatProgress(state.options.length, state.totalCount);
|
|
146
|
+
displayOptions.push({
|
|
147
|
+
value: LOAD_MORE_SYMBOL,
|
|
148
|
+
label: loadMoreLabel,
|
|
149
|
+
hint: progress,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// Handle case where filtering removed all visible options
|
|
153
|
+
if (displayOptions.length === 0) {
|
|
154
|
+
p.cancel('No matching options');
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
// Show the select prompt
|
|
158
|
+
const result = await p.select({
|
|
159
|
+
message: dynamicOptions.message,
|
|
160
|
+
options: displayOptions,
|
|
161
|
+
initialValue: dynamicOptions.initialValue,
|
|
162
|
+
});
|
|
163
|
+
if (p.isCancel(result)) {
|
|
164
|
+
controller.abort();
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
// Check if "Load more" was selected
|
|
168
|
+
if (result === LOAD_MORE_SYMBOL) {
|
|
169
|
+
// Load more options
|
|
170
|
+
const loadMoreSpinner = p.spinner();
|
|
171
|
+
loadMoreSpinner.start('Loading more...');
|
|
172
|
+
try {
|
|
173
|
+
const nextPage = await loadOptions(state.nextCursor ?? undefined);
|
|
174
|
+
// Append new options
|
|
175
|
+
state.options = [...state.options, ...nextPage.options];
|
|
176
|
+
state.nextCursor = nextPage.nextCursor ?? null;
|
|
177
|
+
// Keep totalCount from initial if not provided in subsequent pages
|
|
178
|
+
if (nextPage.totalCount !== undefined) {
|
|
179
|
+
state.totalCount = nextPage.totalCount;
|
|
180
|
+
}
|
|
181
|
+
loadMoreSpinner.stop(`Loaded ${state.options.length}${state.totalCount ? ` of ${state.totalCount}` : ''}`);
|
|
182
|
+
// Continue the loop to show updated options
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
loadMoreSpinner.stop('Failed to load more');
|
|
187
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to load more options';
|
|
188
|
+
// Show error recovery
|
|
189
|
+
const action = await showErrorRecovery(errorMessage);
|
|
190
|
+
if (action === 'cancel') {
|
|
191
|
+
p.cancel('Cancelled');
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
// Retry loading more - continue the loop
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// User selected an actual option
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Handle dynamic select with typeahead filtering
|
|
204
|
+
* This creates a text input that filters options as you type
|
|
205
|
+
*/
|
|
206
|
+
async function handleDynamicSelectWithTypeahead(dynamicOptions) {
|
|
207
|
+
const controller = new AbortController();
|
|
208
|
+
const provider = dynamicOptions.provider;
|
|
209
|
+
const capabilities = provider.capabilities;
|
|
210
|
+
const supportsServerFilter = capabilities?.supportsFilter ?? false;
|
|
211
|
+
const filterDebounceMs = capabilities?.filterDebounceMs ?? DEFAULT_FILTER_DEBOUNCE_MS;
|
|
212
|
+
// State
|
|
213
|
+
let allOptions = [];
|
|
214
|
+
let nextCursor = null;
|
|
215
|
+
let totalCount;
|
|
216
|
+
let currentFilter = '';
|
|
217
|
+
// Load with optional filter
|
|
218
|
+
async function loadOptions(cursor, filter) {
|
|
219
|
+
return provider({
|
|
220
|
+
cursor,
|
|
221
|
+
filter: supportsServerFilter ? filter : undefined,
|
|
222
|
+
signal: controller.signal,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
// Initial load
|
|
226
|
+
const loadingMessage = dynamicOptions.loadingMessage ?? 'Loading...';
|
|
227
|
+
const spinner = p.spinner();
|
|
228
|
+
spinner.start(loadingMessage);
|
|
229
|
+
try {
|
|
230
|
+
const initialPage = await loadOptions();
|
|
231
|
+
allOptions = initialPage.options;
|
|
232
|
+
nextCursor = initialPage.nextCursor ?? null;
|
|
233
|
+
totalCount = initialPage.totalCount;
|
|
234
|
+
spinner.stop(loadingMessage);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
spinner.stop('Failed to load options');
|
|
238
|
+
const errorMessage = dynamicOptions.errorMessage ??
|
|
239
|
+
(error instanceof Error ? error.message : 'Failed to load options');
|
|
240
|
+
const action = await showErrorRecovery(errorMessage);
|
|
241
|
+
if (action === 'cancel') {
|
|
242
|
+
p.cancel('Cancelled');
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
return handleDynamicSelectWithTypeahead(dynamicOptions);
|
|
246
|
+
}
|
|
247
|
+
if (allOptions.length === 0) {
|
|
248
|
+
p.cancel('No options available');
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
251
|
+
// For server-side filtering, we need to refetch when filter changes
|
|
252
|
+
// For client-side filtering, we just filter the loaded options
|
|
253
|
+
// Since @clack/prompts doesn't have a built-in typeahead, we use the standard select
|
|
254
|
+
// The filtering is done before each render
|
|
255
|
+
// Main selection loop
|
|
256
|
+
while (true) {
|
|
257
|
+
// Apply filter
|
|
258
|
+
let displayOptions = allOptions;
|
|
259
|
+
if (currentFilter && !supportsServerFilter) {
|
|
260
|
+
displayOptions = filterOptionsClientSide(allOptions, currentFilter);
|
|
261
|
+
}
|
|
262
|
+
// Build the options array
|
|
263
|
+
const selectOptions = displayOptions.map((opt) => ({
|
|
264
|
+
value: opt.value,
|
|
265
|
+
label: opt.label,
|
|
266
|
+
hint: opt.hint,
|
|
267
|
+
}));
|
|
268
|
+
// Add load more if available
|
|
269
|
+
if (nextCursor) {
|
|
270
|
+
const loadMoreLabel = dynamicOptions.loadMoreLabel ?? 'Load more...';
|
|
271
|
+
const progress = formatProgress(allOptions.length, totalCount);
|
|
272
|
+
selectOptions.push({
|
|
273
|
+
value: LOAD_MORE_SYMBOL,
|
|
274
|
+
label: loadMoreLabel,
|
|
275
|
+
hint: progress,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (selectOptions.length === 0) {
|
|
279
|
+
p.cancel('No matching options');
|
|
280
|
+
process.exit(0);
|
|
281
|
+
}
|
|
282
|
+
const result = await p.select({
|
|
283
|
+
message: dynamicOptions.message,
|
|
284
|
+
options: selectOptions,
|
|
285
|
+
initialValue: dynamicOptions.initialValue,
|
|
286
|
+
});
|
|
287
|
+
if (p.isCancel(result)) {
|
|
288
|
+
controller.abort();
|
|
289
|
+
process.exit(0);
|
|
290
|
+
}
|
|
291
|
+
if (result === LOAD_MORE_SYMBOL) {
|
|
292
|
+
const loadMoreSpinner = p.spinner();
|
|
293
|
+
loadMoreSpinner.start('Loading more...');
|
|
294
|
+
try {
|
|
295
|
+
const nextPage = await loadOptions(nextCursor ?? undefined, currentFilter);
|
|
296
|
+
allOptions = [...allOptions, ...nextPage.options];
|
|
297
|
+
nextCursor = nextPage.nextCursor ?? null;
|
|
298
|
+
if (nextPage.totalCount !== undefined) {
|
|
299
|
+
totalCount = nextPage.totalCount;
|
|
300
|
+
}
|
|
301
|
+
loadMoreSpinner.stop(`Loaded ${allOptions.length}${totalCount ? ` of ${totalCount}` : ''}`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
loadMoreSpinner.stop('Failed to load more');
|
|
306
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to load more';
|
|
307
|
+
const action = await showErrorRecovery(errorMessage);
|
|
308
|
+
if (action === 'cancel') {
|
|
309
|
+
p.cancel('Cancelled');
|
|
310
|
+
process.exit(0);
|
|
311
|
+
}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// =============================================================================
|
|
319
|
+
// Main Prompter Factory
|
|
320
|
+
// =============================================================================
|
|
7
321
|
/**
|
|
8
322
|
* Create a Prompter using @clack/prompts
|
|
9
323
|
*/
|
|
10
324
|
export function createPrompter() {
|
|
11
325
|
return {
|
|
12
326
|
async select(options) {
|
|
327
|
+
// Handle dynamic options
|
|
328
|
+
if (isDynamicOptions(options)) {
|
|
329
|
+
const supportsFilter = options.provider.capabilities?.supportsFilter ?? false;
|
|
330
|
+
// Use typeahead handler if filtering is supported
|
|
331
|
+
if (supportsFilter) {
|
|
332
|
+
return handleDynamicSelectWithTypeahead(options);
|
|
333
|
+
}
|
|
334
|
+
// Standard dynamic select with pagination
|
|
335
|
+
return handleDynamicSelect(options);
|
|
336
|
+
}
|
|
337
|
+
// Static options - existing behavior
|
|
13
338
|
const result = await p.select({
|
|
14
339
|
message: options.message,
|
|
15
340
|
options: options.options,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pokit/prompter-clack",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Clack-based prompter adapter for pok CLI applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@types/bun": "latest"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@pokit/core": "0.0.
|
|
54
|
+
"@pokit/core": "0.0.6"
|
|
55
55
|
},
|
|
56
56
|
"engines": {
|
|
57
57
|
"bun": ">=1.0.0"
|
package/src/prompter.ts
CHANGED
|
@@ -8,10 +8,424 @@ import * as p from '@clack/prompts';
|
|
|
8
8
|
import type {
|
|
9
9
|
Prompter,
|
|
10
10
|
SelectOptions,
|
|
11
|
+
SelectOption,
|
|
12
|
+
DynamicSelectOptions,
|
|
11
13
|
MultiselectOptions,
|
|
12
14
|
ConfirmOptions,
|
|
13
15
|
TextOptions,
|
|
16
|
+
OptionsPage,
|
|
14
17
|
} from '@pokit/core';
|
|
18
|
+
import { isDynamicOptions } from '@pokit/core';
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Constants
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
/** Symbol value for the "Load more" option */
|
|
25
|
+
const LOAD_MORE_SYMBOL = Symbol('__pok_load_more__');
|
|
26
|
+
|
|
27
|
+
/** Default debounce time for filter requests */
|
|
28
|
+
const DEFAULT_FILTER_DEBOUNCE_MS = 150;
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Types
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
/** Internal state for dynamic select */
|
|
35
|
+
type DynamicSelectState<T> = {
|
|
36
|
+
/** All loaded options so far */
|
|
37
|
+
options: SelectOption<T>[];
|
|
38
|
+
/** Cursor for next page (null if no more pages) */
|
|
39
|
+
nextCursor: string | null;
|
|
40
|
+
/** Total count if known */
|
|
41
|
+
totalCount?: number;
|
|
42
|
+
/** Current filter string */
|
|
43
|
+
filter?: string;
|
|
44
|
+
/** Whether we're currently loading */
|
|
45
|
+
isLoading: boolean;
|
|
46
|
+
/** Last error if any */
|
|
47
|
+
error?: Error;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Error recovery action */
|
|
51
|
+
type ErrorAction = 'retry' | 'cancel';
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Utility Functions
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Creates a debounced version of a function
|
|
59
|
+
*/
|
|
60
|
+
function debounce<T extends (...args: any[]) => any>(
|
|
61
|
+
fn: T,
|
|
62
|
+
ms: number
|
|
63
|
+
): (...args: Parameters<T>) => void {
|
|
64
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
65
|
+
return (...args: Parameters<T>) => {
|
|
66
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
67
|
+
timeoutId = setTimeout(() => fn(...args), ms);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Client-side filter for options
|
|
73
|
+
*/
|
|
74
|
+
function filterOptionsClientSide<T>(
|
|
75
|
+
options: SelectOption<T>[],
|
|
76
|
+
filter: string
|
|
77
|
+
): SelectOption<T>[] {
|
|
78
|
+
const lowerFilter = filter.toLowerCase();
|
|
79
|
+
return options.filter(
|
|
80
|
+
(opt) =>
|
|
81
|
+
opt.label.toLowerCase().includes(lowerFilter) ||
|
|
82
|
+
(opt.hint && opt.hint.toLowerCase().includes(lowerFilter))
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Format progress text (e.g., "10 of 50")
|
|
88
|
+
*/
|
|
89
|
+
function formatProgress(loaded: number, total?: number): string {
|
|
90
|
+
if (total !== undefined) {
|
|
91
|
+
return `${loaded} of ${total}`;
|
|
92
|
+
}
|
|
93
|
+
return `${loaded} loaded`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// =============================================================================
|
|
97
|
+
// Error Recovery
|
|
98
|
+
// =============================================================================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Show error recovery prompt
|
|
102
|
+
* Returns 'retry' or 'cancel'
|
|
103
|
+
*/
|
|
104
|
+
async function showErrorRecovery(errorMessage: string): Promise<ErrorAction> {
|
|
105
|
+
const result = await p.select({
|
|
106
|
+
message: `Error: ${errorMessage}`,
|
|
107
|
+
options: [
|
|
108
|
+
{ value: 'retry' as const, label: 'Retry', hint: 'Try loading again' },
|
|
109
|
+
{ value: 'cancel' as const, label: 'Cancel', hint: 'Abort selection' },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (p.isCancel(result)) {
|
|
114
|
+
return 'cancel';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result as ErrorAction;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// Dynamic Select Implementation
|
|
122
|
+
// =============================================================================
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Handle dynamic select with pagination, filtering, and error recovery
|
|
126
|
+
*/
|
|
127
|
+
async function handleDynamicSelect<T>(
|
|
128
|
+
dynamicOptions: DynamicSelectOptions<T>
|
|
129
|
+
): Promise<T> {
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
const provider = dynamicOptions.provider;
|
|
132
|
+
const capabilities = provider.capabilities;
|
|
133
|
+
const supportsFilter = capabilities?.supportsFilter ?? false;
|
|
134
|
+
const filterDebounceMs = capabilities?.filterDebounceMs ?? DEFAULT_FILTER_DEBOUNCE_MS;
|
|
135
|
+
|
|
136
|
+
// State
|
|
137
|
+
const state: DynamicSelectState<T> = {
|
|
138
|
+
options: [],
|
|
139
|
+
nextCursor: null,
|
|
140
|
+
isLoading: true,
|
|
141
|
+
filter: undefined,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Load options from provider
|
|
145
|
+
async function loadOptions(
|
|
146
|
+
cursor?: string,
|
|
147
|
+
filter?: string,
|
|
148
|
+
append = false
|
|
149
|
+
): Promise<OptionsPage<T>> {
|
|
150
|
+
const result = await provider({
|
|
151
|
+
cursor,
|
|
152
|
+
filter,
|
|
153
|
+
signal: controller.signal,
|
|
154
|
+
});
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Initial load with spinner
|
|
159
|
+
const loadingMessage = dynamicOptions.loadingMessage ?? 'Loading...';
|
|
160
|
+
const spinner = p.spinner();
|
|
161
|
+
spinner.start(loadingMessage);
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const initialPage = await loadOptions();
|
|
165
|
+
state.options = initialPage.options;
|
|
166
|
+
state.nextCursor = initialPage.nextCursor ?? null;
|
|
167
|
+
state.totalCount = initialPage.totalCount;
|
|
168
|
+
state.isLoading = false;
|
|
169
|
+
spinner.stop(loadingMessage);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
spinner.stop('Failed to load options');
|
|
172
|
+
const errorMessage =
|
|
173
|
+
dynamicOptions.errorMessage ??
|
|
174
|
+
(error instanceof Error ? error.message : 'Failed to load options');
|
|
175
|
+
|
|
176
|
+
// Show error recovery
|
|
177
|
+
const action = await showErrorRecovery(errorMessage);
|
|
178
|
+
if (action === 'cancel') {
|
|
179
|
+
p.cancel('Cancelled');
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Retry - recursive call
|
|
184
|
+
return handleDynamicSelect(dynamicOptions);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check for empty results
|
|
188
|
+
if (state.options.length === 0) {
|
|
189
|
+
p.cancel('No options available');
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Main selection loop - handles "Load more" and re-selection
|
|
194
|
+
while (true) {
|
|
195
|
+
// Build options list
|
|
196
|
+
const displayOptions: { value: T | typeof LOAD_MORE_SYMBOL; label: string; hint?: string }[] =
|
|
197
|
+
[];
|
|
198
|
+
|
|
199
|
+
// Determine which options to show (filter if needed)
|
|
200
|
+
let optionsToShow = state.options;
|
|
201
|
+
if (state.filter && !supportsFilter) {
|
|
202
|
+
// Client-side filtering
|
|
203
|
+
optionsToShow = filterOptionsClientSide(state.options, state.filter);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Add user options
|
|
207
|
+
for (const opt of optionsToShow) {
|
|
208
|
+
displayOptions.push({
|
|
209
|
+
value: opt.value,
|
|
210
|
+
label: opt.label,
|
|
211
|
+
hint: opt.hint,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Add "Load more" if there are more pages
|
|
216
|
+
if (state.nextCursor) {
|
|
217
|
+
const loadMoreLabel = dynamicOptions.loadMoreLabel ?? 'Load more...';
|
|
218
|
+
const progress = formatProgress(state.options.length, state.totalCount);
|
|
219
|
+
displayOptions.push({
|
|
220
|
+
value: LOAD_MORE_SYMBOL,
|
|
221
|
+
label: loadMoreLabel,
|
|
222
|
+
hint: progress,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Handle case where filtering removed all visible options
|
|
227
|
+
if (displayOptions.length === 0) {
|
|
228
|
+
p.cancel('No matching options');
|
|
229
|
+
process.exit(0);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Show the select prompt
|
|
233
|
+
const result = await p.select({
|
|
234
|
+
message: dynamicOptions.message,
|
|
235
|
+
options: displayOptions as Parameters<typeof p.select>[0]['options'],
|
|
236
|
+
initialValue: dynamicOptions.initialValue,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (p.isCancel(result)) {
|
|
240
|
+
controller.abort();
|
|
241
|
+
process.exit(0);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Check if "Load more" was selected
|
|
245
|
+
if (result === LOAD_MORE_SYMBOL) {
|
|
246
|
+
// Load more options
|
|
247
|
+
const loadMoreSpinner = p.spinner();
|
|
248
|
+
loadMoreSpinner.start('Loading more...');
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const nextPage = await loadOptions(state.nextCursor ?? undefined);
|
|
252
|
+
|
|
253
|
+
// Append new options
|
|
254
|
+
state.options = [...state.options, ...nextPage.options];
|
|
255
|
+
state.nextCursor = nextPage.nextCursor ?? null;
|
|
256
|
+
// Keep totalCount from initial if not provided in subsequent pages
|
|
257
|
+
if (nextPage.totalCount !== undefined) {
|
|
258
|
+
state.totalCount = nextPage.totalCount;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
loadMoreSpinner.stop(
|
|
262
|
+
`Loaded ${state.options.length}${state.totalCount ? ` of ${state.totalCount}` : ''}`
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// Continue the loop to show updated options
|
|
266
|
+
continue;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
loadMoreSpinner.stop('Failed to load more');
|
|
269
|
+
const errorMessage =
|
|
270
|
+
error instanceof Error ? error.message : 'Failed to load more options';
|
|
271
|
+
|
|
272
|
+
// Show error recovery
|
|
273
|
+
const action = await showErrorRecovery(errorMessage);
|
|
274
|
+
if (action === 'cancel') {
|
|
275
|
+
p.cancel('Cancelled');
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Retry loading more - continue the loop
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// User selected an actual option
|
|
285
|
+
return result as T;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Handle dynamic select with typeahead filtering
|
|
291
|
+
* This creates a text input that filters options as you type
|
|
292
|
+
*/
|
|
293
|
+
async function handleDynamicSelectWithTypeahead<T>(
|
|
294
|
+
dynamicOptions: DynamicSelectOptions<T>
|
|
295
|
+
): Promise<T> {
|
|
296
|
+
const controller = new AbortController();
|
|
297
|
+
const provider = dynamicOptions.provider;
|
|
298
|
+
const capabilities = provider.capabilities;
|
|
299
|
+
const supportsServerFilter = capabilities?.supportsFilter ?? false;
|
|
300
|
+
const filterDebounceMs = capabilities?.filterDebounceMs ?? DEFAULT_FILTER_DEBOUNCE_MS;
|
|
301
|
+
|
|
302
|
+
// State
|
|
303
|
+
let allOptions: SelectOption<T>[] = [];
|
|
304
|
+
let nextCursor: string | null = null;
|
|
305
|
+
let totalCount: number | undefined;
|
|
306
|
+
let currentFilter = '';
|
|
307
|
+
|
|
308
|
+
// Load with optional filter
|
|
309
|
+
async function loadOptions(cursor?: string, filter?: string): Promise<OptionsPage<T>> {
|
|
310
|
+
return provider({
|
|
311
|
+
cursor,
|
|
312
|
+
filter: supportsServerFilter ? filter : undefined,
|
|
313
|
+
signal: controller.signal,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Initial load
|
|
318
|
+
const loadingMessage = dynamicOptions.loadingMessage ?? 'Loading...';
|
|
319
|
+
const spinner = p.spinner();
|
|
320
|
+
spinner.start(loadingMessage);
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const initialPage = await loadOptions();
|
|
324
|
+
allOptions = initialPage.options;
|
|
325
|
+
nextCursor = initialPage.nextCursor ?? null;
|
|
326
|
+
totalCount = initialPage.totalCount;
|
|
327
|
+
spinner.stop(loadingMessage);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
spinner.stop('Failed to load options');
|
|
330
|
+
const errorMessage =
|
|
331
|
+
dynamicOptions.errorMessage ??
|
|
332
|
+
(error instanceof Error ? error.message : 'Failed to load options');
|
|
333
|
+
|
|
334
|
+
const action = await showErrorRecovery(errorMessage);
|
|
335
|
+
if (action === 'cancel') {
|
|
336
|
+
p.cancel('Cancelled');
|
|
337
|
+
process.exit(0);
|
|
338
|
+
}
|
|
339
|
+
return handleDynamicSelectWithTypeahead(dynamicOptions);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (allOptions.length === 0) {
|
|
343
|
+
p.cancel('No options available');
|
|
344
|
+
process.exit(0);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// For server-side filtering, we need to refetch when filter changes
|
|
348
|
+
// For client-side filtering, we just filter the loaded options
|
|
349
|
+
// Since @clack/prompts doesn't have a built-in typeahead, we use the standard select
|
|
350
|
+
// The filtering is done before each render
|
|
351
|
+
|
|
352
|
+
// Main selection loop
|
|
353
|
+
while (true) {
|
|
354
|
+
// Apply filter
|
|
355
|
+
let displayOptions = allOptions;
|
|
356
|
+
if (currentFilter && !supportsServerFilter) {
|
|
357
|
+
displayOptions = filterOptionsClientSide(allOptions, currentFilter);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Build the options array
|
|
361
|
+
const selectOptions: { value: T | typeof LOAD_MORE_SYMBOL; label: string; hint?: string }[] =
|
|
362
|
+
displayOptions.map((opt) => ({
|
|
363
|
+
value: opt.value,
|
|
364
|
+
label: opt.label,
|
|
365
|
+
hint: opt.hint,
|
|
366
|
+
}));
|
|
367
|
+
|
|
368
|
+
// Add load more if available
|
|
369
|
+
if (nextCursor) {
|
|
370
|
+
const loadMoreLabel = dynamicOptions.loadMoreLabel ?? 'Load more...';
|
|
371
|
+
const progress = formatProgress(allOptions.length, totalCount);
|
|
372
|
+
selectOptions.push({
|
|
373
|
+
value: LOAD_MORE_SYMBOL,
|
|
374
|
+
label: loadMoreLabel,
|
|
375
|
+
hint: progress,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (selectOptions.length === 0) {
|
|
380
|
+
p.cancel('No matching options');
|
|
381
|
+
process.exit(0);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const result = await p.select({
|
|
385
|
+
message: dynamicOptions.message,
|
|
386
|
+
options: selectOptions as Parameters<typeof p.select>[0]['options'],
|
|
387
|
+
initialValue: dynamicOptions.initialValue,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
if (p.isCancel(result)) {
|
|
391
|
+
controller.abort();
|
|
392
|
+
process.exit(0);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (result === LOAD_MORE_SYMBOL) {
|
|
396
|
+
const loadMoreSpinner = p.spinner();
|
|
397
|
+
loadMoreSpinner.start('Loading more...');
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const nextPage = await loadOptions(nextCursor ?? undefined, currentFilter);
|
|
401
|
+
allOptions = [...allOptions, ...nextPage.options];
|
|
402
|
+
nextCursor = nextPage.nextCursor ?? null;
|
|
403
|
+
if (nextPage.totalCount !== undefined) {
|
|
404
|
+
totalCount = nextPage.totalCount;
|
|
405
|
+
}
|
|
406
|
+
loadMoreSpinner.stop(
|
|
407
|
+
`Loaded ${allOptions.length}${totalCount ? ` of ${totalCount}` : ''}`
|
|
408
|
+
);
|
|
409
|
+
continue;
|
|
410
|
+
} catch (error) {
|
|
411
|
+
loadMoreSpinner.stop('Failed to load more');
|
|
412
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to load more';
|
|
413
|
+
const action = await showErrorRecovery(errorMessage);
|
|
414
|
+
if (action === 'cancel') {
|
|
415
|
+
p.cancel('Cancelled');
|
|
416
|
+
process.exit(0);
|
|
417
|
+
}
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return result as T;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// =============================================================================
|
|
427
|
+
// Main Prompter Factory
|
|
428
|
+
// =============================================================================
|
|
15
429
|
|
|
16
430
|
/**
|
|
17
431
|
* Create a Prompter using @clack/prompts
|
|
@@ -19,6 +433,20 @@ import type {
|
|
|
19
433
|
export function createPrompter(): Prompter {
|
|
20
434
|
return {
|
|
21
435
|
async select<T>(options: SelectOptions<T>): Promise<T> {
|
|
436
|
+
// Handle dynamic options
|
|
437
|
+
if (isDynamicOptions(options)) {
|
|
438
|
+
const supportsFilter = options.provider.capabilities?.supportsFilter ?? false;
|
|
439
|
+
|
|
440
|
+
// Use typeahead handler if filtering is supported
|
|
441
|
+
if (supportsFilter) {
|
|
442
|
+
return handleDynamicSelectWithTypeahead(options);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Standard dynamic select with pagination
|
|
446
|
+
return handleDynamicSelect(options);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Static options - existing behavior
|
|
22
450
|
const result = await p.select({
|
|
23
451
|
message: options.message,
|
|
24
452
|
options: options.options as Parameters<typeof p.select<T>>[0]['options'],
|