@xynogen/pix-core 0.1.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.
@@ -0,0 +1,1081 @@
1
+ /**
2
+ * Ask Tool — structured questionnaire for pi-coding-agent
3
+ *
4
+ * Single file. Single tool. `ask_user_question` API style:
5
+ * multiple questions, options w/ label/description/preview, multiSelect.
6
+ *
7
+ * Replaces both the old `ask` and the external `ask_user_question` package.
8
+ */
9
+
10
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
11
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
12
+ import { Type, type Static } from "typebox";
13
+ import {
14
+ Container,
15
+ type Component,
16
+ Editor,
17
+ type EditorTheme,
18
+ fuzzyFilter,
19
+ Key,
20
+ matchesKey,
21
+ type KeybindingsManager,
22
+ Markdown,
23
+ type MarkdownTheme,
24
+ Spacer,
25
+ Text,
26
+ type TUI,
27
+ truncateToWidth,
28
+ visibleWidth,
29
+ decodeKittyPrintable,
30
+ wrapTextWithAnsi,
31
+ } from "@earendil-works/pi-tui";
32
+ import { renderSingleSelectRows } from "./single-select-layout.ts";
33
+
34
+ // ── Constants ──────────────────────────────────────────────────────────
35
+
36
+ const MAX_QUESTIONS = 4;
37
+ const MIN_OPTIONS = 2;
38
+ const MAX_OPTIONS = 4;
39
+ const MAX_HEADER_LENGTH = 16;
40
+ const MAX_LABEL_LENGTH = 60;
41
+
42
+ const SENTINEL_FREEFORM = "Type something.";
43
+ const SENTINEL_CHAT = "Chat about this";
44
+ const SENTINEL_NEXT = "Next";
45
+
46
+ const SPLIT_PANE_MIN_WIDTH = 84;
47
+ const SEPARATOR = " │ ";
48
+
49
+ // ── Schema ─────────────────────────────────────────────────────────────
50
+
51
+ const OptionSchema = Type.Object({
52
+ label: Type.String({
53
+ maxLength: MAX_LABEL_LENGTH,
54
+ description: `MAX ${MAX_LABEL_LENGTH} CHARACTERS. Display text for this option. Concise (1-5 words).`,
55
+ }),
56
+ description: Type.String({
57
+ description: "Explanation of what this option means or trade-offs.",
58
+ }),
59
+ preview: Type.Optional(
60
+ Type.String({
61
+ description:
62
+ "Optional markdown preview for side-by-side layout (single-select only).",
63
+ }),
64
+ ),
65
+ });
66
+
67
+ const QuestionSchema = Type.Object({
68
+ question: Type.String({
69
+ description: "Clear, specific question ending with ?",
70
+ }),
71
+ header: Type.String({
72
+ maxLength: MAX_HEADER_LENGTH,
73
+ description: `MAX ${MAX_HEADER_LENGTH} CHARS — short chip/tag. E.g. "Auth method", "Approach".`,
74
+ }),
75
+ options: Type.Array(OptionSchema, {
76
+ minItems: MIN_OPTIONS,
77
+ maxItems: MAX_OPTIONS,
78
+ description:
79
+ "2-4 options. 'Type something.' and 'Chat about this' are auto-appended.",
80
+ }),
81
+ multiSelect: Type.Optional(
82
+ Type.Boolean({
83
+ default: false,
84
+ description:
85
+ "Allow multiple selections. Suppresses 'Type something.' row.",
86
+ }),
87
+ ),
88
+ });
89
+
90
+ const QuestionsSchema = Type.Array(QuestionSchema, {
91
+ minItems: 1,
92
+ maxItems: MAX_QUESTIONS,
93
+ description: "1-4 questions",
94
+ });
95
+
96
+ const ParamsSchema = Type.Object({ questions: QuestionsSchema });
97
+
98
+ type OptionData = Static<typeof OptionSchema>;
99
+ type QuestionData = Static<typeof QuestionSchema>;
100
+ type Params = Static<typeof ParamsSchema>;
101
+
102
+ // ── Answer types ───────────────────────────────────────────────────────
103
+
104
+ type AnswerKind = "option" | "custom" | "chat" | "multi";
105
+
106
+ interface QuestionAnswer {
107
+ questionIndex: number;
108
+ question: string;
109
+ kind: AnswerKind;
110
+ answer: string | null;
111
+ selected?: string[];
112
+ preview?: string;
113
+ }
114
+
115
+ interface QuestionnaireResult {
116
+ answers: QuestionAnswer[];
117
+ cancelled: boolean;
118
+ }
119
+
120
+ // ── Helpers ────────────────────────────────────────────────────────────
121
+
122
+ function safeMarkdownTheme(): MarkdownTheme | undefined {
123
+ try {
124
+ const md = getMarkdownTheme();
125
+ if (!md) return undefined;
126
+ md.bold("");
127
+ return md;
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ export function hasAnyPreview(q: QuestionData): boolean {
134
+ return q.options.some(
135
+ (o) => typeof o.preview === "string" && o.preview.length > 0,
136
+ );
137
+ }
138
+
139
+ /** Which sentinel rows are auto-appended for a question. */
140
+ export function sentinelsFor(
141
+ q: QuestionData,
142
+ ): Array<{ kind: string; label: string }> {
143
+ const out: Array<{ kind: string; label: string }> = [];
144
+ if (q.multiSelect) {
145
+ out.push({ kind: "next", label: SENTINEL_NEXT });
146
+ } else if (!hasAnyPreview(q)) {
147
+ out.push({ kind: "other", label: SENTINEL_FREEFORM });
148
+ }
149
+ // Chat sentinel is always last (in its own row list, not in main list)
150
+ return out;
151
+ }
152
+
153
+ export function formatAnswerScalar(a: QuestionAnswer): string {
154
+ if (a.kind === "multi") return (a.selected ?? []).join(", ");
155
+ if (a.kind === "custom") return a.answer ?? "(custom)";
156
+ if (a.kind === "chat") return "(chat)";
157
+ return a.answer ?? "(selected)";
158
+ }
159
+
160
+ export function buildResponseText(
161
+ answers: QuestionAnswer[],
162
+ questions: QuestionData[],
163
+ ): string {
164
+ const segs: string[] = [];
165
+ for (const a of answers) {
166
+ const q = questions[a.questionIndex]?.question ?? `Q${a.questionIndex + 1}`;
167
+ let s = `"${q}"="${formatAnswerScalar(a)}"`;
168
+ if (a.preview) s += `. selected preview: ${a.preview}`;
169
+ segs.push(s);
170
+ }
171
+ return segs.length
172
+ ? `User answered: ${segs.join(". ")}.`
173
+ : "User declined to answer questions.";
174
+ }
175
+
176
+ // ── Scrollbar helper ───────────────────────────────────────────────────
177
+
178
+ function scrollIndicator(index: number, total: number): string {
179
+ if (total <= 1) return "";
180
+ const pos = Math.round((index / (total - 1)) * 6);
181
+ const bar = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦"][pos] ?? "·";
182
+ return ` ${bar} ${index + 1}/${total}`;
183
+ }
184
+
185
+ // ── TUI Components ─────────────────────────────────────────────────────
186
+
187
+ function borderColor(theme: Theme): (s: string) => string {
188
+ return (s: string) => theme.fg("accent", s);
189
+ }
190
+
191
+ function dim(theme: Theme): (s: string) => string {
192
+ return (s: string) => theme.fg("dim", s);
193
+ }
194
+
195
+ class TabBar implements Component {
196
+ private questions: QuestionData[];
197
+ private activeIndex: number;
198
+ private theme: Theme;
199
+
200
+ constructor(questions: QuestionData[], activeIndex: number, theme: Theme) {
201
+ this.questions = questions;
202
+ this.activeIndex = activeIndex;
203
+ this.theme = theme;
204
+ }
205
+
206
+ invalidate(): void {}
207
+
208
+ render(width: number): string[] {
209
+ const t = this.theme;
210
+ const inner = Math.max(10, width - 2);
211
+ // Build tab labels: "1.Approach 2.Auth 3.Database"
212
+ const parts: string[] = [];
213
+ for (let i = 0; i < this.questions.length; i++) {
214
+ const active = i === this.activeIndex;
215
+ const num = `${i + 1}`;
216
+ const tag = `${num}.${this.questions[i]!.header}`;
217
+ if (active) {
218
+ parts.push(t.fg("accent", t.bold(tag)));
219
+ } else {
220
+ parts.push(t.fg("dim", tag));
221
+ }
222
+ }
223
+ const line = parts.join(t.fg("dim", " "));
224
+ return [
225
+ truncateToWidth(
226
+ t.fg("accent", "╭─") +
227
+ line +
228
+ t.fg(
229
+ "accent",
230
+ "─".repeat(Math.max(0, inner - line.length - 1)) + "╮",
231
+ ),
232
+ width,
233
+ "",
234
+ ),
235
+ ].filter(Boolean);
236
+ }
237
+ }
238
+
239
+ class AskQuestionnaire extends Container {
240
+ private params: Params;
241
+ private tui: TUI;
242
+ private theme: Theme;
243
+ private keybindings: KeybindingsManager;
244
+ private onDone: (result: QuestionnaireResult | null) => void;
245
+
246
+ private currentIndex = 0;
247
+ private answers: QuestionAnswer[] = [];
248
+ private freeformDraft = "";
249
+ private searchQuery = "";
250
+ private selectedOptionIndex = 0;
251
+ private multiChecked = new Set<number>();
252
+ private inputMode = false; // typing freeform text
253
+ private freeformText = "";
254
+ private editor?: Editor;
255
+ private mdTheme = safeMarkdownTheme();
256
+ // Resolve panel width once
257
+ private _splitWidth: number | null = null;
258
+
259
+ constructor(
260
+ params: Params,
261
+ tui: TUI,
262
+ theme: Theme,
263
+ keybindings: KeybindingsManager,
264
+ onDone: (result: QuestionnaireResult | null) => void,
265
+ ) {
266
+ super();
267
+ this.params = params;
268
+ this.tui = tui;
269
+ this.theme = theme;
270
+ this.keybindings = keybindings;
271
+ this.onDone = onDone;
272
+ this.renderLayout();
273
+ }
274
+
275
+ private get currentQ(): QuestionData {
276
+ return this.params.questions[this.currentIndex]!;
277
+ }
278
+
279
+ private get filteredOptions(): OptionData[] {
280
+ if (!this.searchQuery) return this.currentQ.options;
281
+ return fuzzyFilter(
282
+ this.currentQ.options,
283
+ this.searchQuery,
284
+ (o) => `${o.label} ${o.description}`,
285
+ );
286
+ }
287
+
288
+ private get mainListItems(): Array<{
289
+ kind: string;
290
+ label?: string;
291
+ option?: OptionData;
292
+ }> {
293
+ const items: Array<{ kind: string; label?: string; option?: OptionData }> =
294
+ [];
295
+ for (const o of this.filteredOptions) {
296
+ items.push({ kind: "option", option: o });
297
+ }
298
+ for (const s of sentinelsFor(this.currentQ)) {
299
+ items.push({ kind: s.kind, label: s.label });
300
+ }
301
+ return items;
302
+ }
303
+
304
+ private get totalItems(): number {
305
+ return this.mainListItems.length;
306
+ }
307
+
308
+ private clampSelection(): void {
309
+ const total = this.totalItems;
310
+ if (total === 0) {
311
+ this.selectedOptionIndex = 0;
312
+ return;
313
+ }
314
+ this.selectedOptionIndex = Math.max(
315
+ 0,
316
+ Math.min(this.selectedOptionIndex, total - 1),
317
+ );
318
+ }
319
+
320
+ private get selectedItem(): (typeof this.mainListItems)[0] | undefined {
321
+ return this.mainListItems[this.selectedOptionIndex];
322
+ }
323
+
324
+ invalidate(): void {
325
+ super.invalidate();
326
+ this._splitWidth = null;
327
+ }
328
+
329
+ renderLayout(): void {
330
+ this.clear();
331
+ const t = this.theme;
332
+ // Border top
333
+ this.addChild(new Text("", 0, 0)); // placeholder, re-rendered
334
+
335
+ // Tab bar
336
+ if (this.params.questions.length > 1) {
337
+ this.addChild(new TabBar(this.params.questions, this.currentIndex, t));
338
+ }
339
+
340
+ // Question header chip
341
+ const q = this.currentQ;
342
+ const chip = t.fg("accent", t.bold(q.header));
343
+ const prog =
344
+ this.params.questions.length > 1
345
+ ? dim(t)(
346
+ scrollIndicator(this.currentIndex, this.params.questions.length),
347
+ )
348
+ : "";
349
+ this.addChild(new Text(`${chip}${prog}`, 1, 0));
350
+ this.addChild(new Spacer(1));
351
+
352
+ // Question text
353
+ this.addChild(new Text(t.fg("text", t.bold(q.question)), 1, 0));
354
+ this.addChild(new Spacer(1));
355
+
356
+ // Search bar for single-select
357
+ if (!q.multiSelect && !this.inputMode) {
358
+ const searchVal = this.searchQuery
359
+ ? t.fg("text", this.searchQuery)
360
+ : t.fg("dim", "type to filter");
361
+ this.addChild(
362
+ new Text(`${t.fg("accent", "Filter:")} ${searchVal}`, 1, 0),
363
+ );
364
+ }
365
+
366
+ // Options area (filled on render)
367
+ this.addChild(new Spacer(1));
368
+
369
+ // Input mode editor
370
+ if (this.inputMode) {
371
+ this.addChild(this.ensureEditor());
372
+ }
373
+
374
+ // Footer hints
375
+ this.addChild(new Spacer(1));
376
+ this.addChild(this.buildHintText());
377
+
378
+ // Border bottom
379
+ this.addChild(new Text("", 0, 0));
380
+ }
381
+
382
+ private ensureEditor(): Editor {
383
+ if (this.editor) return this.editor;
384
+ const editor = new Editor(this.tui, {
385
+ borderColor: (s: string) => this.theme.fg("accent", s),
386
+ selectList: {
387
+ selectedPrefix: (s: string) => this.theme.fg("accent", s),
388
+ selectedText: (s: string) => this.theme.fg("accent", s),
389
+ description: (s: string) => this.theme.fg("muted", s),
390
+ scrollInfo: (s: string) => this.theme.fg("dim", s),
391
+ noMatch: (s: string) => this.theme.fg("warning", s),
392
+ },
393
+ });
394
+ editor.disableSubmit = false;
395
+ editor.onSubmit = (text: string) => this.handleFreeformSubmit(text);
396
+ (editor as any).focused = true;
397
+ this.editor = editor;
398
+ return editor;
399
+ }
400
+
401
+ private buildHintText(): Text {
402
+ const t = this.theme;
403
+ const isMulti = !!this.currentQ.multiSelect;
404
+ const hints: string[] = [];
405
+ if (this.inputMode) {
406
+ hints.push(dim(t)("enter=submit • esc=back • ^c=cancel"));
407
+ } else if (isMulti) {
408
+ hints.push(
409
+ dim(t)(
410
+ "↑↓=nav • space=toggle • enter=commit & next • esc=clear • ^c=cancel",
411
+ ),
412
+ );
413
+ } else {
414
+ hints.push(
415
+ dim(t)("↑↓=nav • type=filter • enter=select • esc=clear • ^c=cancel"),
416
+ );
417
+ }
418
+ return new Text(hints.join("\n"), 1, 0);
419
+ }
420
+
421
+ private recordAnswer(
422
+ kind: AnswerKind,
423
+ answer: string | null,
424
+ selected?: string[],
425
+ preview?: string,
426
+ ): void {
427
+ // Remove any previous answer for this question
428
+ this.answers = this.answers.filter(
429
+ (a) => a.questionIndex !== this.currentIndex,
430
+ );
431
+ this.answers.push({
432
+ questionIndex: this.currentIndex,
433
+ question: this.currentQ.question,
434
+ kind,
435
+ answer,
436
+ selected,
437
+ preview,
438
+ });
439
+ }
440
+
441
+ private commitAnswer(): void {
442
+ const item = this.selectedItem;
443
+ if (!item) {
444
+ this.cancel();
445
+ return;
446
+ }
447
+
448
+ if (item.kind === "option" && item.option) {
449
+ this.recordAnswer(
450
+ "option",
451
+ item.option.label,
452
+ undefined,
453
+ item.option.preview,
454
+ );
455
+ this.nextQuestion();
456
+ } else if (item.kind === "other") {
457
+ this.inputMode = true;
458
+ this.freeformText = "";
459
+ (this.ensureEditor() as any).focused = true;
460
+ this.invalidate();
461
+ this.renderLayout();
462
+ this.tui.requestRender();
463
+ } else if (item.kind === "next") {
464
+ // multi-select commit
465
+ const selected = Array.from(this.multiChecked)
466
+ .sort((a, b) => a - b)
467
+ .map((i) => this.currentQ.options[i]!.label);
468
+ if (selected.length === 0) {
469
+ this.cancel();
470
+ return;
471
+ }
472
+ this.recordAnswer("multi", null, selected);
473
+ this.nextQuestion();
474
+ }
475
+ }
476
+
477
+ private handleFreeformSubmit(text: string): void {
478
+ if (!text.trim()) {
479
+ this.cancel();
480
+ return;
481
+ }
482
+ this.recordAnswer("custom", text.trim());
483
+ this.nextQuestion();
484
+ }
485
+
486
+ private nextQuestion(): void {
487
+ this.searchQuery = "";
488
+ this.multiChecked.clear();
489
+ this.inputMode = false;
490
+ this.selectedOptionIndex = 0;
491
+ this.freeformText = "";
492
+ this.editor = undefined;
493
+
494
+ if (this.currentIndex + 1 < this.params.questions.length) {
495
+ this.currentIndex++;
496
+ this.invalidate();
497
+ this.renderLayout();
498
+ this.tui.requestRender();
499
+ } else {
500
+ this.onDone({ answers: this.answers, cancelled: false });
501
+ }
502
+ }
503
+
504
+ private cancel(): void {
505
+ this.onDone({ answers: this.answers, cancelled: true });
506
+ }
507
+
508
+ private commitMulti(): void {
509
+ const checked = Array.from(this.multiChecked).sort((a, b) => a - b);
510
+ if (checked.length === 0) {
511
+ this.cancel();
512
+ return;
513
+ }
514
+ const selected = checked.map((i) => this.currentQ.options[i]!.label);
515
+ this.recordAnswer("multi", null, selected);
516
+ this.nextQuestion();
517
+ }
518
+
519
+ private toggleMulti(index: number): void {
520
+ if (index < 0 || index >= this.currentQ.options.length) return;
521
+ if (this.multiChecked.has(index)) this.multiChecked.delete(index);
522
+ else this.multiChecked.add(index);
523
+ this.invalidate();
524
+ }
525
+
526
+ handleInput(data: string): void {
527
+ // Global: cancel
528
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
529
+ this.cancel();
530
+ return;
531
+ }
532
+
533
+ // Input mode: handle editor keys
534
+ if (this.inputMode) {
535
+ if (matchesKey(data, Key.escape)) {
536
+ this.inputMode = false;
537
+ this.editor = undefined;
538
+ this.invalidate();
539
+ this.renderLayout();
540
+ this.tui.requestRender();
541
+ return;
542
+ }
543
+ // Forward all other keys to the Editor (typing, enter=submit, etc.)
544
+ this.ensureEditor().handleInput(data);
545
+ this.tui.requestRender();
546
+ return;
547
+ }
548
+
549
+ const isMulti = !!this.currentQ.multiSelect;
550
+ const total = this.totalItems;
551
+
552
+ // Navigation
553
+ if (
554
+ this.keybindings.matches(data, "tui.select.up") ||
555
+ matchesKey(data, Key.shift("tab")) ||
556
+ matchesKey(data, Key.ctrl("k"))
557
+ ) {
558
+ if (total > 0) {
559
+ this.selectedOptionIndex =
560
+ (this.selectedOptionIndex - 1 + total) % total;
561
+ this.invalidate();
562
+ this.tui.requestRender();
563
+ }
564
+ return;
565
+ }
566
+
567
+ if (
568
+ this.keybindings.matches(data, "tui.select.down") ||
569
+ matchesKey(data, Key.tab) ||
570
+ matchesKey(data, Key.ctrl("j"))
571
+ ) {
572
+ if (total > 0) {
573
+ this.selectedOptionIndex = (this.selectedOptionIndex + 1) % total;
574
+ this.invalidate();
575
+ this.tui.requestRender();
576
+ }
577
+ return;
578
+ }
579
+
580
+ // Backspace: pop search
581
+ if (
582
+ this.keybindings.matches(data, "tui.editor.deleteCharBackward") ||
583
+ matchesKey(data, Key.backspace)
584
+ ) {
585
+ if (this.searchQuery) {
586
+ const chars = [...this.searchQuery];
587
+ chars.pop();
588
+ this.searchQuery = chars.join("");
589
+ this.selectedOptionIndex = 0;
590
+ this.invalidate();
591
+ this.tui.requestRender();
592
+ }
593
+ return;
594
+ }
595
+
596
+ // Escape: clear search
597
+ if (matchesKey(data, Key.escape)) {
598
+ if (this.searchQuery) {
599
+ this.searchQuery = "";
600
+ this.selectedOptionIndex = 0;
601
+ this.invalidate();
602
+ this.tui.requestRender();
603
+ }
604
+ return;
605
+ }
606
+
607
+ // Space: toggle multi-select
608
+ if (matchesKey(data, Key.space) && isMulti) {
609
+ if (this.selectedItem?.kind === "option" && this.selectedItem.option) {
610
+ const idx = this.filteredOptions.indexOf(this.selectedItem.option);
611
+ if (idx >= 0) this.toggleMulti(idx);
612
+ this.invalidate();
613
+ this.tui.requestRender();
614
+ }
615
+ return;
616
+ }
617
+
618
+ // Number shortcut
619
+ const numMatch = data.match(/^[1-9]$/);
620
+ if (numMatch && this.filteredOptions.length > 0) {
621
+ const idx = Number(numMatch[0]) - 1;
622
+ if (idx >= 0 && idx < this.filteredOptions.length) {
623
+ if (isMulti) {
624
+ this.toggleMulti(idx);
625
+ this.selectedOptionIndex = Math.min(idx, this.totalItems - 1);
626
+ this.invalidate();
627
+ this.tui.requestRender();
628
+ } else {
629
+ // Direct select
630
+ const opt = this.filteredOptions[idx]!;
631
+ this.recordAnswer("option", opt.label, undefined, opt.preview);
632
+ this.nextQuestion();
633
+ }
634
+ return;
635
+ }
636
+ }
637
+
638
+ // Submit / select
639
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
640
+ this.commitAnswer();
641
+ return;
642
+ }
643
+
644
+ // Search input: type to filter
645
+ if (!isMulti) {
646
+ const printable = decodeKittyPrintable(data);
647
+ if (printable !== undefined) {
648
+ this.searchQuery += printable;
649
+ this.selectedOptionIndex = 0;
650
+ this.invalidate();
651
+ this.tui.requestRender();
652
+ return;
653
+ }
654
+ const chars = [...data];
655
+ if (
656
+ chars.length === 1 &&
657
+ chars[0] &&
658
+ chars[0].charCodeAt(0) >= 32 &&
659
+ chars[0].charCodeAt(0) < 127
660
+ ) {
661
+ this.searchQuery += chars[0];
662
+ this.selectedOptionIndex = 0;
663
+ this.invalidate();
664
+ this.tui.requestRender();
665
+ }
666
+ }
667
+ }
668
+
669
+ private renderOptions(width: number): string[] {
670
+ const t = this.theme;
671
+ const inner = Math.max(20, width - 6);
672
+ const isMulti = !!this.currentQ.multiSelect;
673
+ const items = this.mainListItems;
674
+ const total = items.length;
675
+ const chk = (i: number) =>
676
+ isMulti
677
+ ? this.multiChecked.has(i)
678
+ ? t.fg("success", "✓")
679
+ : t.fg("dim", "○")
680
+ : "";
681
+
682
+ if (total === 0) return [t.fg("warning", "No options")];
683
+
684
+ const maxVisible = Math.min(total, 12);
685
+ const start = Math.max(
686
+ 0,
687
+ Math.min(
688
+ this.selectedOptionIndex - Math.floor(maxVisible / 2),
689
+ total - maxVisible,
690
+ ),
691
+ );
692
+ const end = Math.min(start + maxVisible, total);
693
+
694
+ const lines: string[] = [];
695
+ const pad = " ";
696
+
697
+ for (let i = start; i < end; i++) {
698
+ const item = items[i]!;
699
+ const sel = i === this.selectedOptionIndex;
700
+ const ptr = sel ? t.fg("accent", "→") : " ";
701
+
702
+ if (item.kind === "option" && item.option) {
703
+ const optIdx = this.filteredOptions.indexOf(item.option);
704
+ const checkbox = isMulti ? ` ${chk(optIdx)}` : "";
705
+ const num = t.fg("dim", `${optIdx + 1}.`);
706
+ const label = sel
707
+ ? t.fg("accent", t.bold(item.option.label))
708
+ : t.fg("text", t.bold(item.option.label));
709
+ lines.push(
710
+ truncateToWidth(`${ptr} ${num}${checkbox} ${label}`, inner, ""),
711
+ );
712
+ if (item.option.description) {
713
+ const wrapped = wrapTextWithAnsi(
714
+ item.option.description,
715
+ Math.max(10, inner - 6),
716
+ );
717
+ for (const w of wrapped) {
718
+ lines.push(truncateToWidth(`${pad}${t.fg("muted", w)}`, inner, ""));
719
+ }
720
+ }
721
+ } else if (item.kind === "other") {
722
+ const label = sel
723
+ ? t.fg("accent", t.bold(SENTINEL_FREEFORM))
724
+ : t.fg("text", t.bold(SENTINEL_FREEFORM));
725
+ lines.push(
726
+ truncateToWidth(`${ptr} ${t.fg("dim", "✎")} ${label}`, inner, ""),
727
+ );
728
+ } else if (item.kind === "next") {
729
+ const label = sel
730
+ ? t.fg("accent", t.bold(SENTINEL_NEXT))
731
+ : t.fg("text", t.bold(SENTINEL_NEXT));
732
+ lines.push(
733
+ truncateToWidth(`${ptr} ${t.fg("dim", "→")} ${label}`, inner, ""),
734
+ );
735
+ }
736
+ }
737
+
738
+ if (start > 0 || end < total) {
739
+ const count =
740
+ this.filteredOptions.length > 0
741
+ ? `${this.selectedOptionIndex + 1}/${total}`
742
+ : `${total}`;
743
+ lines.push(t.fg("dim", truncateToWidth(` ${count}`, inner, "")));
744
+ }
745
+
746
+ return lines;
747
+ }
748
+
749
+ private renderPreview(width: number): string[] {
750
+ const item = this.selectedItem;
751
+ if (!item || item.kind !== "option" || !item.option?.preview) {
752
+ return [this.theme.fg("dim", "No preview")];
753
+ }
754
+
755
+ const mdText = item.option.preview;
756
+ const mdWidth = Math.max(10, width);
757
+
758
+ if (this.mdTheme) {
759
+ const md = new Markdown(
760
+ `## ${item.option.label}\n\n${mdText}`,
761
+ 0,
762
+ 0,
763
+ this.mdTheme,
764
+ );
765
+ return md.render(mdWidth);
766
+ }
767
+
768
+ const lines = wrapTextWithAnsi(mdText, mdWidth);
769
+ return lines.map((l) =>
770
+ truncateToWidth(this.theme.fg("muted", l), mdWidth, ""),
771
+ );
772
+ }
773
+
774
+ override render(width: number): string[] {
775
+ const inner = Math.max(20, width - 4);
776
+ const t = this.theme;
777
+ const isMulti = !!this.currentQ.multiSelect;
778
+ const hasPreview =
779
+ !isMulti &&
780
+ this.selectedItem?.kind === "option" &&
781
+ !!this.selectedItem?.option?.preview;
782
+
783
+ // Decide layout: split pane if preview and wide enough
784
+ const useSplit = hasPreview && width >= SPLIT_PANE_MIN_WIDTH;
785
+ const leftWidth = useSplit ? Math.floor((width - 6) * 0.45) : inner;
786
+ const previewWidth = useSplit ? Math.max(20, width - leftWidth - 10) : 0;
787
+
788
+ const lines: string[] = [];
789
+
790
+ // Build a bordered body row: pad/truncate the (ANSI-containing) content to
791
+ // exactly `inner` visible columns, then wrap in side borders. Using
792
+ // visibleWidth() (not String.length) keeps ANSI codes + wide glyphs honest.
793
+ const row = (content: string): string => {
794
+ return " " + truncateToWidth(content, Math.max(0, width - 1), "");
795
+ };
796
+
797
+ // Tab bar
798
+ if (this.params.questions.length > 1) {
799
+ const tabParts: string[] = [];
800
+ for (let i = 0; i < this.params.questions.length; i++) {
801
+ const active = i === this.currentIndex;
802
+ const tag = `${i + 1}.${this.params.questions[i]!.header}`;
803
+ tabParts.push(active ? t.fg("accent", t.bold(tag)) : t.fg("dim", tag));
804
+ }
805
+ const tabLine = tabParts.join(t.fg("dim", " "));
806
+ lines.push(row(tabLine));
807
+ }
808
+
809
+ // Header chip
810
+ const chip = t.fg("accent", t.bold(this.currentQ.header));
811
+ const prog =
812
+ this.params.questions.length > 1
813
+ ? dim(t)(` ${this.currentIndex + 1}/${this.params.questions.length}`)
814
+ : "";
815
+ lines.push(row(`${chip}${prog}`));
816
+
817
+ // Question text
818
+ const questionWrapped = wrapTextWithAnsi(
819
+ this.currentQ.question,
820
+ Math.max(10, inner),
821
+ );
822
+ for (const w of questionWrapped) {
823
+ lines.push(row(t.fg("text", t.bold(w))));
824
+ }
825
+
826
+ // Input mode: render the freeform editor instead of the options list.
827
+ if (this.inputMode) {
828
+ lines.push("");
829
+ lines.push(row(t.fg("accent", t.bold("Type your response:"))));
830
+ lines.push("");
831
+ const editorLines = this.ensureEditor().render(Math.max(0, width - 1));
832
+ for (const el of editorLines)
833
+ lines.push(" " + truncateToWidth(el, Math.max(0, width - 1), ""));
834
+ lines.push("");
835
+ lines.push(row(dim(t)("enter submit • esc back • ctrl+c cancel")));
836
+ lines.push("");
837
+ return lines.map((l) => truncateToWidth(l, width, ""));
838
+ }
839
+
840
+ // Search bar
841
+ if (!isMulti) {
842
+ const searchVal = this.searchQuery
843
+ ? t.fg("text", this.searchQuery)
844
+ : t.fg("dim", "type to filter");
845
+ lines.push(row(`${t.fg("accent", "Filter:")} ${searchVal}`));
846
+ }
847
+
848
+ // Chat sentinel row (above options for single-select, always visible)
849
+ const chatLabel =
850
+ this.selectedOptionIndex === -999
851
+ ? t.fg("accent", t.bold(SENTINEL_CHAT))
852
+ : t.fg("dim", SENTINEL_CHAT);
853
+ lines.push(row(` ${t.fg("dim", "💬")} ${chatLabel}`));
854
+
855
+ // Options (with optional preview pane)
856
+ const optionLines = this.renderOptions(useSplit ? leftWidth : width - 4);
857
+ const previewLines = useSplit ? this.renderPreview(previewWidth) : [];
858
+ const maxOptLines = Math.max(optionLines.length, previewLines.length);
859
+
860
+ if (useSplit) {
861
+ const sep = t.fg("dim", SEPARATOR);
862
+ for (let i = 0; i < maxOptLines; i++) {
863
+ const left = truncateToWidth(
864
+ optionLines[i] ?? "",
865
+ leftWidth - 1,
866
+ "",
867
+ true,
868
+ );
869
+ const right = truncateToWidth(
870
+ previewLines[i] ?? "",
871
+ previewWidth - 2,
872
+ "",
873
+ );
874
+ const paintedLeft = left || " ".repeat(leftWidth - 1);
875
+ const paintedRight = right || " ".repeat(previewWidth - 2);
876
+ const body = `${paintedLeft}${sep}${paintedRight}`;
877
+ lines.push(" " + truncateToWidth(body, Math.max(0, width - 1), ""));
878
+ }
879
+ } else {
880
+ for (const line of optionLines) {
881
+ lines.push(row(line));
882
+ }
883
+ }
884
+
885
+ // Footer hints
886
+ const hintTexts: string[] = [];
887
+ if (isMulti) {
888
+ hintTexts.push("↑↓ nav • space toggle • enter commit • esc clear");
889
+ } else {
890
+ hintTexts.push("↑↓ nav • type filter • enter select • esc clear");
891
+ }
892
+ hintTexts.push("ctrl+c cancel");
893
+ const hint = dim(t)(hintTexts.join(" • "));
894
+ lines.push(row(hint));
895
+ lines.push("");
896
+
897
+ // Final safety net: never emit a line wider than the terminal.
898
+ return lines.map((l) => truncateToWidth(l, width, ""));
899
+ }
900
+ }
901
+
902
+ // ── RPC fallback ───────────────────────────────────────────────────────
903
+
904
+ async function rpcFallback(
905
+ ui: { select: Function; input: Function },
906
+ params: Params,
907
+ ): Promise<QuestionnaireResult> {
908
+ const answers: QuestionAnswer[] = [];
909
+ let cancelled = false;
910
+
911
+ for (let i = 0; i < params.questions.length; i++) {
912
+ const q = params.questions[i]!;
913
+ const header = q.header;
914
+
915
+ if (q.multiSelect) {
916
+ const lines = q.options.map(
917
+ (o, idx) => `${idx + 1}. ${o.label} — ${o.description}`,
918
+ );
919
+ const raw = await ui.input(
920
+ `${header}: ${q.question}\n\n${lines.join("\n")}\n\nEnter numbers separated by commas:`,
921
+ "e.g. 1,3",
922
+ );
923
+ if (raw == null) {
924
+ cancelled = true;
925
+ break;
926
+ }
927
+ const indices = String(raw)
928
+ .split(",")
929
+ .map((s) => Number(s.trim()))
930
+ .filter((n) => n >= 1 && n <= q.options.length);
931
+ const selected = indices.map((n) => q.options[n - 1]!.label);
932
+ if (selected.length > 0) {
933
+ answers.push({
934
+ questionIndex: i,
935
+ question: q.question,
936
+ kind: "multi",
937
+ answer: null,
938
+ selected,
939
+ });
940
+ } else {
941
+ cancelled = true;
942
+ break;
943
+ }
944
+ } else {
945
+ const items = q.options.map((o) => `${o.label} — ${o.description}`);
946
+ items.push(SENTINEL_FREEFORM);
947
+ const chosen = await ui.select(`${header}: ${q.question}`, items);
948
+ if (chosen == null) {
949
+ cancelled = true;
950
+ break;
951
+ }
952
+ if (chosen === SENTINEL_FREEFORM) {
953
+ const text = await ui.input(q.question, "Type your answer...");
954
+ if (text == null) {
955
+ cancelled = true;
956
+ break;
957
+ }
958
+ answers.push({
959
+ questionIndex: i,
960
+ question: q.question,
961
+ kind: "custom",
962
+ answer: String(text),
963
+ });
964
+ } else {
965
+ const opt = q.options.find(
966
+ (o) =>
967
+ chosen === o.label || `${o.label} — ${o.description}` === chosen,
968
+ )!;
969
+ answers.push({
970
+ questionIndex: i,
971
+ question: q.question,
972
+ kind: "option",
973
+ answer: opt?.label ?? String(chosen),
974
+ });
975
+ }
976
+ }
977
+ }
978
+
979
+ return { answers, cancelled };
980
+ }
981
+
982
+ // ── Tool registration ──────────────────────────────────────────────────
983
+
984
+ export default function registerAsk(pi: ExtensionAPI): void {
985
+ pi.registerTool({
986
+ name: "ask_user",
987
+ label: "Ask",
988
+ description: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous.`,
989
+ promptSnippet: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`,
990
+ promptGuidelines: [
991
+ `Use ask whenever the user's request is underspecified and you cannot proceed without concrete decisions — you can ask up to ${MAX_QUESTIONS} questions per invocation.`,
992
+ `Each question MUST have ${MIN_OPTIONS}-${MAX_OPTIONS} options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer ("${SENTINEL_FREEFORM}" row is appended automatically to single-select questions) or pick "${SENTINEL_CHAT}" to abandon the questionnaire.`,
993
+ `Set multiSelect: true when multiple answers are valid; this suppresses the "${SENTINEL_FREEFORM}" row. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) — single-select only. NOTE: any non-empty preview on a single-select question ALSO suppresses the "${SENTINEL_FREEFORM}" row (no room in the side-by-side layout); "${SENTINEL_CHAT}" remains the escape hatch. If you recommend a specific option, make it the first option and append "(Recommended)" to its label.`,
994
+ "Do not stack multiple ask calls back-to-back — group all clarifying questions into one invocation.",
995
+ ],
996
+ executionMode: "sequential",
997
+ parameters: ParamsSchema,
998
+
999
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1000
+ if (signal?.aborted) {
1001
+ return {
1002
+ content: [{ type: "text", text: "Cancelled" }],
1003
+ details: { answers: [], cancelled: true },
1004
+ };
1005
+ }
1006
+
1007
+ const typed = params as unknown as Params;
1008
+
1009
+ // Validate
1010
+ if (!Array.isArray(typed.questions) || typed.questions.length === 0) {
1011
+ return {
1012
+ content: [
1013
+ { type: "text", text: "At least one question is required." },
1014
+ ],
1015
+ isError: true,
1016
+ details: { answers: [], cancelled: true },
1017
+ };
1018
+ }
1019
+
1020
+ if (!ctx.hasUI) {
1021
+ const result = await rpcFallback(ctx.ui, typed);
1022
+ const text = result.cancelled
1023
+ ? "User cancelled the questionnaire"
1024
+ : buildResponseText(result.answers, typed.questions);
1025
+ return { content: [{ type: "text", text }], details: result };
1026
+ }
1027
+
1028
+ // Render inline in the conversation thread (no floating overlay).
1029
+ const result = await ctx.ui.custom<QuestionnaireResult | null>(
1030
+ (tui, theme, keybindings, done) => {
1031
+ if (signal) {
1032
+ signal.addEventListener(
1033
+ "abort",
1034
+ () => done({ answers: [], cancelled: true }),
1035
+ { once: true },
1036
+ );
1037
+ }
1038
+ return new AskQuestionnaire(typed, tui, theme, keybindings, done);
1039
+ },
1040
+ );
1041
+
1042
+ if (!result || result.cancelled) {
1043
+ return {
1044
+ content: [{ type: "text", text: "User cancelled the questionnaire" }],
1045
+ details: result ?? { answers: [], cancelled: true },
1046
+ };
1047
+ }
1048
+
1049
+ const text = buildResponseText(result.answers, typed.questions);
1050
+ return { content: [{ type: "text", text }], details: result };
1051
+ },
1052
+
1053
+ renderCall(args, theme) {
1054
+ const questions = Array.isArray(args.questions) ? args.questions : [];
1055
+ const count = questions.length;
1056
+ const firstQ = (questions[0]?.question ?? "") as string;
1057
+ let text = theme.fg("toolTitle", theme.bold(`ask (${count}) `));
1058
+ text += theme.fg("muted", firstQ);
1059
+ if (count > 1) text += theme.fg("dim", ` +${count - 1} more`);
1060
+ return new Text(text, 0, 0);
1061
+ },
1062
+
1063
+ renderResult(result, options, theme) {
1064
+ const details = result.details as
1065
+ | { answers?: QuestionAnswer[]; cancelled?: boolean }
1066
+ | undefined;
1067
+ if (options.isPartial) {
1068
+ return new Text(theme.fg("muted", "Waiting for user input..."), 0, 0);
1069
+ }
1070
+ if (!details || details.cancelled || !details.answers?.length) {
1071
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
1072
+ }
1073
+ const texts = details.answers.map((a) => {
1074
+ const v =
1075
+ a.kind === "multi" ? (a.selected ?? []).join(", ") : (a.answer ?? "");
1076
+ return `${a.questionIndex + 1}: ${v}`;
1077
+ });
1078
+ return new Text(theme.fg("success", `✓ ${texts.join(" • ")}`), 0, 0);
1079
+ },
1080
+ });
1081
+ }