yoyo-pi 0.1.4

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,1404 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Input, Key, matchesKey, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+
5
+ interface ChoiceOption {
6
+ label: string;
7
+ value?: string;
8
+ description?: string;
9
+ }
10
+
11
+ interface NormalizedOption {
12
+ label: string;
13
+ value: string;
14
+ description?: string;
15
+ }
16
+
17
+ interface ChoiceSelection {
18
+ label: string;
19
+ value: string;
20
+ index?: number;
21
+ custom?: boolean;
22
+ }
23
+
24
+ interface ChoiceDetails {
25
+ mode: "single" | "multiple";
26
+ question: string;
27
+ options: NormalizedOption[];
28
+ selected: ChoiceSelection[];
29
+ cancelled: boolean;
30
+ }
31
+
32
+ interface SingleChoiceParams {
33
+ question: string;
34
+ options: ChoiceOption[];
35
+ allowOther?: boolean;
36
+ otherLabel?: string;
37
+ }
38
+
39
+ interface MultipleChoiceParams extends SingleChoiceParams {
40
+ minSelections?: number;
41
+ maxSelections?: number;
42
+ defaultSelectedValues?: string[];
43
+ }
44
+
45
+ interface ChoiceQuestionParams extends MultipleChoiceParams {
46
+ id?: string;
47
+ label?: string;
48
+ mode?: "single" | "multiple" | "multi" | string;
49
+ }
50
+
51
+ interface NormalizedQuestion {
52
+ id: string;
53
+ label: string;
54
+ mode: "single" | "multiple";
55
+ question: string;
56
+ options: NormalizedOption[];
57
+ allowOther: boolean;
58
+ otherLabel: string;
59
+ minSelections: number;
60
+ maxSelections?: number;
61
+ defaultSelectedValues: string[];
62
+ }
63
+
64
+ interface ChoiceQuestionnaireParams {
65
+ title?: string;
66
+ questions: ChoiceQuestionParams[];
67
+ }
68
+
69
+ interface ChoiceQuestionAnswer {
70
+ id: string;
71
+ label: string;
72
+ mode: "single" | "multiple";
73
+ question: string;
74
+ selected: ChoiceSelection[];
75
+ }
76
+
77
+ interface ChoiceQuestionnaireDetails {
78
+ title?: string;
79
+ questions: Array<Omit<NormalizedQuestion, "defaultSelectedValues">>;
80
+ answers: ChoiceQuestionAnswer[];
81
+ cancelled: boolean;
82
+ }
83
+
84
+ type Done<T> = (result: T) => void;
85
+
86
+ const OptionSchema = Type.Object({
87
+ label: Type.String({ description: "Human-readable option label shown to the user" }),
88
+ value: Type.Optional(Type.String({ description: "Stable machine-readable value; defaults to label" })),
89
+ description: Type.Optional(Type.String({ description: "Short secondary text shown below or next to the label" })),
90
+ });
91
+
92
+ const SingleChoiceSchema = Type.Object({
93
+ question: Type.String({ description: "The question to ask the user" }),
94
+ options: Type.Array(OptionSchema, { minItems: 1, description: "The available choices" }),
95
+ allowOther: Type.Optional(Type.Boolean({ description: "Allow a freeform other answer. Default: true" })),
96
+ otherLabel: Type.Optional(Type.String({ description: "Label for the freeform other row" })),
97
+ });
98
+
99
+ const MultipleChoiceSchema = Type.Object({
100
+ question: Type.String({ description: "The question to ask the user" }),
101
+ options: Type.Array(OptionSchema, { minItems: 1, description: "The available choices" }),
102
+ allowOther: Type.Optional(Type.Boolean({ description: "Allow a freeform other answer. Default: true" })),
103
+ otherLabel: Type.Optional(Type.String({ description: "Label for the freeform other row" })),
104
+ minSelections: Type.Optional(Type.Number({ minimum: 0, description: "Minimum selected choices before submit. Default: 0" })),
105
+ maxSelections: Type.Optional(Type.Number({ minimum: 1, description: "Maximum selected choices. Omit for no limit" })),
106
+ defaultSelectedValues: Type.Optional(
107
+ Type.Array(Type.String(), { description: "Option values that should start selected" }),
108
+ ),
109
+ });
110
+
111
+ const ChoiceQuestionSchema = Type.Object({
112
+ id: Type.Optional(Type.String({ description: "Stable id for this question; defaults to q1, q2, ..." })),
113
+ label: Type.Optional(Type.String({ description: "Short tab label, e.g. Scope, Edge cases, Tests" })),
114
+ mode: Type.Optional(Type.String({ description: "\"single\" or \"multiple\". Defaults to \"single\"" })),
115
+ question: Type.String({ description: "The question to ask the user" }),
116
+ options: Type.Array(OptionSchema, { minItems: 1, description: "The available choices" }),
117
+ allowOther: Type.Optional(Type.Boolean({ description: "Allow a freeform other answer. Default: true" })),
118
+ otherLabel: Type.Optional(Type.String({ description: "Label for the freeform other row" })),
119
+ minSelections: Type.Optional(Type.Number({ minimum: 0, description: "For multiple mode: minimum selected choices" })),
120
+ maxSelections: Type.Optional(Type.Number({ minimum: 1, description: "For multiple mode: maximum selected choices" })),
121
+ defaultSelectedValues: Type.Optional(
122
+ Type.Array(Type.String(), { description: "For multiple mode: option values that should start selected" }),
123
+ ),
124
+ });
125
+
126
+ const ChoiceQuestionnaireSchema = Type.Object({
127
+ title: Type.Optional(Type.String({ description: "Short title for the batch of questions" })),
128
+ questions: Type.Array(ChoiceQuestionSchema, {
129
+ minItems: 1,
130
+ description: "Questions to ask in one tabbed UI so the agent receives all answers in one result",
131
+ }),
132
+ });
133
+
134
+ function normalizeOptions(options: ChoiceOption[]): NormalizedOption[] {
135
+ return options.map((option) => ({
136
+ label: option.label,
137
+ value: option.value ?? option.label,
138
+ description: option.description,
139
+ }));
140
+ }
141
+
142
+ function normalizeQuestion(question: ChoiceQuestionParams, index: number): NormalizedQuestion {
143
+ const mode = question.mode === "multiple" || question.mode === "multi" ? "multiple" : "single";
144
+ return {
145
+ id: question.id || `q${index + 1}`,
146
+ label: question.label || `Q${index + 1}`,
147
+ mode,
148
+ question: question.question,
149
+ options: normalizeOptions(question.options),
150
+ allowOther: question.allowOther !== false,
151
+ otherLabel: question.otherLabel ?? (mode === "multiple" ? "something else…" : "other…"),
152
+ minSelections: Math.max(0, Math.floor(question.minSelections ?? 0)),
153
+ maxSelections: question.maxSelections === undefined ? undefined : Math.max(1, Math.floor(question.maxSelections)),
154
+ defaultSelectedValues: question.defaultSelectedValues ?? [],
155
+ };
156
+ }
157
+
158
+ function padAnsi(text: string, width: number): string {
159
+ const truncated = truncateToWidth(text, Math.max(0, width), "");
160
+ return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
161
+ }
162
+
163
+ function isPrintable(data: string): boolean {
164
+ if (!data) return false;
165
+ if (data.includes("\x1b")) return false;
166
+ return [...data].every((ch) => {
167
+ const code = ch.charCodeAt(0);
168
+ return code >= 32 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f);
169
+ });
170
+ }
171
+
172
+ function firstText(result: { content: Array<{ type: string; text?: string }> }): string {
173
+ const part = result.content[0];
174
+ return part?.type === "text" ? (part.text ?? "") : "";
175
+ }
176
+
177
+ function noUiDetails(mode: "single" | "multiple", question: string, options: NormalizedOption[]): ChoiceDetails {
178
+ return { mode, question, options, selected: [], cancelled: true };
179
+ }
180
+
181
+ function formatSelection(selection: ChoiceSelection): string {
182
+ if (selection.custom) return `custom: ${selection.label}`;
183
+ return selection.index ? `${selection.index}. ${selection.label}` : selection.label;
184
+ }
185
+
186
+ class SingleChoicePicker {
187
+ private readonly input = new Input();
188
+ private activeIndex = 0;
189
+ private inputMode = false;
190
+ private cachedWidth?: number;
191
+ private cachedLines?: string[];
192
+ private _focused = false;
193
+
194
+ constructor(
195
+ private readonly tui: { requestRender(): void },
196
+ private readonly theme: any,
197
+ private readonly question: string,
198
+ private readonly options: NormalizedOption[],
199
+ private readonly allowOther: boolean,
200
+ private readonly otherLabel: string,
201
+ private readonly done: Done<ChoiceSelection | null>,
202
+ ) {
203
+ this.input.onSubmit = (value) => this.submitOther(value);
204
+ this.input.onEscape = () => this.stopInputMode();
205
+ }
206
+
207
+ get focused(): boolean {
208
+ return this._focused;
209
+ }
210
+
211
+ set focused(value: boolean) {
212
+ this._focused = value;
213
+ this.input.focused = value && this.inputMode;
214
+ }
215
+
216
+ render(width: number): string[] {
217
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
218
+
219
+ const lines: string[] = [];
220
+ const add = (line = "") => lines.push(truncateToWidth(line, width, ""));
221
+ const activeIsOther = this.allowOther && this.activeIndex === this.options.length;
222
+
223
+ add(`${this.theme.fg("success", this.theme.bold("?"))} ${this.theme.fg("text", this.theme.bold(this.question))}`);
224
+ add("");
225
+
226
+ for (let i = 0; i < this.options.length; i++) {
227
+ const option = this.options[i]!;
228
+ const active = this.activeIndex === i;
229
+ const mark = active ? "◉" : "◯";
230
+ const pillText = ` ${mark} ${option.label} `;
231
+ const pill = active
232
+ ? this.theme.bg("selectedBg", this.theme.fg("success", this.theme.bold(pillText)))
233
+ : this.theme.fg("dim", pillText);
234
+ add(` ${pill}`);
235
+ if (option.description) {
236
+ add(` ${active ? this.theme.fg("muted", option.description) : this.theme.fg("dim", option.description)}`);
237
+ }
238
+ }
239
+
240
+ if (this.allowOther) {
241
+ const value = this.input.getValue().trim();
242
+ const label = value || this.otherLabel;
243
+ const mark = activeIsOther ? "◉" : "◯";
244
+ const otherText = ` ${mark} ${label} `;
245
+ const otherPill = activeIsOther
246
+ ? this.theme.bg("selectedBg", this.theme.fg("warning", this.theme.bold(otherText)))
247
+ : this.theme.fg("warning", otherText);
248
+ add(` ${otherPill}`);
249
+ if (activeIsOther && this.inputMode) {
250
+ for (const inputLine of this.input.render(Math.max(1, width - 5))) {
251
+ add(` ${inputLine}`);
252
+ }
253
+ }
254
+ }
255
+
256
+ add("");
257
+ const help = this.inputMode
258
+ ? "[enter] use custom answer · [esc] back"
259
+ : "[tab/↑↓] move · [enter] confirm choice · [esc] cancel";
260
+ add(this.theme.fg("dim", help));
261
+
262
+ this.cachedWidth = width;
263
+ this.cachedLines = lines;
264
+ return lines;
265
+ }
266
+
267
+ handleInput(data: string): void {
268
+ if (this.inputMode) {
269
+ this.input.handleInput(data);
270
+ this.refresh();
271
+ return;
272
+ }
273
+
274
+ if (matchesKey(data, Key.escape)) {
275
+ this.done(null);
276
+ return;
277
+ }
278
+
279
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.down) || matchesKey(data, Key.right)) {
280
+ this.move(1);
281
+ return;
282
+ }
283
+
284
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.up) || matchesKey(data, Key.left)) {
285
+ this.move(-1);
286
+ return;
287
+ }
288
+
289
+ const numeric = data.length === 1 ? Number(data) : Number.NaN;
290
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= this.options.length) {
291
+ this.activeIndex = numeric - 1;
292
+ this.refresh();
293
+ return;
294
+ }
295
+
296
+ if (this.allowOther && (data === "+" || (isPrintable(data) && this.activeIndex === this.options.length))) {
297
+ this.activeIndex = this.options.length;
298
+ this.startInputMode(data === "+" ? undefined : data);
299
+ return;
300
+ }
301
+
302
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
303
+ this.confirm();
304
+ }
305
+ }
306
+
307
+ invalidate(): void {
308
+ this.cachedWidth = undefined;
309
+ this.cachedLines = undefined;
310
+ this.input.invalidate();
311
+ }
312
+
313
+ private move(delta: number): void {
314
+ const count = this.options.length + (this.allowOther ? 1 : 0);
315
+ this.activeIndex = (this.activeIndex + delta + count) % count;
316
+ this.refresh();
317
+ }
318
+
319
+ private confirm(): void {
320
+ if (this.allowOther && this.activeIndex === this.options.length) {
321
+ const value = this.input.getValue().trim();
322
+ if (value) {
323
+ this.submitOther(value);
324
+ } else {
325
+ this.startInputMode();
326
+ }
327
+ return;
328
+ }
329
+
330
+ const option = this.options[this.activeIndex];
331
+ if (!option) return;
332
+ this.done({ label: option.label, value: option.value, index: this.activeIndex + 1 });
333
+ }
334
+
335
+ private startInputMode(initial?: string): void {
336
+ this.inputMode = true;
337
+ this.input.focused = this._focused;
338
+ if (initial) this.input.setValue(this.input.getValue() + initial);
339
+ this.refresh();
340
+ }
341
+
342
+ private stopInputMode(): void {
343
+ this.inputMode = false;
344
+ this.input.focused = false;
345
+ this.refresh();
346
+ }
347
+
348
+ private submitOther(value: string): void {
349
+ const trimmed = value.trim();
350
+ if (!trimmed) {
351
+ this.stopInputMode();
352
+ return;
353
+ }
354
+ this.done({ label: trimmed, value: trimmed, custom: true });
355
+ }
356
+
357
+ private refresh(): void {
358
+ this.invalidate();
359
+ this.tui.requestRender();
360
+ }
361
+ }
362
+
363
+ class MultipleChoicePicker {
364
+ private readonly input = new Input();
365
+ private readonly checked = new Set<number>();
366
+ private focusIndex = 0;
367
+ private inputMode = false;
368
+ private warning: string | undefined;
369
+ private cachedWidth?: number;
370
+ private cachedLines?: string[];
371
+ private _focused = false;
372
+
373
+ constructor(
374
+ private readonly tui: { requestRender(): void },
375
+ private readonly theme: any,
376
+ private readonly question: string,
377
+ private readonly options: NormalizedOption[],
378
+ private readonly allowOther: boolean,
379
+ private readonly otherLabel: string,
380
+ defaultSelectedValues: string[],
381
+ private readonly minSelections: number,
382
+ private readonly maxSelections: number | undefined,
383
+ private readonly done: Done<ChoiceSelection[] | null>,
384
+ ) {
385
+ const defaults = new Set(defaultSelectedValues);
386
+ for (let i = 0; i < this.options.length; i++) {
387
+ if (defaults.has(this.options[i]!.value)) this.checked.add(i);
388
+ }
389
+ this.input.onSubmit = (value) => {
390
+ const trimmed = value.trim();
391
+ if (trimmed) this.checked.add(-1);
392
+ this.inputMode = false;
393
+ this.input.focused = false;
394
+ this.refresh();
395
+ };
396
+ this.input.onEscape = () => {
397
+ this.inputMode = false;
398
+ this.input.focused = false;
399
+ this.refresh();
400
+ };
401
+ }
402
+
403
+ get focused(): boolean {
404
+ return this._focused;
405
+ }
406
+
407
+ set focused(value: boolean) {
408
+ this._focused = value;
409
+ this.input.focused = value && this.inputMode;
410
+ }
411
+
412
+ render(width: number): string[] {
413
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
414
+
415
+ const lines: string[] = [];
416
+ const add = (line = "") => lines.push(truncateToWidth(line, width, ""));
417
+ const suffix = this.theme.fg("dim", " · choose any");
418
+ add(`${this.theme.fg("success", this.theme.bold("?"))} ${this.theme.fg("text", this.theme.bold(this.question))}${suffix}`);
419
+ add("");
420
+
421
+ for (let i = 0; i < this.options.length; i++) {
422
+ add(this.renderCell(i, width));
423
+ }
424
+
425
+ if (this.allowOther) {
426
+ const otherIndex = this.options.length;
427
+ const focused = this.focusIndex === otherIndex;
428
+ const selected = this.checked.has(-1);
429
+ const value = this.input.getValue().trim();
430
+ const marker = selected ? this.theme.fg("success", this.theme.bold("[✓]")) : this.theme.fg("warning", "[+] ");
431
+ const text = value || this.otherLabel;
432
+ let row = ` ${marker} ${this.theme.fg(selected ? "warning" : "dim", text)}`;
433
+ if (focused) row = this.theme.bg("selectedBg", row);
434
+ add(row);
435
+ if (focused && this.inputMode) {
436
+ for (const inputLine of this.input.render(Math.max(1, width - 3))) {
437
+ add(` ${inputLine}`);
438
+ }
439
+ }
440
+ }
441
+
442
+ add("");
443
+ if (this.warning) add(this.theme.fg("warning", this.warning));
444
+ const count = this.selectionCount();
445
+ const limitText = this.maxSelections ? ` · max ${this.maxSelections}` : "";
446
+ add(
447
+ `${this.theme.fg("dim", "[↑↓/tab] move · [space] toggle · [enter] commit · [esc] cancel · ")}${this.theme.fg(
448
+ count > 0 ? "success" : "dim",
449
+ `${count} picked`,
450
+ )}${this.theme.fg("dim", limitText)}`,
451
+ );
452
+
453
+ this.cachedWidth = width;
454
+ this.cachedLines = lines;
455
+ return lines;
456
+ }
457
+
458
+ handleInput(data: string): void {
459
+ if (this.inputMode) {
460
+ this.input.handleInput(data);
461
+ this.refresh();
462
+ return;
463
+ }
464
+
465
+ if (matchesKey(data, Key.escape)) {
466
+ this.done(null);
467
+ return;
468
+ }
469
+
470
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.down)) {
471
+ this.move(1);
472
+ return;
473
+ }
474
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.up)) {
475
+ this.move(-1);
476
+ return;
477
+ }
478
+ if (matchesKey(data, Key.right)) {
479
+ this.move(1);
480
+ return;
481
+ }
482
+ if (matchesKey(data, Key.left)) {
483
+ this.move(-1);
484
+ return;
485
+ }
486
+
487
+ const numeric = data.length === 1 ? Number(data) : Number.NaN;
488
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= this.options.length) {
489
+ this.toggle(numeric - 1);
490
+ return;
491
+ }
492
+
493
+ if (this.allowOther && data === "+") {
494
+ this.focusIndex = this.options.length;
495
+ this.startOtherInput();
496
+ return;
497
+ }
498
+
499
+ if (matchesKey(data, Key.space)) {
500
+ this.toggleFocused();
501
+ return;
502
+ }
503
+
504
+ if (matchesKey(data, Key.enter)) {
505
+ this.submit();
506
+ }
507
+ }
508
+
509
+ invalidate(): void {
510
+ this.cachedWidth = undefined;
511
+ this.cachedLines = undefined;
512
+ this.input.invalidate();
513
+ }
514
+
515
+ private renderCell(index: number, width: number): string {
516
+ const option = this.options[index]!;
517
+ const selected = this.checked.has(index);
518
+ const focused = this.focusIndex === index;
519
+ const marker = selected ? this.theme.fg("success", this.theme.bold("[✓]")) : this.theme.fg("dim", "[ ]");
520
+ const label = selected ? this.theme.fg("text", this.theme.bold(option.label)) : this.theme.fg("text", option.label);
521
+ let cell = ` ${marker} ${label}`;
522
+ if (focused) cell = this.theme.bg("selectedBg", cell);
523
+ return padAnsi(cell, width);
524
+ }
525
+
526
+ private move(delta: number): void {
527
+ const count = this.options.length + (this.allowOther ? 1 : 0);
528
+ this.focusIndex = (this.focusIndex + delta + count) % count;
529
+ this.warning = undefined;
530
+ this.refresh();
531
+ }
532
+
533
+ private toggleFocused(): void {
534
+ if (this.allowOther && this.focusIndex === this.options.length) {
535
+ if (this.checked.has(-1) && this.input.getValue().trim()) {
536
+ this.checked.delete(-1);
537
+ this.refresh();
538
+ } else {
539
+ this.startOtherInput();
540
+ }
541
+ return;
542
+ }
543
+ this.toggle(this.focusIndex);
544
+ }
545
+
546
+ private toggle(index: number): void {
547
+ this.warning = undefined;
548
+ if (this.checked.has(index)) {
549
+ this.checked.delete(index);
550
+ this.refresh();
551
+ return;
552
+ }
553
+ if (this.maxSelections !== undefined && this.selectionCount() >= this.maxSelections) {
554
+ this.warning = `Select at most ${this.maxSelections} option${this.maxSelections === 1 ? "" : "s"}.`;
555
+ this.refresh();
556
+ return;
557
+ }
558
+ this.checked.add(index);
559
+ this.refresh();
560
+ }
561
+
562
+ private startOtherInput(): void {
563
+ this.inputMode = true;
564
+ this.input.focused = this._focused;
565
+ this.warning = undefined;
566
+ this.refresh();
567
+ }
568
+
569
+ private submit(): void {
570
+ const count = this.selectionCount();
571
+ if (count < this.minSelections) {
572
+ this.warning = `Select at least ${this.minSelections} option${this.minSelections === 1 ? "" : "s"}.`;
573
+ this.refresh();
574
+ return;
575
+ }
576
+ const selections: ChoiceSelection[] = [];
577
+ for (let i = 0; i < this.options.length; i++) {
578
+ if (!this.checked.has(i)) continue;
579
+ const option = this.options[i]!;
580
+ selections.push({ label: option.label, value: option.value, index: i + 1 });
581
+ }
582
+ const other = this.input.getValue().trim();
583
+ if (this.checked.has(-1) && other) selections.push({ label: other, value: other, custom: true });
584
+ this.done(selections);
585
+ }
586
+
587
+ private selectionCount(): number {
588
+ let count = 0;
589
+ for (const index of this.checked) {
590
+ if (index === -1) {
591
+ if (this.input.getValue().trim()) count++;
592
+ } else {
593
+ count++;
594
+ }
595
+ }
596
+ return count;
597
+ }
598
+
599
+ private refresh(): void {
600
+ this.invalidate();
601
+ this.tui.requestRender();
602
+ }
603
+ }
604
+
605
+ class ChoiceQuestionnairePicker {
606
+ private readonly input = new Input();
607
+ private readonly focusByQuestion: number[];
608
+ private readonly checkedByQuestion = new Map<string, Set<number>>();
609
+ private readonly otherValues = new Map<string, string>();
610
+ private readonly answers = new Map<string, ChoiceQuestionAnswer>();
611
+ private current = 0;
612
+ private inputMode = false;
613
+ private warning: string | undefined;
614
+ private cachedWidth?: number;
615
+ private cachedLines?: string[];
616
+ private _focused = false;
617
+
618
+ constructor(
619
+ private readonly tui: { requestRender(): void },
620
+ private readonly theme: any,
621
+ private readonly title: string | undefined,
622
+ private readonly questions: NormalizedQuestion[],
623
+ private readonly done: Done<ChoiceQuestionnaireDetails | null>,
624
+ ) {
625
+ this.focusByQuestion = questions.map(() => 0);
626
+ for (const question of questions) {
627
+ if (question.mode !== "multiple") continue;
628
+ const checked = this.getChecked(question);
629
+ const defaults = new Set(question.defaultSelectedValues);
630
+ for (let i = 0; i < question.options.length; i++) {
631
+ if (defaults.has(question.options[i]!.value)) checked.add(i);
632
+ }
633
+ }
634
+ this.input.onSubmit = (value) => this.submitOther(value);
635
+ this.input.onEscape = () => this.stopInputMode();
636
+ }
637
+
638
+ get focused(): boolean {
639
+ return this._focused;
640
+ }
641
+
642
+ set focused(value: boolean) {
643
+ this._focused = value;
644
+ this.input.focused = value && this.inputMode;
645
+ }
646
+
647
+ render(width: number): string[] {
648
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
649
+
650
+ const lines: string[] = [];
651
+ const add = (line = "") => lines.push(truncateToWidth(line, width, ""));
652
+ const question = this.currentQuestion();
653
+ if (!question) return [];
654
+
655
+ if (this.title) add(this.theme.fg("accent", this.theme.bold(this.title)));
656
+ for (const tabLine of this.renderTabs(width)) add(tabLine);
657
+ add("");
658
+ const modeHint = question.mode === "multiple" ? " · choose any" : " · choose one";
659
+ add(
660
+ `${this.theme.fg("success", this.theme.bold("?"))} ${this.theme.fg("text", this.theme.bold(question.question))}${this.theme.fg(
661
+ "dim",
662
+ modeHint,
663
+ )}`,
664
+ );
665
+ add("");
666
+
667
+ for (let i = 0; i < question.options.length; i++) {
668
+ add(this.renderOption(question, i, width));
669
+ const description = question.options[i]?.description;
670
+ if (description) add(` ${this.theme.fg(this.focusIndex() === i ? "muted" : "dim", description)}`);
671
+ }
672
+
673
+ if (question.allowOther) {
674
+ add(this.renderOther(question, width));
675
+ if (this.focusIndex() === question.options.length && this.inputMode) {
676
+ for (const inputLine of this.input.render(Math.max(1, width - 5))) {
677
+ add(` ${inputLine}`);
678
+ }
679
+ }
680
+ }
681
+
682
+ add("");
683
+ if (this.warning) add(this.theme.fg("warning", this.warning));
684
+ const answered = this.answers.size;
685
+ const total = this.questions.length;
686
+ const action = question.mode === "multiple" ? "[space] toggle · [enter] next" : "[enter] choose";
687
+ add(
688
+ `${this.theme.fg("dim", `[←→/tab] question · [↑↓] option · ${action} · [esc] cancel · `)}${this.theme.fg(
689
+ answered > 0 ? "success" : "dim",
690
+ `${answered}/${total} answered`,
691
+ )}`,
692
+ );
693
+
694
+ this.cachedWidth = width;
695
+ this.cachedLines = lines;
696
+ return lines;
697
+ }
698
+
699
+ handleInput(data: string): void {
700
+ if (this.inputMode) {
701
+ this.input.handleInput(data);
702
+ this.refresh();
703
+ return;
704
+ }
705
+
706
+ if (matchesKey(data, Key.escape)) {
707
+ this.done(null);
708
+ return;
709
+ }
710
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
711
+ this.switchQuestion(1);
712
+ return;
713
+ }
714
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
715
+ this.switchQuestion(-1);
716
+ return;
717
+ }
718
+ if (matchesKey(data, Key.down)) {
719
+ this.moveFocus(1);
720
+ return;
721
+ }
722
+ if (matchesKey(data, Key.up)) {
723
+ this.moveFocus(-1);
724
+ return;
725
+ }
726
+
727
+ const question = this.currentQuestion();
728
+ if (!question) return;
729
+
730
+ const numeric = data.length === 1 ? Number(data) : Number.NaN;
731
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= question.options.length) {
732
+ this.activateIndex(question, numeric - 1);
733
+ return;
734
+ }
735
+
736
+ if (question.allowOther && data === "+") {
737
+ this.setFocusIndex(question.options.length);
738
+ this.activateIndex(question, question.options.length);
739
+ return;
740
+ }
741
+
742
+ if (matchesKey(data, Key.space)) {
743
+ if (question.mode === "multiple") {
744
+ this.activateIndex(question, this.focusIndex());
745
+ } else {
746
+ this.activateIndex(question, this.focusIndex());
747
+ }
748
+ return;
749
+ }
750
+
751
+ if (matchesKey(data, Key.enter)) {
752
+ if (question.mode === "multiple") {
753
+ if (this.commitMultiple(question)) this.advanceOrFinish();
754
+ } else {
755
+ this.activateIndex(question, this.focusIndex());
756
+ }
757
+ }
758
+ }
759
+
760
+ invalidate(): void {
761
+ this.cachedWidth = undefined;
762
+ this.cachedLines = undefined;
763
+ this.input.invalidate();
764
+ }
765
+
766
+ private renderTabs(width: number): string[] {
767
+ const lines: string[] = [];
768
+ let line = "";
769
+ for (let i = 0; i < this.questions.length; i++) {
770
+ const question = this.questions[i]!;
771
+ const active = i === this.current;
772
+ const answered = this.answers.has(question.id);
773
+ const mark = answered ? "✓" : "·";
774
+ const raw = ` ${mark} ${question.label} `;
775
+ const styled = active
776
+ ? this.theme.bg("selectedBg", this.theme.fg("text", raw))
777
+ : this.theme.fg(answered ? "success" : "dim", raw);
778
+ const candidate = line ? `${line} ${styled}` : styled;
779
+ if (visibleWidth(candidate) > width && line) {
780
+ lines.push(truncateToWidth(line, width, ""));
781
+ line = styled;
782
+ } else {
783
+ line = candidate;
784
+ }
785
+ }
786
+ if (line) lines.push(truncateToWidth(line, width, ""));
787
+ return lines;
788
+ }
789
+
790
+ private renderOption(question: NormalizedQuestion, index: number, width: number): string {
791
+ const option = question.options[index]!;
792
+ const focused = this.focusIndex() === index;
793
+ const selected = this.isSelected(question, index);
794
+ const marker =
795
+ question.mode === "multiple"
796
+ ? selected
797
+ ? this.theme.fg("success", this.theme.bold("[✓]"))
798
+ : this.theme.fg("dim", "[ ]")
799
+ : selected
800
+ ? this.theme.fg("success", this.theme.bold("◉"))
801
+ : this.theme.fg("dim", "◯");
802
+ const label = selected ? this.theme.fg("text", this.theme.bold(option.label)) : this.theme.fg("text", option.label);
803
+ let row = ` ${marker} ${label}`;
804
+ if (focused) row = this.theme.bg("selectedBg", row);
805
+ return padAnsi(row, width);
806
+ }
807
+
808
+ private renderOther(question: NormalizedQuestion, width: number): string {
809
+ const focused = this.focusIndex() === question.options.length;
810
+ const selected = this.isSelected(question, -1);
811
+ const value = this.otherValues.get(question.id)?.trim();
812
+ const marker =
813
+ question.mode === "multiple"
814
+ ? selected
815
+ ? this.theme.fg("success", this.theme.bold("[✓]"))
816
+ : this.theme.fg("warning", "[+]")
817
+ : selected
818
+ ? this.theme.fg("warning", this.theme.bold("◉"))
819
+ : this.theme.fg("warning", "◯");
820
+ const label = value || question.otherLabel;
821
+ let row = ` ${marker} ${this.theme.fg(selected ? "warning" : "dim", label)}`;
822
+ if (focused) row = this.theme.bg("selectedBg", row);
823
+ return padAnsi(row, width);
824
+ }
825
+
826
+ private currentQuestion(): NormalizedQuestion | undefined {
827
+ return this.questions[this.current];
828
+ }
829
+
830
+ private focusIndex(): number {
831
+ return this.focusByQuestion[this.current] ?? 0;
832
+ }
833
+
834
+ private setFocusIndex(index: number): void {
835
+ this.focusByQuestion[this.current] = index;
836
+ }
837
+
838
+ private totalItems(question: NormalizedQuestion): number {
839
+ return question.options.length + (question.allowOther ? 1 : 0);
840
+ }
841
+
842
+ private switchQuestion(delta: number): void {
843
+ this.current = (this.current + delta + this.questions.length) % this.questions.length;
844
+ this.warning = undefined;
845
+ this.inputMode = false;
846
+ this.input.focused = false;
847
+ this.refresh();
848
+ }
849
+
850
+ private moveFocus(delta: number): void {
851
+ const question = this.currentQuestion();
852
+ if (!question) return;
853
+ const total = this.totalItems(question);
854
+ this.setFocusIndex((this.focusIndex() + delta + total) % total);
855
+ this.warning = undefined;
856
+ this.refresh();
857
+ }
858
+
859
+ private activateIndex(question: NormalizedQuestion, index: number): void {
860
+ this.warning = undefined;
861
+ if (question.allowOther && index === question.options.length) {
862
+ this.startOtherInput(question);
863
+ return;
864
+ }
865
+
866
+ if (question.mode === "multiple") {
867
+ this.toggleMultiple(question, index);
868
+ return;
869
+ }
870
+
871
+ const option = question.options[index];
872
+ if (!option) return;
873
+ this.saveAnswer(question, [{ label: option.label, value: option.value, index: index + 1 }]);
874
+ this.advanceOrFinish();
875
+ }
876
+
877
+ private startOtherInput(question: NormalizedQuestion): void {
878
+ this.input.setValue(this.otherValues.get(question.id) ?? "");
879
+ this.inputMode = true;
880
+ this.input.focused = this._focused;
881
+ this.refresh();
882
+ }
883
+
884
+ private stopInputMode(): void {
885
+ this.inputMode = false;
886
+ this.input.focused = false;
887
+ this.refresh();
888
+ }
889
+
890
+ private submitOther(value: string): void {
891
+ const question = this.currentQuestion();
892
+ if (!question) return;
893
+ const trimmed = value.trim();
894
+ if (!trimmed) {
895
+ this.stopInputMode();
896
+ return;
897
+ }
898
+
899
+ if (question.mode === "multiple") {
900
+ const checked = this.getChecked(question);
901
+ if (!checked.has(-1) && question.maxSelections !== undefined && this.selectionCount(question) >= question.maxSelections) {
902
+ this.warning = `Select at most ${question.maxSelections} option${question.maxSelections === 1 ? "" : "s"}.`;
903
+ this.stopInputMode();
904
+ return;
905
+ }
906
+ this.otherValues.set(question.id, trimmed);
907
+ checked.add(-1);
908
+ this.stopInputMode();
909
+ return;
910
+ }
911
+
912
+ this.otherValues.set(question.id, trimmed);
913
+ this.saveAnswer(question, [{ label: trimmed, value: trimmed, custom: true }]);
914
+ this.inputMode = false;
915
+ this.input.focused = false;
916
+ this.advanceOrFinish();
917
+ }
918
+
919
+ private toggleMultiple(question: NormalizedQuestion, index: number): void {
920
+ const checked = this.getChecked(question);
921
+ if (checked.has(index)) {
922
+ checked.delete(index);
923
+ this.updateCommittedMultiple(question);
924
+ this.refresh();
925
+ return;
926
+ }
927
+ if (question.maxSelections !== undefined && this.selectionCount(question) >= question.maxSelections) {
928
+ this.warning = `Select at most ${question.maxSelections} option${question.maxSelections === 1 ? "" : "s"}.`;
929
+ this.refresh();
930
+ return;
931
+ }
932
+ checked.add(index);
933
+ this.updateCommittedMultiple(question);
934
+ this.refresh();
935
+ }
936
+
937
+ private commitMultiple(question: NormalizedQuestion): boolean {
938
+ const count = this.selectionCount(question);
939
+ if (count < question.minSelections) {
940
+ this.warning = `Select at least ${question.minSelections} option${question.minSelections === 1 ? "" : "s"}.`;
941
+ this.refresh();
942
+ return false;
943
+ }
944
+ this.saveAnswer(question, this.getSelections(question));
945
+ return true;
946
+ }
947
+
948
+ private updateCommittedMultiple(question: NormalizedQuestion): void {
949
+ if (!this.answers.has(question.id)) return;
950
+ this.saveAnswer(question, this.getSelections(question));
951
+ }
952
+
953
+ private getChecked(question: NormalizedQuestion): Set<number> {
954
+ let checked = this.checkedByQuestion.get(question.id);
955
+ if (!checked) {
956
+ checked = new Set<number>();
957
+ this.checkedByQuestion.set(question.id, checked);
958
+ }
959
+ return checked;
960
+ }
961
+
962
+ private isSelected(question: NormalizedQuestion, index: number): boolean {
963
+ if (question.mode === "multiple") {
964
+ if (index === -1) return this.getChecked(question).has(-1) && Boolean(this.otherValues.get(question.id)?.trim());
965
+ return this.getChecked(question).has(index);
966
+ }
967
+ const selected = this.answers.get(question.id)?.selected[0];
968
+ if (!selected) return false;
969
+ if (index === -1) return selected.custom === true;
970
+ return selected.index === index + 1;
971
+ }
972
+
973
+ private selectionCount(question: NormalizedQuestion): number {
974
+ let count = 0;
975
+ for (const index of this.getChecked(question)) {
976
+ if (index === -1) {
977
+ if (this.otherValues.get(question.id)?.trim()) count++;
978
+ } else {
979
+ count++;
980
+ }
981
+ }
982
+ return count;
983
+ }
984
+
985
+ private getSelections(question: NormalizedQuestion): ChoiceSelection[] {
986
+ if (question.mode === "single") return this.answers.get(question.id)?.selected ?? [];
987
+ const selections: ChoiceSelection[] = [];
988
+ const checked = this.getChecked(question);
989
+ for (let i = 0; i < question.options.length; i++) {
990
+ if (!checked.has(i)) continue;
991
+ const option = question.options[i]!;
992
+ selections.push({ label: option.label, value: option.value, index: i + 1 });
993
+ }
994
+ const other = this.otherValues.get(question.id)?.trim();
995
+ if (checked.has(-1) && other) selections.push({ label: other, value: other, custom: true });
996
+ return selections;
997
+ }
998
+
999
+ private saveAnswer(question: NormalizedQuestion, selected: ChoiceSelection[]): void {
1000
+ this.answers.set(question.id, {
1001
+ id: question.id,
1002
+ label: question.label,
1003
+ mode: question.mode,
1004
+ question: question.question,
1005
+ selected,
1006
+ });
1007
+ }
1008
+
1009
+ private allAnswered(): boolean {
1010
+ return this.questions.every((question) => this.answers.has(question.id));
1011
+ }
1012
+
1013
+ private advanceOrFinish(): void {
1014
+ if (this.allAnswered()) {
1015
+ this.done(this.buildDetails(false));
1016
+ return;
1017
+ }
1018
+ for (let offset = 1; offset <= this.questions.length; offset++) {
1019
+ const next = (this.current + offset) % this.questions.length;
1020
+ if (!this.answers.has(this.questions[next]!.id)) {
1021
+ this.current = next;
1022
+ break;
1023
+ }
1024
+ }
1025
+ this.warning = undefined;
1026
+ this.inputMode = false;
1027
+ this.input.focused = false;
1028
+ this.refresh();
1029
+ }
1030
+
1031
+ private buildDetails(cancelled: boolean): ChoiceQuestionnaireDetails {
1032
+ return {
1033
+ title: this.title,
1034
+ questions: this.questions.map((question) => ({
1035
+ id: question.id,
1036
+ label: question.label,
1037
+ mode: question.mode,
1038
+ question: question.question,
1039
+ options: question.options,
1040
+ allowOther: question.allowOther,
1041
+ otherLabel: question.otherLabel,
1042
+ minSelections: question.minSelections,
1043
+ maxSelections: question.maxSelections,
1044
+ })),
1045
+ answers: this.questions.flatMap((question) => {
1046
+ const answer = this.answers.get(question.id);
1047
+ return answer ? [answer] : [];
1048
+ }),
1049
+ cancelled,
1050
+ };
1051
+ }
1052
+
1053
+ private refresh(): void {
1054
+ this.invalidate();
1055
+ this.tui.requestRender();
1056
+ }
1057
+ }
1058
+
1059
+ async function askSingleChoice(ctx: ExtensionContext, params: SingleChoiceParams): Promise<ChoiceDetails> {
1060
+ const options = normalizeOptions(params.options);
1061
+ const allowOther = params.allowOther !== false;
1062
+ const otherLabel = params.otherLabel ?? "other…";
1063
+
1064
+ if (!ctx.hasUI) return noUiDetails("single", params.question, options);
1065
+ if (options.length === 0) return noUiDetails("single", params.question, options);
1066
+
1067
+ const selected = await ctx.ui.custom<ChoiceSelection | null>((tui, theme, _kb, done) => {
1068
+ return new SingleChoicePicker(tui, theme, params.question, options, allowOther, otherLabel, done);
1069
+ });
1070
+
1071
+ return {
1072
+ mode: "single",
1073
+ question: params.question,
1074
+ options,
1075
+ selected: selected ? [selected] : [],
1076
+ cancelled: selected === null,
1077
+ };
1078
+ }
1079
+
1080
+ async function askMultipleChoice(ctx: ExtensionContext, params: MultipleChoiceParams): Promise<ChoiceDetails> {
1081
+ const options = normalizeOptions(params.options);
1082
+ const allowOther = params.allowOther !== false;
1083
+ const otherLabel = params.otherLabel ?? "something else…";
1084
+ const minSelections = Math.max(0, Math.floor(params.minSelections ?? 0));
1085
+ const maxSelections = params.maxSelections === undefined ? undefined : Math.max(1, Math.floor(params.maxSelections));
1086
+
1087
+ if (!ctx.hasUI) return noUiDetails("multiple", params.question, options);
1088
+ if (options.length === 0) return noUiDetails("multiple", params.question, options);
1089
+
1090
+ const selected = await ctx.ui.custom<ChoiceSelection[] | null>((tui, theme, _kb, done) => {
1091
+ return new MultipleChoicePicker(
1092
+ tui,
1093
+ theme,
1094
+ params.question,
1095
+ options,
1096
+ allowOther,
1097
+ otherLabel,
1098
+ params.defaultSelectedValues ?? [],
1099
+ minSelections,
1100
+ maxSelections,
1101
+ done,
1102
+ );
1103
+ });
1104
+
1105
+ return {
1106
+ mode: "multiple",
1107
+ question: params.question,
1108
+ options,
1109
+ selected: selected ?? [],
1110
+ cancelled: selected === null,
1111
+ };
1112
+ }
1113
+
1114
+ function noUiQuestionnaireDetails(title: string | undefined, questions: NormalizedQuestion[]): ChoiceQuestionnaireDetails {
1115
+ return {
1116
+ title,
1117
+ questions: questions.map((question) => ({
1118
+ id: question.id,
1119
+ label: question.label,
1120
+ mode: question.mode,
1121
+ question: question.question,
1122
+ options: question.options,
1123
+ allowOther: question.allowOther,
1124
+ otherLabel: question.otherLabel,
1125
+ minSelections: question.minSelections,
1126
+ maxSelections: question.maxSelections,
1127
+ })),
1128
+ answers: [],
1129
+ cancelled: true,
1130
+ };
1131
+ }
1132
+
1133
+ async function askChoiceQuestions(ctx: ExtensionContext, params: ChoiceQuestionnaireParams): Promise<ChoiceQuestionnaireDetails> {
1134
+ const questions = params.questions.map(normalizeQuestion).filter((question) => question.options.length > 0);
1135
+ if (!ctx.hasUI) return noUiQuestionnaireDetails(params.title, questions);
1136
+ if (questions.length === 0) return noUiQuestionnaireDetails(params.title, questions);
1137
+
1138
+ const details = await ctx.ui.custom<ChoiceQuestionnaireDetails | null>((tui, theme, _kb, done) => {
1139
+ return new ChoiceQuestionnairePicker(tui, theme, params.title, questions, done);
1140
+ });
1141
+
1142
+ return details ?? noUiQuestionnaireDetails(params.title, questions);
1143
+ }
1144
+
1145
+ function renderCallSummary(name: string, question: string | undefined, options: unknown, theme: any) {
1146
+ const count = Array.isArray(options) ? options.length : 0;
1147
+ let text = theme.fg("toolTitle", theme.bold(`${name} `));
1148
+ text += theme.fg("muted", question ?? "");
1149
+ if (count) text += theme.fg("dim", ` (${count} option${count === 1 ? "" : "s"})`);
1150
+ return new Text(text, 0, 0);
1151
+ }
1152
+
1153
+ function renderChoiceResult(result: { content: Array<{ type: string; text?: string }>; details?: ChoiceDetails }, theme: any) {
1154
+ const details = result.details;
1155
+ if (!details) return new Text(firstText(result), 0, 0);
1156
+ if (details.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
1157
+ if (details.selected.length === 0) return new Text(theme.fg("dim", "No choices selected"), 0, 0);
1158
+ const lines = details.selected.map((selection) => {
1159
+ const label = formatSelection(selection);
1160
+ const prefix = selection.custom ? theme.fg("warning", "+ ") : theme.fg("success", "✓ ");
1161
+ return prefix + theme.fg(selection.custom ? "warning" : "accent", label);
1162
+ });
1163
+ return new Text(lines.join("\n"), 0, 0);
1164
+ }
1165
+
1166
+ function formatAnswer(answer: ChoiceQuestionAnswer): string {
1167
+ if (answer.selected.length === 0) return "no selection";
1168
+ return answer.selected.map(formatSelection).join(", ");
1169
+ }
1170
+
1171
+ function renderQuestionnaireResult(
1172
+ result: { content: Array<{ type: string; text?: string }>; details?: ChoiceQuestionnaireDetails },
1173
+ theme: any,
1174
+ ) {
1175
+ const details = result.details;
1176
+ if (!details) return new Text(firstText(result), 0, 0);
1177
+ if (details.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
1178
+ if (details.answers.length === 0) return new Text(theme.fg("dim", "No answers"), 0, 0);
1179
+ const lines = details.answers.map((answer) => {
1180
+ return `${theme.fg("success", "✓ ")}${theme.fg("accent", answer.label)}: ${theme.fg("text", formatAnswer(answer))}`;
1181
+ });
1182
+ return new Text(lines.join("\n"), 0, 0);
1183
+ }
1184
+
1185
+ function toolAlreadyRegistered(pi: ExtensionAPI, name: string): boolean {
1186
+ try {
1187
+ return pi.getAllTools().some((tool) => tool.name === name);
1188
+ } catch {
1189
+ return false;
1190
+ }
1191
+ }
1192
+
1193
+ export default function choicePickerExtension(pi: ExtensionAPI) {
1194
+ if (!toolAlreadyRegistered(pi, "single_choice")) {
1195
+ pi.registerTool({
1196
+ name: "single_choice",
1197
+ label: "Single Choice",
1198
+ description:
1199
+ "Ask the user to pick exactly one option using pi's inline-pill choice picker UI. Use this instead of writing a plain numbered list when the user needs to choose one option.",
1200
+ promptSnippet: "Ask the user to choose exactly one option with an interactive inline-pill picker",
1201
+ promptGuidelines: [
1202
+ "Use single_choice whenever you need the user to choose exactly one option; do not ask with a plain numbered list unless the tool is unavailable.",
1203
+ "For single_choice, provide a concise question and an options array in the standard format: { label, value?, description? }. Use stable value strings when the label may change.",
1204
+ ],
1205
+ parameters: SingleChoiceSchema,
1206
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1207
+ const details = await askSingleChoice(ctx, params as SingleChoiceParams);
1208
+ if (details.cancelled) {
1209
+ return { content: [{ type: "text", text: "User cancelled the single-choice question." }], details };
1210
+ }
1211
+ const selected = details.selected[0];
1212
+ return {
1213
+ content: [{ type: "text", text: selected ? `User selected: ${formatSelection(selected)}` : "No choice selected." }],
1214
+ details,
1215
+ };
1216
+ },
1217
+ renderCall(args, theme) {
1218
+ return renderCallSummary("single_choice", args.question, args.options, theme);
1219
+ },
1220
+ renderResult(result, _options, theme) {
1221
+ return renderChoiceResult(result, theme);
1222
+ },
1223
+ });
1224
+ }
1225
+
1226
+ if (!toolAlreadyRegistered(pi, "multiple_choice")) {
1227
+ pi.registerTool({
1228
+ name: "multiple_choice",
1229
+ label: "Multiple Choice",
1230
+ description:
1231
+ "Ask the user to pick zero or more options using pi's compact multi-select picker UI. Use this instead of writing a plain checkbox list when the user can choose multiple options.",
1232
+ promptSnippet: "Ask the user to choose any number of options with an interactive compact multi-select picker",
1233
+ promptGuidelines: [
1234
+ "Use multiple_choice whenever you need the user to choose any number of options; do not ask with a plain checkbox list unless the tool is unavailable.",
1235
+ "For multiple_choice, provide a concise question and an options array in the standard format: { label, value?, description? }. Set minSelections or maxSelections only when needed.",
1236
+ ],
1237
+ parameters: MultipleChoiceSchema,
1238
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1239
+ const details = await askMultipleChoice(ctx, params as MultipleChoiceParams);
1240
+ if (details.cancelled) {
1241
+ return { content: [{ type: "text", text: "User cancelled the multiple-choice question." }], details };
1242
+ }
1243
+ const text = details.selected.length
1244
+ ? `User selected ${details.selected.length} option${details.selected.length === 1 ? "" : "s"}: ${details.selected
1245
+ .map(formatSelection)
1246
+ .join(", ")}`
1247
+ : "User selected no options.";
1248
+ return { content: [{ type: "text", text }], details };
1249
+ },
1250
+ renderCall(args, theme) {
1251
+ return renderCallSummary("multiple_choice", args.question, args.options, theme);
1252
+ },
1253
+ renderResult(result, _options, theme) {
1254
+ return renderChoiceResult(result, theme);
1255
+ },
1256
+ });
1257
+ }
1258
+
1259
+ if (!toolAlreadyRegistered(pi, "choice_questions")) {
1260
+ pi.registerTool({
1261
+ name: "choice_questions",
1262
+ label: "Choice Questions",
1263
+ description:
1264
+ "Ask the user a batch of single-choice and/or multiple-choice questions in one tabbed UI. Use when you have several clarifying questions so the user can answer one by one and the agent receives all answers in one result.",
1265
+ promptSnippet: "Ask several single/multiple choice questions in one tabbed picker and receive all answers at once",
1266
+ promptGuidelines: [
1267
+ "Use choice_questions when you have two or more clarifying questions; prefer it over asking multiple separate single_choice or multiple_choice questions.",
1268
+ "For choice_questions, provide { title?, questions: [{ id?, label?, mode?, question, options: [{ label, value?, description? }], allowOther?, minSelections?, maxSelections? }] }. Use short labels for the tab tags.",
1269
+ ],
1270
+ parameters: ChoiceQuestionnaireSchema,
1271
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1272
+ const details = await askChoiceQuestions(ctx, params as ChoiceQuestionnaireParams);
1273
+ if (details.cancelled) {
1274
+ return { content: [{ type: "text", text: "User cancelled the batched choice questions." }], details };
1275
+ }
1276
+ const answerLines = details.answers.map((answer) => `${answer.label}: ${formatAnswer(answer)}`);
1277
+ return {
1278
+ content: [
1279
+ {
1280
+ type: "text",
1281
+ text: `User answered ${details.answers.length}/${details.questions.length} questions:\n${answerLines.join("\n")}`,
1282
+ },
1283
+ ],
1284
+ details,
1285
+ };
1286
+ },
1287
+ renderCall(args, theme) {
1288
+ const count = Array.isArray(args.questions) ? args.questions.length : 0;
1289
+ let text = theme.fg("toolTitle", theme.bold("choice_questions "));
1290
+ text += theme.fg("muted", args.title ?? `${count} question${count === 1 ? "" : "s"}`);
1291
+ if (args.title && count) text += theme.fg("dim", ` (${count} question${count === 1 ? "" : "s"})`);
1292
+ return new Text(text, 0, 0);
1293
+ },
1294
+ renderResult(result, _options, theme) {
1295
+ return renderQuestionnaireResult(result, theme);
1296
+ },
1297
+ });
1298
+ }
1299
+
1300
+ pi.registerCommand("choice-demo", {
1301
+ description: "Preview the single_choice, multiple_choice, and choice_questions pickers",
1302
+ handler: async (args, ctx) => {
1303
+ const mode = args.trim().toLowerCase();
1304
+ if (mode === "multi" || mode === "multiple" || mode === "m") {
1305
+ const details = await askMultipleChoice(ctx, {
1306
+ question: "Which stack pieces should I scaffold?",
1307
+ options: [
1308
+ { label: "TypeScript + Vite frontend", value: "vite" },
1309
+ { label: "Postgres + Drizzle ORM", value: "db" },
1310
+ { label: "Auth (passkeys, sessions)", value: "auth" },
1311
+ { label: "Stripe billing + webhooks", value: "billing" },
1312
+ { label: "OpenAI streaming endpoint", value: "ai" },
1313
+ ],
1314
+ defaultSelectedValues: ["vite", "db"],
1315
+ });
1316
+ ctx.ui.notify(
1317
+ details.cancelled ? "Cancelled" : `Picked: ${details.selected.map((s) => s.label).join(", ") || "none"}`,
1318
+ "info",
1319
+ );
1320
+ return;
1321
+ }
1322
+
1323
+ if (mode === "batch" || mode === "questions" || mode === "q") {
1324
+ const details = await askChoiceQuestions(ctx, {
1325
+ title: "Clarifying questions",
1326
+ questions: [
1327
+ {
1328
+ id: "scope",
1329
+ label: "Scope",
1330
+ mode: "single",
1331
+ question: "How broad should this change be?",
1332
+ options: [
1333
+ { label: "Minimal fix", value: "minimal" },
1334
+ { label: "Balanced improvement", value: "balanced" },
1335
+ { label: "Full polish", value: "full" },
1336
+ ],
1337
+ },
1338
+ {
1339
+ id: "edges",
1340
+ label: "Edges",
1341
+ mode: "multiple",
1342
+ question: "Which edge cases should I cover?",
1343
+ options: [
1344
+ { label: "Empty input", value: "empty" },
1345
+ { label: "Invalid values", value: "invalid" },
1346
+ { label: "Cancellation", value: "cancel" },
1347
+ ],
1348
+ minSelections: 1,
1349
+ },
1350
+ ],
1351
+ });
1352
+ ctx.ui.notify(
1353
+ details.cancelled ? "Cancelled" : `Answered: ${details.answers.map((answer) => `${answer.label}=${formatAnswer(answer)}`).join("; ")}`,
1354
+ "info",
1355
+ );
1356
+ return;
1357
+ }
1358
+
1359
+ const details = await askSingleChoice(ctx, {
1360
+ question: "Where should I commit this change?",
1361
+ options: [
1362
+ { label: "Existing branch · feat/picker-ctx", value: "existing" },
1363
+ { label: "New branch off main", value: "new-main" },
1364
+ { label: "New branch off feat/picker-ctx", value: "new-feature" },
1365
+ ],
1366
+ otherLabel: "other branch name…",
1367
+ });
1368
+ ctx.ui.notify(
1369
+ details.cancelled ? "Cancelled" : `Picked: ${details.selected.map((s) => s.label).join(", ")}`,
1370
+ "info",
1371
+ );
1372
+ },
1373
+ });
1374
+
1375
+ pi.on("before_agent_start", (event) => {
1376
+ const activeTools = new Set(pi.getActiveTools());
1377
+ const hasSingle = activeTools.has("single_choice");
1378
+ const hasMultiple = activeTools.has("multiple_choice");
1379
+ const hasQuestions = activeTools.has("choice_questions");
1380
+ if (!hasSingle && !hasMultiple && !hasQuestions) return;
1381
+
1382
+ const lines = [
1383
+ "Choice picker standard format:",
1384
+ ...(hasQuestions
1385
+ ? [
1386
+ "- If you have two or more clarifying questions, prefer one choice_questions call so the user can answer every question in tabs and you receive all answers in one result.",
1387
+ "- For choice_questions, call { title?, questions: [{ id?, label?, mode: 'single' | 'multiple', question, options: [{ label, value?, description? }], allowOther?, minSelections?, maxSelections? }] }. Keep label short because it is shown as a tab tag.",
1388
+ ]
1389
+ : []),
1390
+ ...(hasSingle
1391
+ ? [
1392
+ "- For exactly-one user decisions, call single_choice with { question, options: [{ label, value?, description? }], allowOther? } instead of writing a plain numbered list.",
1393
+ ]
1394
+ : []),
1395
+ ...(hasMultiple
1396
+ ? [
1397
+ "- For choose-any user decisions, call multiple_choice with { question, options: [{ label, value?, description? }], minSelections?, maxSelections?, allowOther? } instead of writing a plain checkbox list.",
1398
+ ]
1399
+ : []),
1400
+ "- After the choice tool returns, continue using the user's selected values.",
1401
+ ];
1402
+ return { systemPrompt: `${event.systemPrompt}\n\n${lines.join("\n")}` };
1403
+ });
1404
+ }