pi-soly 0.3.0 → 0.5.0

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/ask/picker.ts ADDED
@@ -0,0 +1,686 @@
1
+ // =============================================================================
2
+ // picker.ts — Claude Code-style multi-question picker TUI component
3
+ // =============================================================================
4
+ //
5
+ // Renders a tabbed multi-question flow inside pi's TUI. One `ask_pro` tool
6
+ // call can show N questions; the user navigates between them with Tab/arrows
7
+ // or instant-picks with 1-N. For multi-select questions, Enter toggles and
8
+ // the last question's Enter submits. For single-select (default), Enter on
9
+ // an option auto-advances to the next question (or submits on the last).
10
+ //
11
+ // Per-question `allowOther: true` appends a synthetic "Other…" option that
12
+ // opens a text-input dialog when picked. The custom string is stored as the
13
+ // answer (string for single-select, pushed into the array for multi-select).
14
+ // =============================================================================
15
+
16
+ import {
17
+ Container,
18
+ Text,
19
+ Spacer,
20
+ type Component,
21
+ type KeybindingsManager,
22
+ } from "@earendil-works/pi-tui";
23
+
24
+ /** Minimal theme shape we need. Matches pi-coding-agent's Theme.fg / .bold. */
25
+ export interface AskProTheme {
26
+ fg: (color: string, text: string) => string;
27
+ bold: (text: string) => string;
28
+ }
29
+
30
+ export interface AskOption {
31
+ label: string;
32
+ description?: string;
33
+ recommended?: boolean;
34
+ }
35
+
36
+ export interface AskQuestion {
37
+ /** Short label shown in the tab (e.g. "Auth", "Tokens"). 1-2 words. */
38
+ header: string;
39
+ /** The full question. */
40
+ question: string;
41
+ /** 2-4 options. */
42
+ options: AskOption[];
43
+ /** If true, user can pick multiple options (checkboxes). Default false. */
44
+ multiSelect?: boolean;
45
+ /** If true, append a synthetic "Other…" option that opens a text-input
46
+ * dialog when picked. The custom string is stored as the answer.
47
+ * Default false. */
48
+ allowOther?: boolean;
49
+ }
50
+
51
+ /** Single-pick answer: either an option index (0..N-1) or a custom string
52
+ * (when "Other…" was picked and the user typed something). */
53
+ export type AskAnswer = number | string;
54
+ /** Multi-pick answer: a heterogeneous array of option indices + custom strings. */
55
+ export type AskMultiAnswer = Array<AskAnswer>;
56
+
57
+ export interface AskProResult {
58
+ /** Set if the user cancelled (Esc). Other fields are absent. */
59
+ cancelled?: boolean;
60
+ /** Map of question index → answer. Single: number | string. Multi: (number | string)[] */
61
+ answers?: Record<number, AskAnswer | AskMultiAnswer>;
62
+ }
63
+
64
+ /** Options for the text-input dialog opened when "Other…" is picked. */
65
+ export interface AskProInputRequest {
66
+ title: string;
67
+ prompt: string;
68
+ placeholder?: string;
69
+ }
70
+
71
+ interface AskProComponentDeps {
72
+ questions: AskQuestion[];
73
+ theme: AskProTheme;
74
+ keybindings: KeybindingsManager;
75
+ done: (result: AskProResult) => void;
76
+ /** Optional title shown above the tabs. */
77
+ title?: string;
78
+ /** Open a text-input dialog for the "Other…" option. Returns the typed
79
+ * text, or undefined if the user cancelled. If omitted, the "Other…"
80
+ * option is hidden even when `allowOther: true` (caller should ensure
81
+ * the dependency is present if it advertises allowOther). */
82
+ onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Keycode constants — pi uses raw escape sequences for arrows and a few other
87
+ // special keys. Tab is a single \t. Enter is \n or \r. Esc is \x1b.
88
+ // ---------------------------------------------------------------------------
89
+
90
+ const KEY_ESC = "\x1b";
91
+ const KEY_TAB = "\t";
92
+ const KEY_ENTER = "\n";
93
+ const KEY_ENTER_CR = "\r";
94
+ const KEY_SPACE = " ";
95
+ const KEY_UP = "\x1b[A";
96
+ const KEY_DOWN = "\x1b[B";
97
+ const KEY_RIGHT = "\x1b[C";
98
+ const KEY_LEFT = "\x1b[D";
99
+ const KEY_SHIFT_TAB = "\x1b[Z";
100
+ const KEY_BACKSPACE = "\x7f";
101
+
102
+ /** A standalone picker component. Extends Container so it composes in the
103
+ * editor area like any other TUI widget. */
104
+ export class AskProComponent extends Container {
105
+ private questions: AskQuestion[];
106
+ private theme: AskProTheme;
107
+ private keybindings: KeybindingsManager;
108
+ private done: (result: AskProResult) => void;
109
+ private onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
110
+ private title: string;
111
+
112
+ private currentIndex = 0;
113
+ private selectedIndex = 0;
114
+ /** answers[questionIdx] = AskAnswer (single) or AskMultiAnswer (multi). */
115
+ private answers = new Map<number, AskAnswer | AskMultiAnswer>();
116
+ /** Set true once `done` is called — further input is ignored. */
117
+ private completed = false;
118
+ /** Set while a text-input dialog is awaiting the user's reply. */
119
+ private awaitingInput = false;
120
+
121
+ private tabsText!: Text;
122
+ private bodyContainer!: Container;
123
+ private footerText!: Text;
124
+
125
+ constructor(deps: AskProComponentDeps) {
126
+ super();
127
+ this.questions = deps.questions;
128
+ this.theme = deps.theme;
129
+ this.keybindings = deps.keybindings;
130
+ this.done = deps.done;
131
+ this.onRequestInput = deps.onRequestInput;
132
+ this.title = deps.title ?? "pi-ask";
133
+
134
+ const titleText = new Text(this.theme.fg("accent", this.theme.bold(this.title)), 1, 0);
135
+ this.addChild(titleText);
136
+ this.addChild(new Spacer(1));
137
+
138
+ this.tabsText = new Text("", 1, 0);
139
+ this.addChild(this.tabsText);
140
+ this.addChild(new Spacer(1));
141
+
142
+ this.bodyContainer = new Container();
143
+ this.addChild(this.bodyContainer);
144
+
145
+ this.addChild(new Spacer(1));
146
+ this.footerText = new Text("", 1, 0);
147
+ this.addChild(this.footerText);
148
+
149
+ this.repaint();
150
+ }
151
+
152
+ // -------------------------------------------------------------------------
153
+ // Public state accessors (used by tests; safe to call from outside)
154
+ // -------------------------------------------------------------------------
155
+
156
+ getCurrentIndex(): number {
157
+ return this.currentIndex;
158
+ }
159
+
160
+ getSelectedIndex(): number {
161
+ return this.selectedIndex;
162
+ }
163
+
164
+ getAnswers(): Map<number, AskAnswer | AskMultiAnswer> {
165
+ return new Map(this.answers);
166
+ }
167
+
168
+ // -------------------------------------------------------------------------
169
+ // Rendering — updates the Text/Container children; the TUI re-renders
170
+ // the whole tree on its next render cycle, picking up our changes.
171
+ // -------------------------------------------------------------------------
172
+
173
+ private repaint(): void {
174
+ this.tabsText.setText(this.renderTabs());
175
+ this.renderQuestionBody();
176
+ this.footerText.setText(this.renderFooter());
177
+ }
178
+
179
+ private renderTabs(): string {
180
+ return this.questions
181
+ .map((q, i) => {
182
+ const answered = this.isAnswered(i);
183
+ const active = i === this.currentIndex;
184
+ const marker = active ? "◉" : answered ? "✓" : "○";
185
+ const label = q.header.length > 12 ? `${q.header.slice(0, 11)}…` : q.header;
186
+ const color = active ? "accent" : answered ? "success" : "dim";
187
+ return this.theme.fg(color, `${marker} ${label}`);
188
+ })
189
+ .join(this.theme.fg("dim", " "));
190
+ }
191
+
192
+ private renderQuestionBody(): void {
193
+ this.bodyContainer.clear();
194
+ const q = this.questions[this.currentIndex];
195
+ if (!q) return;
196
+
197
+ // Question line: "Q1 of 3: <question>"
198
+ this.bodyContainer.addChild(
199
+ new Text(
200
+ this.theme.fg("dim", `Q${this.currentIndex + 1} of ${this.questions.length}: `) +
201
+ this.theme.bold(q.question),
202
+ 1,
203
+ 0,
204
+ ),
205
+ );
206
+ this.bodyContainer.addChild(new Spacer(1));
207
+
208
+ const isMulti = q.multiSelect ?? false;
209
+ const allowOther = q.allowOther ?? false;
210
+ const currentAns = this.answers.get(this.currentIndex);
211
+
212
+ // Real options
213
+ for (let i = 0; i < q.options.length; i++) {
214
+ const opt = q.options[i];
215
+ if (!opt) continue;
216
+ const isSelected = i === this.selectedIndex;
217
+
218
+ // Cursor
219
+ const cursor = isSelected ? this.theme.fg("accent", "❯ ") : " ";
220
+
221
+ // Checkbox (multi) or radio (single)
222
+ let prefix: string;
223
+ if (isMulti) {
224
+ const isChecked = this.isIndexChecked(currentAns, i);
225
+ prefix = (isChecked ? "☒" : "☐") + " ";
226
+ prefix = this.theme.fg(isChecked ? "success" : "dim", prefix);
227
+ } else {
228
+ const isChosen = currentAns === i;
229
+ prefix = (isChosen ? "●" : "○") + " ";
230
+ prefix = this.theme.fg(isChosen ? "success" : "dim", prefix);
231
+ }
232
+
233
+ // ⭐ prefix for recommended
234
+ const star = opt.recommended ? this.theme.fg("warning", "⭐ ") : "";
235
+
236
+ // Label — accent if selected, text otherwise
237
+ const labelText = `${star}${opt.label}`;
238
+ const label = this.theme.fg(isSelected ? "accent" : "text", labelText);
239
+
240
+ this.bodyContainer.addChild(new Text(cursor + prefix + label, 1, 0));
241
+
242
+ // Description on its own line
243
+ if (opt.description) {
244
+ this.bodyContainer.addChild(
245
+ new Text(" " + this.theme.fg("dim", opt.description), 1, 0),
246
+ );
247
+ }
248
+ }
249
+
250
+ // Synthetic "Other…" option (when allowOther=true)
251
+ if (allowOther && this.onRequestInput) {
252
+ const otherIndex = q.options.length;
253
+ const isOtherSelected = this.selectedIndex === otherIndex;
254
+ const customStr = this.getCustomString(currentAns);
255
+
256
+ const cursor = isOtherSelected ? this.theme.fg("accent", "❯ ") : " ";
257
+
258
+ if (isMulti) {
259
+ const isChecked = this.isCustomStringChecked(currentAns);
260
+ const prefix = (isChecked ? "☒" : "☐") + " ";
261
+ const prefixStyled = this.theme.fg(isChecked ? "success" : "dim", prefix);
262
+ const labelInner = customStr
263
+ ? `Other: ${this.theme.bold(customStr)}`
264
+ : "Other…";
265
+ const labelStyled = this.theme.fg(
266
+ isOtherSelected ? "accent" : "text",
267
+ labelInner,
268
+ );
269
+ this.bodyContainer.addChild(
270
+ new Text(cursor + prefixStyled + labelStyled, 1, 0),
271
+ );
272
+ if (isOtherSelected && !customStr) {
273
+ this.bodyContainer.addChild(
274
+ new Text(
275
+ " " +
276
+ this.theme.fg("dim", "(press Enter to type a custom answer)"),
277
+ 1,
278
+ 0,
279
+ ),
280
+ );
281
+ }
282
+ } else {
283
+ const isChosen = typeof currentAns === "string";
284
+ const prefix = (isChosen ? "●" : "○") + " ";
285
+ const prefixStyled = this.theme.fg(isChosen ? "success" : "dim", prefix);
286
+ const labelInner = customStr
287
+ ? `Other: ${this.theme.bold(customStr)}`
288
+ : "Other…";
289
+ const labelStyled = this.theme.fg(
290
+ isOtherSelected ? "accent" : "text",
291
+ labelInner,
292
+ );
293
+ this.bodyContainer.addChild(
294
+ new Text(cursor + prefixStyled + labelStyled, 1, 0),
295
+ );
296
+ if (isOtherSelected && !customStr) {
297
+ this.bodyContainer.addChild(
298
+ new Text(
299
+ " " +
300
+ this.theme.fg("dim", "(press Enter to type a custom answer)"),
301
+ 1,
302
+ 0,
303
+ ),
304
+ );
305
+ }
306
+ }
307
+ }
308
+
309
+ // For multiSelect on the LAST question, also show a "Submit" row at
310
+ // the bottom (visual hint — pressing Enter on it submits).
311
+ if (isMulti && this.currentIndex === this.questions.length - 1) {
312
+ this.bodyContainer.addChild(new Spacer(1));
313
+ const allAnswered = this.allAnswered();
314
+ const submitLabel = allAnswered ? "▶ Submit answers" : "▶ Submit (need to answer all)";
315
+ this.bodyContainer.addChild(
316
+ new Text(
317
+ this.theme.fg(allAnswered ? "accent" : "dim", submitLabel),
318
+ 1,
319
+ 0,
320
+ ),
321
+ );
322
+ }
323
+ }
324
+
325
+ // -------------------------------------------------------------------------
326
+ // State helpers
327
+ // -------------------------------------------------------------------------
328
+
329
+ /** Is option `idx` currently in the answer (multi only)? */
330
+ private isIndexChecked(ans: AskAnswer | AskMultiAnswer | undefined, idx: number): boolean {
331
+ if (ans === undefined) return false;
332
+ if (typeof ans === "number" || typeof ans === "string") return false;
333
+ return (ans as AskMultiAnswer).includes(idx);
334
+ }
335
+
336
+ /** Is a custom string currently in the multi answer? */
337
+ private isCustomStringChecked(ans: AskAnswer | AskMultiAnswer | undefined): boolean {
338
+ if (ans === undefined) return false;
339
+ if (typeof ans === "number" || typeof ans === "string") return false;
340
+ return (ans as AskMultiAnswer).some((a) => typeof a === "string");
341
+ }
342
+
343
+ /** Extract the current custom string (for single-pick or the first
344
+ * string in a multi answer). Returns "" if no custom string. */
345
+ private getCustomString(ans: AskAnswer | AskMultiAnswer | undefined): string {
346
+ if (ans === undefined) return "";
347
+ if (typeof ans === "string") return ans;
348
+ const found = (ans as AskMultiAnswer).find((a) => typeof a === "string");
349
+ return typeof found === "string" ? found : "";
350
+ }
351
+
352
+ /** Total number of selectable rows for the current question (options + Other). */
353
+ private totalOptionsForCurrent(): number {
354
+ const q = this.questions[this.currentIndex];
355
+ if (!q) return 0;
356
+ const allowOther = q.allowOther ?? false;
357
+ return q.options.length + (allowOther && this.onRequestInput ? 1 : 0);
358
+ }
359
+
360
+ // -------------------------------------------------------------------------
361
+ // Rendering — tabs and footer
362
+ // -------------------------------------------------------------------------
363
+
364
+ private renderFooter(): string {
365
+ const q = this.questions[this.currentIndex];
366
+ if (!q) return "";
367
+ const isMulti = q.multiSelect ?? false;
368
+ const isLast = this.currentIndex === this.questions.length - 1;
369
+ const allowOther = q.allowOther ?? false;
370
+ const otherIndex = allowOther ? q.options.length : -1;
371
+ const totalOptions = this.totalOptionsForCurrent();
372
+
373
+ const parts: string[] = [];
374
+ parts.push(this.theme.fg("dim", "↑↓ navigate"));
375
+ parts.push(this.theme.fg("dim", `1-${totalOptions} pick`));
376
+ if (this.currentIndex > 0) parts.push(this.theme.fg("dim", "tab/← prev"));
377
+ if (this.currentIndex < this.questions.length - 1) {
378
+ parts.push(this.theme.fg("dim", "tab/→ next"));
379
+ }
380
+ // "Other…" hint: single-select uses Enter, multi-select uses Space
381
+ if (allowOther && this.onRequestInput && this.selectedIndex === otherIndex) {
382
+ parts.push(this.theme.fg("accent", isMulti ? "␣ type" : "⏎ type"));
383
+ } else if (isMulti) {
384
+ // Multi-select: Space toggles, Enter advances/submits
385
+ parts.push(this.theme.fg("dim", "␣ toggle"));
386
+ if (isLast) {
387
+ parts.push(
388
+ this.theme.fg(
389
+ this.allAnswered() ? "accent" : "dim",
390
+ this.allAnswered() ? "⏎ submit" : "⏎ (answer all)",
391
+ ),
392
+ );
393
+ } else {
394
+ parts.push(this.theme.fg("dim", "⏎ next"));
395
+ }
396
+ } else {
397
+ // Single-select: Enter is the action key
398
+ parts.push(this.theme.fg("accent", isLast ? "⏎ submit" : "⏎ next"));
399
+ }
400
+ parts.push(this.theme.fg("dim", "esc cancel"));
401
+ return parts.join(" ");
402
+ }
403
+
404
+ private isAnswered(qIdx: number): boolean {
405
+ const a = this.answers.get(qIdx);
406
+ if (a === undefined) return false;
407
+ if (Array.isArray(a)) return a.length > 0;
408
+ return true;
409
+ }
410
+
411
+ private allAnswered(): boolean {
412
+ for (let i = 0; i < this.questions.length; i++) {
413
+ if (!this.isAnswered(i)) return false;
414
+ }
415
+ return true;
416
+ }
417
+
418
+ // -------------------------------------------------------------------------
419
+ // Key handling
420
+ // -------------------------------------------------------------------------
421
+
422
+ handleInput(keyData: string): void {
423
+ if (this.completed || this.awaitingInput) return;
424
+
425
+ // Esc — cancel
426
+ if (keyData === KEY_ESC) {
427
+ this.completed = true;
428
+ this.done({ cancelled: true });
429
+ return;
430
+ }
431
+
432
+ const q = this.questions[this.currentIndex];
433
+ if (!q) return;
434
+ const isMulti = q.multiSelect ?? false;
435
+ const allowOther = q.allowOther ?? false;
436
+ const otherIndex = allowOther ? q.options.length : -1;
437
+ const totalOptions = this.totalOptionsForCurrent();
438
+
439
+ // Arrow up / k — move selection up
440
+ if (
441
+ this.keybindings.matches(keyData, "tui.select.up") ||
442
+ keyData === "k" ||
443
+ keyData === KEY_UP
444
+ ) {
445
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
446
+ this.repaint();
447
+ return;
448
+ }
449
+
450
+ // Arrow down / j — move selection down
451
+ if (
452
+ this.keybindings.matches(keyData, "tui.select.down") ||
453
+ keyData === "j" ||
454
+ keyData === KEY_DOWN
455
+ ) {
456
+ this.selectedIndex = Math.min(totalOptions - 1, this.selectedIndex + 1);
457
+ this.repaint();
458
+ return;
459
+ }
460
+
461
+ // Tab / Right arrow — next question
462
+ if (keyData === KEY_TAB || keyData === KEY_RIGHT) {
463
+ if (this.currentIndex < this.questions.length - 1) {
464
+ this.currentIndex++;
465
+ this.selectedIndex = 0;
466
+ this.repaint();
467
+ }
468
+ return;
469
+ }
470
+
471
+ // Shift+Tab / Left arrow — prev question
472
+ if (keyData === KEY_SHIFT_TAB || keyData === KEY_LEFT) {
473
+ if (this.currentIndex > 0) {
474
+ this.currentIndex--;
475
+ this.selectedIndex = 0;
476
+ this.repaint();
477
+ }
478
+ return;
479
+ }
480
+
481
+ // Backspace — also prev question (common convention)
482
+ if (keyData === KEY_BACKSPACE && this.currentIndex > 0) {
483
+ this.currentIndex--;
484
+ this.selectedIndex = 0;
485
+ this.repaint();
486
+ return;
487
+ }
488
+
489
+ // Number keys 1-N — instant pick (including "Other…" at position N+1)
490
+ const num = parseInt(keyData, 10);
491
+ if (!isNaN(num) && num >= 1 && num <= totalOptions) {
492
+ this.handlePick(num - 1);
493
+ return;
494
+ }
495
+
496
+ // Space — toggle in multi-select (Claude Code convention).
497
+ // On "Other…", opens the input dialog (or toggles existing custom string).
498
+ // In single-select, Space is a no-op (Enter is the action key there).
499
+ if (keyData === KEY_SPACE) {
500
+ if (!isMulti) return;
501
+ // On Other… → open input dialog (or re-toggle existing custom string)
502
+ if (allowOther && this.onRequestInput && this.selectedIndex === otherIndex) {
503
+ void this.requestOtherInput();
504
+ return;
505
+ }
506
+ const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
507
+ const idx = cur.indexOf(this.selectedIndex);
508
+ if (idx === -1) cur.push(this.selectedIndex);
509
+ else cur.splice(idx, 1);
510
+ this.answers.set(this.currentIndex, cur);
511
+ this.repaint();
512
+ return;
513
+ }
514
+
515
+ // Enter — confirm / advance / submit (universal confirm gesture).
516
+ // In single-select: picks the option, then advances or submits.
517
+ // In multi-select: skips toggle (use Space for that); just advances
518
+ // or submits. On the LAST question, if all answered, Enter submits.
519
+ if (
520
+ this.keybindings.matches(keyData, "tui.select.confirm") ||
521
+ keyData === KEY_ENTER ||
522
+ keyData === KEY_ENTER_CR
523
+ ) {
524
+ // If "Other…" is the selected option in single-select, open
525
+ // the input dialog. In multi-select, Enter on Other… just
526
+ // advances (use Space to toggle/type a custom answer).
527
+ if (
528
+ allowOther &&
529
+ this.onRequestInput &&
530
+ this.selectedIndex === otherIndex &&
531
+ !isMulti
532
+ ) {
533
+ void this.requestOtherInput();
534
+ return;
535
+ }
536
+
537
+ if (isMulti) {
538
+ // On the LAST question, if all questions are answered, Enter
539
+ // submits. Otherwise it advances (if not last) or stays put
540
+ // (on last + not all answered — user must finish first).
541
+ if (
542
+ this.currentIndex === this.questions.length - 1 &&
543
+ this.allAnswered()
544
+ ) {
545
+ this.submit();
546
+ return;
547
+ }
548
+ if (this.currentIndex < this.questions.length - 1) {
549
+ this.currentIndex++;
550
+ this.selectedIndex = 0;
551
+ }
552
+ this.repaint();
553
+ } else {
554
+ // Single-select: set current as answer, then advance or submit
555
+ this.answers.set(this.currentIndex, this.selectedIndex);
556
+ if (this.currentIndex < this.questions.length - 1) {
557
+ this.currentIndex++;
558
+ this.selectedIndex = 0;
559
+ this.repaint();
560
+ } else if (this.allAnswered()) {
561
+ this.submit();
562
+ } else {
563
+ this.repaint();
564
+ }
565
+ }
566
+ return;
567
+ }
568
+ }
569
+
570
+ private handlePick(optionIdx: number): void {
571
+ const q = this.questions[this.currentIndex];
572
+ if (!q) return;
573
+ const isMulti = q.multiSelect ?? false;
574
+ const allowOther = q.allowOther ?? false;
575
+ const otherIndex = allowOther ? q.options.length : -1;
576
+
577
+ // "Other…" picked via number key
578
+ if (allowOther && this.onRequestInput && optionIdx === otherIndex) {
579
+ void this.requestOtherInput();
580
+ return;
581
+ }
582
+
583
+ if (isMulti) {
584
+ const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
585
+ const idx = cur.indexOf(optionIdx);
586
+ if (idx === -1) cur.push(optionIdx);
587
+ else cur.splice(idx, 1);
588
+ this.answers.set(this.currentIndex, cur);
589
+ this.repaint();
590
+ } else {
591
+ this.answers.set(this.currentIndex, optionIdx);
592
+ this.selectedIndex = optionIdx;
593
+ if (this.currentIndex < this.questions.length - 1) {
594
+ // Advance to next question; don't submit yet
595
+ this.currentIndex++;
596
+ this.selectedIndex = 0;
597
+ this.repaint();
598
+ } else if (this.allAnswered()) {
599
+ // Last question + all answered → submit
600
+ this.submit();
601
+ } else {
602
+ this.repaint();
603
+ }
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Open the text-input dialog for the "Other…" option. Awaits the user's
609
+ * reply asynchronously. While awaiting, the picker ignores further input.
610
+ * If the user types text, the answer is stored (string for single, pushed
611
+ * to the multi-select array for multi). If the user cancels, the answer
612
+ * is unchanged.
613
+ */
614
+ private async requestOtherInput(): Promise<void> {
615
+ if (!this.onRequestInput) return;
616
+ const q = this.questions[this.currentIndex];
617
+ if (!q) return;
618
+ this.awaitingInput = true;
619
+ const isMulti = q.multiSelect ?? false;
620
+
621
+ const text = await this.onRequestInput({
622
+ title: q.header,
623
+ prompt: `Custom answer for: ${q.question}`,
624
+ placeholder: "Type your answer…",
625
+ });
626
+ this.awaitingInput = false;
627
+
628
+ if (text === undefined) {
629
+ // User cancelled — leave answer as-is, just redraw
630
+ this.repaint();
631
+ return;
632
+ }
633
+ const trimmed = text.trim();
634
+ if (trimmed === "") {
635
+ this.repaint();
636
+ return;
637
+ }
638
+
639
+ if (isMulti) {
640
+ const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
641
+ // Replace existing custom string (if any) so user can edit
642
+ const existingIdx = cur.findIndex((a) => typeof a === "string");
643
+ if (existingIdx >= 0) cur[existingIdx] = trimmed;
644
+ else cur.push(trimmed);
645
+ this.answers.set(this.currentIndex, cur);
646
+ this.repaint();
647
+ } else {
648
+ // Single-select: set custom string, advance or submit
649
+ this.answers.set(this.currentIndex, trimmed);
650
+ if (this.currentIndex < this.questions.length - 1) {
651
+ this.currentIndex++;
652
+ this.selectedIndex = 0;
653
+ this.repaint();
654
+ } else if (this.allAnswered()) {
655
+ this.submit();
656
+ } else {
657
+ this.repaint();
658
+ }
659
+ }
660
+ }
661
+
662
+ private submit(): void {
663
+ if (!this.allAnswered()) return;
664
+ const answers: Record<number, AskAnswer | AskMultiAnswer> = {};
665
+ for (let i = 0; i < this.questions.length; i++) {
666
+ answers[i] = this.answers.get(i) as AskAnswer | AskMultiAnswer;
667
+ }
668
+ this.completed = true;
669
+ this.done({ answers });
670
+ }
671
+
672
+ // -------------------------------------------------------------------------
673
+ // Public no-op dispose (Container doesn't define one; we just stop taking
674
+ // input). TUI will tear down children when the parent is disposed.
675
+ // -------------------------------------------------------------------------
676
+
677
+ dispose(): void {
678
+ this.completed = true;
679
+ this.awaitingInput = false;
680
+ }
681
+ }
682
+
683
+ /** Type guard for the public component. */
684
+ export function isAskProComponent(c: Component): c is AskProComponent {
685
+ return c instanceof AskProComponent;
686
+ }