@fyeeme/pi-ask-user 2.0.0 → 2.0.1

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,642 @@
1
+ /**
2
+ * Legacy per-question ask path — migrated from oh-my-pi
3
+ * (packages/coding-agent/src/tools/ask.ts, askSingleQuestion and its
4
+ * custom-input title helpers).
5
+ *
6
+ * This path serves hosts without rich dialog support (RPC mode). Questions are
7
+ * asked one at a time through native select/input dialogs, mirroring omp's
8
+ * legacy selector flow: recommended suffixes, a multi-select toggle loop with
9
+ * a "Done selecting" terminal option, "Other" free-text via the editor, the
10
+ * timeout auto-selection with the TIMEOUT_DETECTION_TOLERANCE heuristic, and
11
+ * (i/n) progress titles.
12
+ *
13
+ * Adaptations for pi's extension API:
14
+ * - pi's ui.select accepts only `{ signal, timeout }` dialog options. The omp
15
+ * options (initialIndex, onTimeout*, onLeft/onRight, helpText,
16
+ * selectionMarker, checkedIndices, markableCount, outline) are still part
17
+ * of UIContext and still honored by selectOption()'s bookkeeping, but the
18
+ * pi adapter layer cannot forward them, so surfaces render without radio/
19
+ * checkbox markers, initial cursor placement, and ←/→ question navigation.
20
+ * The logic is kept 1:1 so hosts that do support the options (tests, future
21
+ * pi) light them up automatically.
22
+ * - Option descriptions cannot ride along in pi's string[] select; they are
23
+ * dropped from the visible list (labels stay).
24
+ * - omp's ui.editor(title, prefill, {signal}, {promptStyle}) maps to pi's
25
+ * ui.editor(title, prefill) (multi-line, no signal) with a fallback to
26
+ * ui.input on hosts without an editor; the signal race moves to a local
27
+ * untilAborted equivalent.
28
+ * - omp's untilAborted / AbortSignal.any utilities are re-implemented locally.
29
+ */
30
+
31
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
32
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
33
+ import { ELLIPSIS, replaceTabs, SYMBOLS } from "./compat.ts";
34
+ import type { AskOption, ExtensionUISelectItem } from "./types.ts";
35
+
36
+ export type { AskOption };
37
+
38
+ function getSelectOptionLabel(option: ExtensionUISelectItem): string {
39
+ return typeof option === "string" ? option : option.label;
40
+ }
41
+
42
+ function getSelectOptionDescription(option: ExtensionUISelectItem): string | undefined {
43
+ return typeof option === "string" ? undefined : option.description;
44
+ }
45
+
46
+ function toSelectOption(option: AskOption, label = option.label): ExtensionUISelectItem {
47
+ return option.description ? { label, description: option.description } : label;
48
+ }
49
+
50
+ export const RECOMMENDED_SUFFIX = " (Recommended)";
51
+ export const OTHER_OPTION = "Other (type your own)";
52
+ const CHAT_ABOUT_THIS_OPTION = "Chat about this";
53
+ export const NEXT_OPTION = "Next →";
54
+ /** Reserved runtime labels the model must not collide with (omp ask.ts). */
55
+ export const RESERVED_OPTION_LABELS: Record<string, true> = {
56
+ [OTHER_OPTION]: true,
57
+ [CHAT_ABOUT_THIS_OPTION]: true,
58
+ [NEXT_OPTION]: true,
59
+ };
60
+ // Window after the timeout deadline within which an `undefined` selection is
61
+ // attributed to a UI-enforced timeout (for surfaces that close the dialog at
62
+ // the deadline but never invoke `onTimeout`). Cancels beyond it are user Esc.
63
+ const TIMEOUT_DETECTION_TOLERANCE_MS = 1_000;
64
+
65
+ function getDoneOptionLabel(): string {
66
+ return `${SYMBOLS.status.success} Done selecting`;
67
+ }
68
+
69
+ /** Add "(Recommended)" suffix to the option at the given index if not already present */
70
+ export function addRecommendedSuffix(options: AskOption[], recommendedIndex?: number): ExtensionUISelectItem[] {
71
+ if (recommendedIndex === undefined || recommendedIndex < 0 || recommendedIndex >= options.length) {
72
+ return options.map(option => toSelectOption(option));
73
+ }
74
+ return options.map((option, i) => {
75
+ const label =
76
+ i === recommendedIndex && !option.label.endsWith(RECOMMENDED_SUFFIX)
77
+ ? option.label + RECOMMENDED_SUFFIX
78
+ : option.label;
79
+ return toSelectOption(option, label);
80
+ });
81
+ }
82
+
83
+ export function getAutoSelectionOnTimeout(options: AskOption[], recommended?: number): string[] {
84
+ if (options.length === 0) return [];
85
+ if (typeof recommended === "number" && recommended >= 0 && recommended < options.length) {
86
+ return [options[recommended]!.label];
87
+ }
88
+ return [options[0]!.label];
89
+ }
90
+
91
+ /** Strip "(Recommended)" suffix from a label */
92
+ export function stripRecommendedSuffix(label: string): string {
93
+ return label.endsWith(RECOMMENDED_SUFFIX) ? label.slice(0, -RECOMMENDED_SUFFIX.length) : label;
94
+ }
95
+
96
+ // --- untilAborted / signal composition (omp @oh-my-pi/pi-utils) ------------
97
+
98
+ function composeSignals(a: AbortSignal | undefined, b: AbortSignal | undefined): AbortSignal | undefined {
99
+ if (!a) return b;
100
+ if (!b) return a;
101
+ if (a.aborted || b.aborted) return AbortSignal.abort();
102
+ const controller = new AbortController();
103
+ const onAbort = (): void => controller.abort();
104
+ a.addEventListener("abort", onAbort, { once: true });
105
+ b.addEventListener("abort", onAbort, { once: true });
106
+ return controller.signal;
107
+ }
108
+
109
+ /** omp utils untilAborted: resolve `undefined` when the signal fires first,
110
+ * otherwise pass the wrapped promise's outcome through untouched. */
111
+ function untilAborted<T>(signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T | undefined> {
112
+ if (!signal) return run();
113
+ if (signal.aborted) return Promise.resolve(undefined);
114
+ return new Promise<T | undefined>((resolve, reject) => {
115
+ let settled = false;
116
+ const settleAbort = (): void => {
117
+ if (settled) return;
118
+ settled = true;
119
+ resolve(undefined);
120
+ };
121
+ signal.addEventListener("abort", settleAbort, { once: true });
122
+ run().then(
123
+ value => {
124
+ if (settled) return;
125
+ settled = true;
126
+ signal.removeEventListener("abort", settleAbort);
127
+ resolve(value);
128
+ },
129
+ error => {
130
+ if (settled) return;
131
+ settled = true;
132
+ signal.removeEventListener("abort", settleAbort);
133
+ reject(error);
134
+ },
135
+ );
136
+ });
137
+ }
138
+
139
+ // --- custom input title (omp buildCustomInputRows & friends) ----------------
140
+
141
+ interface CustomInputContext {
142
+ selectionMarker: "radio" | "checkbox";
143
+ checkedIndices?: readonly number[];
144
+ markableCount: number;
145
+ }
146
+
147
+ const MAX_CUSTOM_INPUT_OPTION_ROWS = 8;
148
+ const MAX_CUSTOM_INPUT_TITLE_ROWS = 16;
149
+ const MIN_CUSTOM_INPUT_CONTENT_WIDTH = 20;
150
+ const CUSTOM_INPUT_CHROME_COLUMNS = 8;
151
+ const CUSTOM_INPUT_DESCRIPTION_INDENT = " ";
152
+
153
+ function customInputContentWidth(): number {
154
+ const cols = process.stdout.columns ?? 80;
155
+ return Math.max(MIN_CUSTOM_INPUT_CONTENT_WIDTH, cols - CUSTOM_INPUT_CHROME_COLUMNS);
156
+ }
157
+
158
+ function clampLineToWidth(line: string, width: number): string {
159
+ if (visibleWidth(line) <= width) return line;
160
+ return truncateToWidth(line, width, ELLIPSIS);
161
+ }
162
+
163
+ function flattenDescription(text: string): string {
164
+ return text.replace(/\s+/g, " ").trim();
165
+ }
166
+
167
+ interface CustomInputOptionGap {
168
+ total: number;
169
+ checked: number;
170
+ }
171
+
172
+ interface CustomInputOptionWindow {
173
+ indices: number[];
174
+ gapBefore: Map<number, CustomInputOptionGap>;
175
+ }
176
+
177
+ /** Window the option list so the title stays bounded (omp pickCustomInputOptionWindow). */
178
+ function pickCustomInputOptionWindow(
179
+ total: number,
180
+ selectedIndex: number,
181
+ checked: ReadonlySet<number>,
182
+ ): CustomInputOptionWindow {
183
+ if (total === 0) return { indices: [], gapBefore: new Map() };
184
+ if (total <= MAX_CUSTOM_INPUT_OPTION_ROWS) {
185
+ return {
186
+ indices: Array.from({ length: total }, (_, i) => i),
187
+ gapBefore: new Map(),
188
+ };
189
+ }
190
+ const keep = new Set<number>();
191
+ const addIfRoom = (index: number) => {
192
+ if (index >= 0 && index < total && keep.size < MAX_CUSTOM_INPUT_OPTION_ROWS) {
193
+ keep.add(index);
194
+ }
195
+ };
196
+ addIfRoom(selectedIndex);
197
+ addIfRoom(0);
198
+ for (const i of [...checked].sort((a, b) => a - b)) {
199
+ addIfRoom(i);
200
+ }
201
+ for (let i = 0; i < total && keep.size < MAX_CUSTOM_INPUT_OPTION_ROWS; i++) {
202
+ addIfRoom(i);
203
+ }
204
+ const indices = [...keep].sort((a, b) => a - b);
205
+ const gapBefore = new Map<number, CustomInputOptionGap>();
206
+ const countCheckedBetween = (startInclusive: number, endExclusive: number): number => {
207
+ let count = 0;
208
+ for (const i of checked) {
209
+ if (i >= startInclusive && i < endExclusive) count++;
210
+ }
211
+ return count;
212
+ };
213
+ let prev = -1;
214
+ for (const idx of indices) {
215
+ if (idx > prev + 1) {
216
+ gapBefore.set(idx, {
217
+ total: idx - prev - 1,
218
+ checked: countCheckedBetween(prev + 1, idx),
219
+ });
220
+ }
221
+ prev = idx;
222
+ }
223
+ if (prev < total - 1) {
224
+ gapBefore.set(total, {
225
+ total: total - 1 - prev,
226
+ checked: countCheckedBetween(prev + 1, total),
227
+ });
228
+ }
229
+ return { indices, gapBefore };
230
+ }
231
+
232
+ interface CustomInputRow {
233
+ text: string;
234
+ priority: number;
235
+ }
236
+
237
+ function buildCustomInputRows(
238
+ question: string,
239
+ options: ExtensionUISelectItem[],
240
+ context: CustomInputContext,
241
+ contentWidth: number,
242
+ ): CustomInputRow[] {
243
+ const selectedIndex = options.findIndex(option => getSelectOptionLabel(option) === OTHER_OPTION);
244
+ const checked = new Set(context.checkedIndices ?? []);
245
+ const window = pickCustomInputOptionWindow(options.length, selectedIndex, checked);
246
+ const rows: CustomInputRow[] = [];
247
+ rows.push({ text: clampLineToWidth(question, contentWidth), priority: -1 });
248
+ rows.push({ text: "", priority: -1 });
249
+
250
+ const emitGap = (gap: CustomInputOptionGap) => {
251
+ const checkedSuffix = gap.checked > 0 ? `, ${gap.checked} checked` : "";
252
+ rows.push({
253
+ text: clampLineToWidth(
254
+ ` … ${gap.total} more option${gap.total === 1 ? "" : "s"}${checkedSuffix} …`,
255
+ contentWidth,
256
+ ),
257
+ priority: 2,
258
+ });
259
+ };
260
+
261
+ for (const index of window.indices) {
262
+ const gap = window.gapBefore.get(index);
263
+ if (gap !== undefined) emitGap(gap);
264
+ const option = options[index]!;
265
+ const label = getSelectOptionLabel(option);
266
+ const isSelected = index === selectedIndex;
267
+ const isMarkable = index < context.markableCount;
268
+ const prefix =
269
+ context.selectionMarker === "radio" && (isMarkable || isSelected)
270
+ ? `${isSelected ? SYMBOLS.radio.selected : SYMBOLS.radio.unselected} `
271
+ : context.selectionMarker === "checkbox" && isMarkable
272
+ ? `${checked.has(index) ? SYMBOLS.checkbox.checked : SYMBOLS.checkbox.unchecked} `
273
+ : isSelected
274
+ ? `${SYMBOLS.nav.cursor} `
275
+ : " ";
276
+ rows.push({ text: clampLineToWidth(prefix + label, contentWidth), priority: -1 });
277
+ const description = getSelectOptionDescription(option);
278
+ if (description) {
279
+ const flat = flattenDescription(description);
280
+ if (flat) {
281
+ rows.push({
282
+ text: clampLineToWidth(`${CUSTOM_INPUT_DESCRIPTION_INDENT}${flat}`, contentWidth),
283
+ priority: isSelected ? 2 : checked.has(index) ? 1 : 0,
284
+ });
285
+ }
286
+ }
287
+ }
288
+
289
+ const trailingGap = window.gapBefore.get(options.length);
290
+ if (trailingGap !== undefined) emitGap(trailingGap);
291
+ rows.push({ text: "", priority: -1 });
292
+ rows.push({ text: "Enter your response:", priority: -1 });
293
+ return rows;
294
+ }
295
+
296
+ function applyCustomInputRowBudget(rows: CustomInputRow[], budget: number): CustomInputRow[] {
297
+ if (rows.length <= budget) return rows;
298
+ const droppable = rows
299
+ .map((row, index) => ({ row, index }))
300
+ .filter(entry => entry.row.priority >= 0)
301
+ .sort((a, b) => a.row.priority - b.row.priority || b.index - a.index);
302
+ const removed = new Set<number>();
303
+ for (const { index } of droppable) {
304
+ if (rows.length - removed.size <= budget) break;
305
+ removed.add(index);
306
+ }
307
+ return rows.filter((_, i) => !removed.has(i));
308
+ }
309
+
310
+ export function formatCustomInputTitle(
311
+ question: string,
312
+ options: ExtensionUISelectItem[],
313
+ context: CustomInputContext,
314
+ ): string {
315
+ const contentWidth = customInputContentWidth();
316
+ const rows = buildCustomInputRows(question, options, context, contentWidth);
317
+ return applyCustomInputRowBudget(rows, MAX_CUSTOM_INPUT_TITLE_ROWS)
318
+ .map(row => row.text)
319
+ .join("\n");
320
+ }
321
+
322
+ // --- askSingleQuestion (omp ask.ts) ----------------------------------------
323
+
324
+ export interface SelectionResult {
325
+ selectedOptions: string[];
326
+ customInput?: string;
327
+ timedOut: boolean;
328
+ navigation?: "back" | "forward";
329
+ cancelled?: boolean;
330
+ }
331
+
332
+ export interface NavigationControls {
333
+ allowBack: boolean;
334
+ allowForward: boolean;
335
+ progressText?: string;
336
+ }
337
+
338
+ export interface AskSingleQuestionOptions {
339
+ recommended?: number;
340
+ timeout?: number;
341
+ signal?: AbortSignal;
342
+ initialSelection?: Pick<SelectionResult, "selectedOptions" | "customInput">;
343
+ navigation?: NavigationControls;
344
+ }
345
+
346
+ export interface UIContext {
347
+ timeoutStartsOnPresentation?: boolean;
348
+ select(
349
+ prompt: string,
350
+ options: ExtensionUISelectItem[],
351
+ options_?: {
352
+ initialIndex?: number;
353
+ timeout?: number;
354
+ signal?: AbortSignal;
355
+ outline?: boolean;
356
+ onTimeout?: () => void;
357
+ onTimeoutStart?: () => void;
358
+ onTimeoutReset?: () => void;
359
+ onLeft?: () => void;
360
+ onRight?: () => void;
361
+ helpText?: string;
362
+ selectionMarker?: "radio" | "checkbox";
363
+ checkedIndices?: readonly number[];
364
+ markableCount?: number;
365
+ },
366
+ ): Promise<string | undefined>;
367
+ editor(title: string, prefill?: string, dialogOptions?: { signal?: AbortSignal }): Promise<string | undefined>;
368
+ }
369
+
370
+ /** Adapt pi's ExtensionUIContext to the omp UIContext surface. */
371
+ export function uiContextFromExtension(extensionUi: Pick<ExtensionUIContext, "select" | "input" | "editor">): UIContext {
372
+ return {
373
+ select: (prompt, options, dialogOptions) =>
374
+ extensionUi.select(
375
+ prompt,
376
+ options.map(option => getSelectOptionLabel(option)),
377
+ {
378
+ ...(dialogOptions?.timeout !== undefined ? { timeout: dialogOptions.timeout } : {}),
379
+ ...(dialogOptions?.signal !== undefined ? { signal: dialogOptions.signal } : {}),
380
+ },
381
+ ),
382
+ editor: (title, prefill, dialogOptions) => {
383
+ const show = () => extensionUi.editor(title, prefill);
384
+ const input = () => extensionUi.input(title, prefill);
385
+ // Prefer pi's multi-line editor; fall back to input when absent.
386
+ const run = () => (typeof extensionUi.editor === "function" ? show() : input());
387
+ return dialogOptions?.signal ? untilAborted(dialogOptions.signal, run) : run();
388
+ },
389
+ };
390
+ }
391
+
392
+ export async function askSingleQuestion(
393
+ ui: UIContext,
394
+ question: string,
395
+ questionOptions: AskOption[],
396
+ multi: boolean,
397
+ options: AskSingleQuestionOptions = {},
398
+ ): Promise<SelectionResult> {
399
+ const { recommended, timeout, signal, initialSelection, navigation } = options;
400
+ const doneLabel = getDoneOptionLabel();
401
+ let selectedOptions = [...(initialSelection?.selectedOptions ?? [])];
402
+ let customInput = initialSelection?.customInput;
403
+ let timedOut = false;
404
+
405
+ const selectOption = async (
406
+ prompt: string,
407
+ optionsToShow: ExtensionUISelectItem[],
408
+ initialIndex?: number,
409
+ marker?: { selectionMarker: "radio" | "checkbox"; checkedIndices?: readonly number[]; markableCount: number },
410
+ ): Promise<{ choice: string | undefined; timedOut: boolean; navigation?: "back" | "forward" }> => {
411
+ let timeoutTriggered = false;
412
+ const onTimeout = () => {
413
+ timeoutTriggered = true;
414
+ };
415
+ let navigationAction: "back" | "forward" | undefined;
416
+ const timeoutMs = typeof timeout === "number" && timeout > 0 ? timeout : undefined;
417
+ const timeoutController = timeoutMs === undefined ? undefined : new AbortController();
418
+ const dialogSignal = composeSignals(signal, timeoutController?.signal);
419
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
420
+ let timeoutStartedMs = Date.now();
421
+ const armFallbackTimeout = (durationMs: number) => {
422
+ clearTimeout(timeoutId);
423
+ timeoutStartedMs = Date.now();
424
+ timeoutId = setTimeout(() => {
425
+ timeoutTriggered = true;
426
+ timeoutController?.abort();
427
+ }, durationMs);
428
+ };
429
+ const helpText = navigation
430
+ ? "up/down navigate enter select ←/→ question esc cancel"
431
+ : "up/down navigate enter select esc cancel";
432
+ const dialogOptions = {
433
+ initialIndex,
434
+ timeout,
435
+ signal: dialogSignal,
436
+ outline: true,
437
+ helpText,
438
+ onTimeout,
439
+ onTimeoutStart: timeoutMs === undefined ? undefined : () => armFallbackTimeout(timeoutMs),
440
+ onTimeoutReset: timeoutMs === undefined ? undefined : () => armFallbackTimeout(timeoutMs),
441
+ selectionMarker: marker?.selectionMarker,
442
+ checkedIndices: marker?.checkedIndices,
443
+ markableCount: marker?.markableCount,
444
+ onLeft: navigation?.allowBack
445
+ ? () => {
446
+ navigationAction = "back";
447
+ }
448
+ : undefined,
449
+ onRight: navigation?.allowForward
450
+ ? () => {
451
+ navigationAction = "forward";
452
+ }
453
+ : undefined,
454
+ };
455
+ try {
456
+ const runSelect = () => {
457
+ const selection = ui.select(prompt, optionsToShow, dialogOptions);
458
+ if (timeoutMs !== undefined && !ui.timeoutStartsOnPresentation) {
459
+ armFallbackTimeout(timeoutMs);
460
+ }
461
+ return selection;
462
+ };
463
+ const choice = await (dialogSignal ? untilAborted(dialogSignal, runSelect) : runSelect());
464
+ if (!timeoutTriggered && choice === undefined && typeof timeout === "number") {
465
+ // Fallback for UI surfaces that enforce `timeout` without invoking
466
+ // `onTimeout`: their auto-cancel resolves right at the deadline. A
467
+ // cancel arriving well past the deadline is a deliberate user Esc on
468
+ // a surface that kept the dialog open — keep treating it as a cancel.
469
+ const elapsed = Date.now() - timeoutStartedMs;
470
+ timeoutTriggered = elapsed >= timeout && elapsed <= timeout + TIMEOUT_DETECTION_TOLERANCE_MS;
471
+ }
472
+ return { choice, timedOut: timeoutTriggered, navigation: navigationAction };
473
+ } catch (error) {
474
+ if (timeoutTriggered && error instanceof Error && error.name === "AbortError") {
475
+ return { choice: undefined, timedOut: true, navigation: navigationAction };
476
+ }
477
+ throw error;
478
+ } finally {
479
+ clearTimeout(timeoutId);
480
+ }
481
+ };
482
+
483
+ const promptForCustomInput = async (
484
+ title: string,
485
+ optionsToShow: ExtensionUISelectItem[],
486
+ context: CustomInputContext,
487
+ ): Promise<{ input: string | undefined }> => {
488
+ const dialogOptions = signal ? { signal } : undefined;
489
+ const editorTitle = formatCustomInputTitle(title, optionsToShow, context);
490
+ const showCustomInput = () => ui.editor(editorTitle, undefined, dialogOptions);
491
+ const input = signal ? await untilAborted(signal, showCustomInput) : await showCustomInput();
492
+ return { input };
493
+ };
494
+
495
+ const promptWithProgress = navigation?.progressText ? `${question} (${navigation.progressText})` : question;
496
+ if (multi) {
497
+ const selected = new Set<string>(selectedOptions);
498
+ let cursorIndex = Math.min(Math.max(recommended ?? 0, 0), Math.max(questionOptions.length - 1, 0));
499
+ const firstSelected = selectedOptions[0];
500
+ if (firstSelected) {
501
+ const selectedIndex = questionOptions.findIndex(option => option.label === firstSelected);
502
+ if (selectedIndex >= 0) cursorIndex = selectedIndex;
503
+ }
504
+ while (true) {
505
+ const opts: ExtensionUISelectItem[] = questionOptions.map(opt => toSelectOption(opt));
506
+
507
+ if (!navigation?.allowForward && selected.size > 0) {
508
+ opts.push(doneLabel);
509
+ }
510
+ opts.push(OTHER_OPTION);
511
+
512
+ const checkedIndices: number[] = [];
513
+ for (let i = 0; i < questionOptions.length; i++) {
514
+ if (selected.has(questionOptions[i]!.label)) checkedIndices.push(i);
515
+ }
516
+ const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
517
+ const {
518
+ choice,
519
+ timedOut: selectTimedOut,
520
+ navigation: arrowNavigation,
521
+ } = await selectOption(`${prefix}${promptWithProgress}`, opts, cursorIndex, {
522
+ selectionMarker: "checkbox",
523
+ checkedIndices,
524
+ markableCount: questionOptions.length,
525
+ });
526
+
527
+ if (arrowNavigation) {
528
+ return { selectedOptions: Array.from(selected), customInput, timedOut, navigation: arrowNavigation };
529
+ }
530
+ if (choice === undefined) {
531
+ if (selectTimedOut) {
532
+ timedOut = true;
533
+ break;
534
+ }
535
+ return { selectedOptions: Array.from(selected), customInput, timedOut, cancelled: true };
536
+ }
537
+ if (choice === doneLabel) break;
538
+
539
+ if (choice === OTHER_OPTION) {
540
+ if (selectTimedOut) {
541
+ timedOut = true;
542
+ break;
543
+ }
544
+ const customResult = await promptForCustomInput(`${prefix}${promptWithProgress}`, opts, {
545
+ selectionMarker: "checkbox",
546
+ checkedIndices,
547
+ markableCount: questionOptions.length,
548
+ });
549
+ if (customResult.input === undefined) {
550
+ continue;
551
+ }
552
+ customInput = customResult.input;
553
+ break;
554
+ }
555
+
556
+ const selectedIdx = opts.findIndex(opt => getSelectOptionLabel(opt) === choice);
557
+ if (selectedIdx >= 0) {
558
+ cursorIndex = selectedIdx;
559
+ }
560
+
561
+ if (selected.has(choice)) {
562
+ selected.delete(choice);
563
+ } else {
564
+ selected.add(choice);
565
+ }
566
+
567
+ if (selectTimedOut) {
568
+ timedOut = true;
569
+ break;
570
+ }
571
+ }
572
+ selectedOptions = Array.from(selected);
573
+ } else {
574
+ while (true) {
575
+ const displayOptions = addRecommendedSuffix(questionOptions, recommended);
576
+ const optionsWithNavigation: ExtensionUISelectItem[] = [...displayOptions, OTHER_OPTION];
577
+
578
+ let initialIndex = recommended;
579
+ const previouslySelected = selectedOptions[0];
580
+ if (previouslySelected) {
581
+ const selectedIndex = questionOptions.findIndex(option => option.label === previouslySelected);
582
+ if (selectedIndex >= 0) initialIndex = selectedIndex;
583
+ } else if (customInput !== undefined) {
584
+ initialIndex = displayOptions.length;
585
+ }
586
+ if (initialIndex !== undefined) {
587
+ const maxIndex = Math.max(optionsWithNavigation.length - 1, 0);
588
+ initialIndex = Math.max(0, Math.min(initialIndex, maxIndex));
589
+ }
590
+
591
+ const {
592
+ choice,
593
+ timedOut: selectTimedOut,
594
+ navigation: arrowNavigation,
595
+ } = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex, {
596
+ selectionMarker: "radio",
597
+ markableCount: displayOptions.length,
598
+ });
599
+ timedOut = selectTimedOut;
600
+
601
+ if (arrowNavigation) {
602
+ return { selectedOptions, customInput, timedOut, navigation: arrowNavigation };
603
+ }
604
+ if (choice === undefined) {
605
+ if (!timedOut) {
606
+ return { selectedOptions, customInput, timedOut, cancelled: true };
607
+ }
608
+ break;
609
+ }
610
+ if (choice === OTHER_OPTION) {
611
+ if (selectTimedOut) {
612
+ break;
613
+ }
614
+ const customResult = await promptForCustomInput(promptWithProgress, optionsWithNavigation, {
615
+ selectionMarker: "radio",
616
+ markableCount: displayOptions.length,
617
+ });
618
+ if (customResult.input === undefined) {
619
+ continue;
620
+ }
621
+ customInput = customResult.input;
622
+ selectedOptions = [];
623
+ break;
624
+ }
625
+ selectedOptions = [stripRecommendedSuffix(choice)];
626
+ customInput = undefined;
627
+ break;
628
+ }
629
+ if (timedOut && selectedOptions.length === 0 && customInput === undefined) {
630
+ selectedOptions = getAutoSelectionOnTimeout(questionOptions, recommended);
631
+ }
632
+ if (navigation?.allowForward) {
633
+ return { selectedOptions, customInput, timedOut, navigation: "forward" };
634
+ }
635
+ }
636
+
637
+ if (timedOut && selectedOptions.length === 0 && customInput === undefined) {
638
+ selectedOptions = getAutoSelectionOnTimeout(questionOptions, recommended);
639
+ }
640
+
641
+ return { selectedOptions, customInput, timedOut };
642
+ }