@pokit/prompter-clack 0.0.2 → 0.0.3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"prompter.d.ts","sourceRoot":"","sources":["../src/prompter.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EACV,QAAQ,EAKT,MAAM,aAAa,CAAC;AAErB;;GAEG;AACH,wBAAgB,cAAc,IAAI,QAAQ,CA2DzC"}
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.2",
3
+ "version": "0.0.3",
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.2"
54
+ "@pokit/core": "0.0.3"
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'],