@xynogen/pix-core 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,5 @@
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";
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
12
2
  import {
13
- type Component,
14
3
  Container,
15
4
  decodeKittyPrintable,
16
5
  Editor,
@@ -18,7 +7,6 @@ import {
18
7
  Key,
19
8
  type KeybindingsManager,
20
9
  Markdown,
21
- type MarkdownTheme,
22
10
  matchesKey,
23
11
  Spacer,
24
12
  Text,
@@ -26,214 +14,26 @@ import {
26
14
  truncateToWidth,
27
15
  wrapTextWithAnsi,
28
16
  } from "@earendil-works/pi-tui";
29
- import { type Static, Type } from "typebox";
30
-
31
- // ── Constants ──────────────────────────────────────────────────────────
32
-
33
- const MAX_QUESTIONS = 4;
34
- const MIN_OPTIONS = 2;
35
- const MAX_OPTIONS = 4;
36
- const MAX_HEADER_LENGTH = 16;
37
- const MAX_LABEL_LENGTH = 60;
38
-
39
- const SENTINEL_FREEFORM = "Type something.";
40
- const SENTINEL_CHAT = "Chat about this";
41
- const SENTINEL_NEXT = "Next";
42
-
43
- const SPLIT_PANE_MIN_WIDTH = 84;
44
- const SEPARATOR = " │ ";
45
-
46
- // ── Schema ─────────────────────────────────────────────────────────────
47
-
48
- const OptionSchema = Type.Object({
49
- label: Type.String({
50
- maxLength: MAX_LABEL_LENGTH,
51
- description: `MAX ${MAX_LABEL_LENGTH} CHARACTERS. Display text for this option. Concise (1-5 words).`,
52
- }),
53
- description: Type.String({
54
- description: "Explanation of what this option means or trade-offs.",
55
- }),
56
- preview: Type.Optional(
57
- Type.String({
58
- description:
59
- "Optional markdown preview for side-by-side layout (single-select only).",
60
- }),
61
- ),
62
- });
63
-
64
- const QuestionSchema = Type.Object({
65
- question: Type.String({
66
- description: "Clear, specific question ending with ?",
67
- }),
68
- header: Type.String({
69
- maxLength: MAX_HEADER_LENGTH,
70
- description: `MAX ${MAX_HEADER_LENGTH} CHARS — short chip/tag. E.g. "Auth method", "Approach".`,
71
- }),
72
- options: Type.Array(OptionSchema, {
73
- minItems: MIN_OPTIONS,
74
- maxItems: MAX_OPTIONS,
75
- description:
76
- "2-4 options. 'Type something.' and 'Chat about this' are auto-appended.",
77
- }),
78
- multiSelect: Type.Optional(
79
- Type.Boolean({
80
- default: false,
81
- description:
82
- "Allow multiple selections. Suppresses 'Type something.' row.",
83
- }),
84
- ),
85
- });
86
-
87
- const QuestionsSchema = Type.Array(QuestionSchema, {
88
- minItems: 1,
89
- maxItems: MAX_QUESTIONS,
90
- description: "1-4 questions",
91
- });
92
-
93
- const ParamsSchema = Type.Object({ questions: QuestionsSchema });
94
-
95
- export type OptionData = Static<typeof OptionSchema>;
96
- export type QuestionData = Static<typeof QuestionSchema>;
97
- type Params = Static<typeof ParamsSchema>;
98
-
99
- // ── Answer types ───────────────────────────────────────────────────────
100
-
101
- type AnswerKind = "option" | "custom" | "chat" | "multi";
102
-
103
- interface QuestionAnswer {
104
- questionIndex: number;
105
- question: string;
106
- kind: AnswerKind;
107
- answer: string | null;
108
- selected?: string[];
109
- preview?: string;
110
- }
111
-
112
- interface QuestionnaireResult {
113
- answers: QuestionAnswer[];
114
- cancelled: boolean;
115
- }
116
-
117
- // ── Helpers ────────────────────────────────────────────────────────────
118
-
119
- function safeMarkdownTheme(): MarkdownTheme | undefined {
120
- try {
121
- const md = getMarkdownTheme();
122
- if (!md) return undefined;
123
- md.bold("");
124
- return md;
125
- } catch {
126
- return undefined;
127
- }
128
- }
129
-
130
- export function hasAnyPreview(q: QuestionData): boolean {
131
- return q.options.some(
132
- (o) => typeof o.preview === "string" && o.preview.length > 0,
133
- );
134
- }
135
-
136
- /** Which sentinel rows are auto-appended for a question. */
137
- export function sentinelsFor(
138
- q: QuestionData,
139
- ): Array<{ kind: string; label: string }> {
140
- const out: Array<{ kind: string; label: string }> = [];
141
- if (q.multiSelect) {
142
- out.push({ kind: "next", label: SENTINEL_NEXT });
143
- } else if (!hasAnyPreview(q)) {
144
- out.push({ kind: "other", label: SENTINEL_FREEFORM });
145
- }
146
- // Chat sentinel is always last (in its own row list, not in main list)
147
- return out;
148
- }
149
-
150
- export function formatAnswerScalar(a: QuestionAnswer): string {
151
- if (a.kind === "multi") return (a.selected ?? []).join(", ");
152
- if (a.kind === "custom") return a.answer ?? "(custom)";
153
- if (a.kind === "chat") return "(chat)";
154
- return a.answer ?? "(selected)";
155
- }
156
-
157
- export function buildResponseText(
158
- answers: QuestionAnswer[],
159
- questions: QuestionData[],
160
- ): string {
161
- const segs: string[] = [];
162
- for (const a of answers) {
163
- const q = questions[a.questionIndex]?.question ?? `Q${a.questionIndex + 1}`;
164
- let s = `"${q}"="${formatAnswerScalar(a)}"`;
165
- if (a.preview) s += `. selected preview: ${a.preview}`;
166
- segs.push(s);
167
- }
168
- return segs.length
169
- ? `User answered: ${segs.join(". ")}.`
170
- : "User declined to answer questions.";
171
- }
172
-
173
- // ── Scrollbar helper ───────────────────────────────────────────────────
174
-
175
- function scrollIndicator(index: number, total: number): string {
176
- if (total <= 1) return "";
177
- const pos = Math.round((index / (total - 1)) * 6);
178
- const bar = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦"][pos] ?? "·";
179
- return ` ${bar} ${index + 1}/${total}`;
180
- }
181
-
182
- // ── TUI Components ─────────────────────────────────────────────────────
183
-
184
- function _borderColor(theme: Theme): (s: string) => string {
185
- return (s: string) => theme.fg("accent", s);
186
- }
187
17
 
188
- function dim(theme: Theme): (s: string) => string {
189
- return (s: string) => theme.fg("dim", s);
190
- }
191
-
192
- class TabBar implements Component {
193
- private questions: QuestionData[];
194
- private activeIndex: number;
195
- private theme: Theme;
196
-
197
- constructor(questions: QuestionData[], activeIndex: number, theme: Theme) {
198
- this.questions = questions;
199
- this.activeIndex = activeIndex;
200
- this.theme = theme;
201
- }
202
-
203
- invalidate(): void {}
204
-
205
- render(width: number): string[] {
206
- const t = this.theme;
207
- const inner = Math.max(10, width - 2);
208
- // Build tab labels: "1.Approach 2.Auth 3.Database"
209
- const parts: string[] = [];
210
- for (let i = 0; i < this.questions.length; i++) {
211
- const active = i === this.activeIndex;
212
- const num = `${i + 1}`;
213
- const tag = `${num}.${this.questions[i]?.header}`;
214
- if (active) {
215
- parts.push(t.fg("accent", t.bold(tag)));
216
- } else {
217
- parts.push(t.fg("dim", tag));
218
- }
219
- }
220
- const line = parts.join(t.fg("dim", " "));
221
- return [
222
- truncateToWidth(
223
- t.fg("accent", "╭─") +
224
- line +
225
- t.fg(
226
- "accent",
227
- `${"─".repeat(Math.max(0, inner - line.length - 1))}╮`,
228
- ),
229
- width,
230
- "",
231
- ),
232
- ].filter(Boolean);
233
- }
234
- }
235
-
236
- class AskQuestionnaire extends Container {
18
+ import { dim, TabBar } from "./components.js";
19
+ import { safeMarkdownTheme, scrollIndicator, sentinelsFor } from "./helpers.js";
20
+ import type { OptionData, Params, QuestionData } from "./schema.js";
21
+ import {
22
+ SENTINEL_CHAT,
23
+ SENTINEL_FREEFORM,
24
+ SENTINEL_NEXT,
25
+ SEPARATOR,
26
+ SPLIT_PANE_MIN_WIDTH,
27
+ } from "./schema.js";
28
+ import type {
29
+ AnswerKind,
30
+ QuestionAnswer,
31
+ QuestionnaireResult,
32
+ } from "./types.js";
33
+
34
+ // ── AskQuestionnaire ───────────────────────────────────────────────────
35
+
36
+ export class AskQuestionnaire extends Container {
237
37
  private params: Params;
238
38
  private tui: TUI;
239
39
  private theme: Theme;
@@ -245,12 +45,9 @@ class AskQuestionnaire extends Container {
245
45
  private searchQuery = "";
246
46
  private selectedOptionIndex = 0;
247
47
  private multiChecked = new Set<number>();
248
- private inputMode = false; // typing freeform text
249
- private freeformText = "";
48
+ private inputMode = false;
250
49
  private editor?: Editor;
251
50
  private mdTheme = safeMarkdownTheme();
252
- // Resolve panel width once
253
- private _splitWidth: number | null = null;
254
51
 
255
52
  constructor(
256
53
  params: Params,
@@ -268,6 +65,8 @@ class AskQuestionnaire extends Container {
268
65
  this.renderLayout();
269
66
  }
270
67
 
68
+ // ── Accessors ──────────────────────────────────────────────────────
69
+
271
70
  private get currentQ(): QuestionData {
272
71
  return this.params.questions[this.currentIndex]!;
273
72
  }
@@ -305,23 +104,22 @@ class AskQuestionnaire extends Container {
305
104
  return this.mainListItems[this.selectedOptionIndex];
306
105
  }
307
106
 
308
- invalidate(): void {
107
+ // ── Layout ─────────────────────────────────────────────────────────
108
+
109
+ override invalidate(): void {
309
110
  super.invalidate();
310
- this._splitWidth = null;
311
111
  }
312
112
 
313
113
  renderLayout(): void {
314
114
  this.clear();
315
115
  const t = this.theme;
316
- // Border top
317
- this.addChild(new Text("", 0, 0)); // placeholder, re-rendered
318
116
 
319
- // Tab bar
117
+ this.addChild(new Text("", 0, 0));
118
+
320
119
  if (this.params.questions.length > 1) {
321
120
  this.addChild(new TabBar(this.params.questions, this.currentIndex, t));
322
121
  }
323
122
 
324
- // Question header chip
325
123
  const q = this.currentQ;
326
124
  const chip = t.fg("accent", t.bold(q.header));
327
125
  const prog =
@@ -332,12 +130,9 @@ class AskQuestionnaire extends Container {
332
130
  : "";
333
131
  this.addChild(new Text(`${chip}${prog}`, 1, 0));
334
132
  this.addChild(new Spacer(1));
335
-
336
- // Question text
337
133
  this.addChild(new Text(t.fg("text", t.bold(q.question)), 1, 0));
338
134
  this.addChild(new Spacer(1));
339
135
 
340
- // Search bar for single-select
341
136
  if (!q.multiSelect && !this.inputMode) {
342
137
  const searchVal = this.searchQuery
343
138
  ? t.fg("text", this.searchQuery)
@@ -347,19 +142,14 @@ class AskQuestionnaire extends Container {
347
142
  );
348
143
  }
349
144
 
350
- // Options area (filled on render)
351
145
  this.addChild(new Spacer(1));
352
146
 
353
- // Input mode editor
354
147
  if (this.inputMode) {
355
148
  this.addChild(this.ensureEditor());
356
149
  }
357
150
 
358
- // Footer hints
359
151
  this.addChild(new Spacer(1));
360
- this.addChild(this.buildHintText());
361
-
362
- // Border bottom
152
+ this.addChild(this._buildHintText());
363
153
  this.addChild(new Text("", 0, 0));
364
154
  }
365
155
 
@@ -377,12 +167,12 @@ class AskQuestionnaire extends Container {
377
167
  });
378
168
  editor.disableSubmit = false;
379
169
  editor.onSubmit = (text: string) => this.handleFreeformSubmit(text);
380
- (editor as any).focused = true;
170
+ editor.focused = true;
381
171
  this.editor = editor;
382
172
  return editor;
383
173
  }
384
174
 
385
- private buildHintText(): Text {
175
+ private _buildHintText(): Text {
386
176
  const t = this.theme;
387
177
  const isMulti = !!this.currentQ.multiSelect;
388
178
  const hints: string[] = [];
@@ -406,13 +196,14 @@ class AskQuestionnaire extends Container {
406
196
  return new Text(hints.join("\n"), 1, 0);
407
197
  }
408
198
 
199
+ // ── Answer management ──────────────────────────────────────────────
200
+
409
201
  private recordAnswer(
410
202
  kind: AnswerKind,
411
203
  answer: string | null,
412
204
  selected?: string[],
413
205
  preview?: string,
414
206
  ): void {
415
- // Remove any previous answer for this question
416
207
  this.answers = this.answers.filter(
417
208
  (a) => a.questionIndex !== this.currentIndex,
418
209
  );
@@ -443,13 +234,11 @@ class AskQuestionnaire extends Container {
443
234
  this.nextQuestion();
444
235
  } else if (item.kind === "other") {
445
236
  this.inputMode = true;
446
- this.freeformText = "";
447
- (this.ensureEditor() as any).focused = true;
237
+ this.ensureEditor().focused = true;
448
238
  this.invalidate();
449
239
  this.renderLayout();
450
240
  this.tui.requestRender();
451
241
  } else if (item.kind === "next") {
452
- // multi-select commit
453
242
  const selected = Array.from(this.multiChecked)
454
243
  .sort((a, b) => a - b)
455
244
  .map((i) => this.currentQ.options[i]?.label);
@@ -478,7 +267,6 @@ class AskQuestionnaire extends Container {
478
267
  this.multiChecked.clear();
479
268
  this.inputMode = false;
480
269
  this.selectedOptionIndex = 0;
481
- this.freeformText = "";
482
270
  this.editor = undefined;
483
271
  this.restoreAnswerState();
484
272
  this.invalidate();
@@ -486,7 +274,6 @@ class AskQuestionnaire extends Container {
486
274
  this.tui.requestRender();
487
275
  }
488
276
 
489
- /** Re-select the previously recorded answer when revisiting a question. */
490
277
  private restoreAnswerState(): void {
491
278
  const prev = this.answers.find(
492
279
  (a) => a.questionIndex === this.currentIndex,
@@ -513,7 +300,6 @@ class AskQuestionnaire extends Container {
513
300
  private nextQuestion(): void {
514
301
  const total = this.params.questions.length;
515
302
  const answered = new Set(this.answers.map((a) => a.questionIndex));
516
- // Advance to the next unanswered question (wrapping), finish when none left.
517
303
  for (let step = 1; step <= total; step++) {
518
304
  const idx = (this.currentIndex + step) % total;
519
305
  if (!answered.has(idx)) {
@@ -536,14 +322,14 @@ class AskQuestionnaire extends Container {
536
322
  this.invalidate();
537
323
  }
538
324
 
325
+ // ── Input handling ─────────────────────────────────────────────────
326
+
539
327
  handleInput(data: string): void {
540
- // Global: cancel
541
328
  if (this.keybindings.matches(data, "tui.select.cancel")) {
542
329
  this.cancel();
543
330
  return;
544
331
  }
545
332
 
546
- // Input mode: handle editor keys
547
333
  if (this.inputMode) {
548
334
  if (matchesKey(data, Key.escape)) {
549
335
  this.inputMode = false;
@@ -553,7 +339,6 @@ class AskQuestionnaire extends Container {
553
339
  this.tui.requestRender();
554
340
  return;
555
341
  }
556
- // Forward all other keys to the Editor (typing, enter=submit, etc.)
557
342
  this.ensureEditor().handleInput(data);
558
343
  this.tui.requestRender();
559
344
  return;
@@ -562,7 +347,6 @@ class AskQuestionnaire extends Container {
562
347
  const isMulti = !!this.currentQ.multiSelect;
563
348
  const total = this.totalItems;
564
349
 
565
- // Navigation
566
350
  if (
567
351
  this.keybindings.matches(data, "tui.select.up") ||
568
352
  matchesKey(data, Key.shift("tab")) ||
@@ -590,7 +374,6 @@ class AskQuestionnaire extends Container {
590
374
  return;
591
375
  }
592
376
 
593
- // Left/Right: move between questions (answers preserved)
594
377
  if (matchesKey(data, Key.left)) {
595
378
  this.gotoQuestion(this.currentIndex - 1);
596
379
  return;
@@ -600,7 +383,6 @@ class AskQuestionnaire extends Container {
600
383
  return;
601
384
  }
602
385
 
603
- // Backspace: pop search
604
386
  if (
605
387
  this.keybindings.matches(data, "tui.editor.deleteCharBackward") ||
606
388
  matchesKey(data, Key.backspace)
@@ -616,7 +398,6 @@ class AskQuestionnaire extends Container {
616
398
  return;
617
399
  }
618
400
 
619
- // Escape: clear search
620
401
  if (matchesKey(data, Key.escape)) {
621
402
  if (this.searchQuery) {
622
403
  this.searchQuery = "";
@@ -627,7 +408,6 @@ class AskQuestionnaire extends Container {
627
408
  return;
628
409
  }
629
410
 
630
- // Space: toggle multi-select
631
411
  if (matchesKey(data, Key.space) && isMulti) {
632
412
  if (this.selectedItem?.kind === "option" && this.selectedItem.option) {
633
413
  const idx = this.filteredOptions.indexOf(this.selectedItem.option);
@@ -638,7 +418,6 @@ class AskQuestionnaire extends Container {
638
418
  return;
639
419
  }
640
420
 
641
- // Number shortcut
642
421
  const numMatch = data.match(/^[1-9]$/);
643
422
  if (numMatch && this.filteredOptions.length > 0) {
644
423
  const idx = Number(numMatch[0]) - 1;
@@ -649,7 +428,6 @@ class AskQuestionnaire extends Container {
649
428
  this.invalidate();
650
429
  this.tui.requestRender();
651
430
  } else {
652
- // Direct select
653
431
  const opt = this.filteredOptions[idx]!;
654
432
  this.recordAnswer("option", opt.label, undefined, opt.preview);
655
433
  this.nextQuestion();
@@ -658,13 +436,11 @@ class AskQuestionnaire extends Container {
658
436
  }
659
437
  }
660
438
 
661
- // Submit / select
662
439
  if (this.keybindings.matches(data, "tui.select.confirm")) {
663
440
  this.commitAnswer();
664
441
  return;
665
442
  }
666
443
 
667
- // Search input: type to filter
668
444
  if (!isMulti) {
669
445
  const printable = decodeKittyPrintable(data);
670
446
  if (printable !== undefined) {
@@ -689,6 +465,8 @@ class AskQuestionnaire extends Container {
689
465
  }
690
466
  }
691
467
 
468
+ // ── Rendering ──────────────────────────────────────────────────────
469
+
692
470
  private renderOptions(width: number): string[] {
693
471
  const t = this.theme;
694
472
  const inner = Math.max(20, width - 6);
@@ -803,19 +581,14 @@ class AskQuestionnaire extends Container {
803
581
  this.selectedItem?.kind === "option" &&
804
582
  !!this.selectedItem?.option?.preview;
805
583
 
806
- // Decide layout: split pane if preview and wide enough
807
584
  const useSplit = hasPreview && width >= SPLIT_PANE_MIN_WIDTH;
808
585
  const leftWidth = useSplit ? Math.floor((width - 6) * 0.45) : inner;
809
586
  const previewWidth = useSplit ? Math.max(20, width - leftWidth - 10) : 0;
810
587
 
811
588
  const lines: string[] = [];
812
589
 
813
- // Build a bordered body row: pad/truncate the (ANSI-containing) content to
814
- // exactly `inner` visible columns, then wrap in side borders. Using
815
- // visibleWidth() (not String.length) keeps ANSI codes + wide glyphs honest.
816
- const row = (content: string): string => {
817
- return ` ${truncateToWidth(content, Math.max(0, width - 1), "")}`;
818
- };
590
+ const row = (content: string): string =>
591
+ ` ${truncateToWidth(content, Math.max(0, width - 1), "")}`;
819
592
 
820
593
  // Tab bar
821
594
  if (this.params.questions.length > 1) {
@@ -825,8 +598,7 @@ class AskQuestionnaire extends Container {
825
598
  const tag = `${i + 1}.${this.params.questions[i]?.header}`;
826
599
  tabParts.push(active ? t.fg("accent", t.bold(tag)) : t.fg("dim", tag));
827
600
  }
828
- const tabLine = tabParts.join(t.fg("dim", " "));
829
- lines.push(row(tabLine));
601
+ lines.push(row(tabParts.join(t.fg("dim", " "))));
830
602
  }
831
603
 
832
604
  // Header chip
@@ -838,22 +610,22 @@ class AskQuestionnaire extends Container {
838
610
  lines.push(row(`${chip}${prog}`));
839
611
 
840
612
  // Question text
841
- const questionWrapped = wrapTextWithAnsi(
613
+ for (const w of wrapTextWithAnsi(
842
614
  this.currentQ.question,
843
615
  Math.max(10, inner),
844
- );
845
- for (const w of questionWrapped) {
616
+ )) {
846
617
  lines.push(row(t.fg("text", t.bold(w))));
847
618
  }
848
619
 
849
- // Input mode: render the freeform editor instead of the options list.
620
+ // Input mode
850
621
  if (this.inputMode) {
851
622
  lines.push("");
852
623
  lines.push(row(t.fg("accent", t.bold("Type your response:"))));
853
624
  lines.push("");
854
625
  const editorLines = this.ensureEditor().render(Math.max(0, width - 1));
855
- for (const el of editorLines)
626
+ for (const el of editorLines) {
856
627
  lines.push(` ${truncateToWidth(el, Math.max(0, width - 1), "")}`);
628
+ }
857
629
  lines.push("");
858
630
  lines.push(row(dim(t)("enter submit • esc back • ctrl+c cancel")));
859
631
  lines.push("");
@@ -868,14 +640,14 @@ class AskQuestionnaire extends Container {
868
640
  lines.push(row(`${t.fg("accent", "Filter:")} ${searchVal}`));
869
641
  }
870
642
 
871
- // Chat sentinel row (above options for single-select, always visible)
643
+ // Chat sentinel
872
644
  const chatLabel =
873
645
  this.selectedOptionIndex === -999
874
646
  ? t.fg("accent", t.bold(SENTINEL_CHAT))
875
647
  : t.fg("dim", SENTINEL_CHAT);
876
648
  lines.push(row(` ${t.fg("dim", "💬")} ${chatLabel}`));
877
649
 
878
- // Options (with optional preview pane)
650
+ // Options (with optional split-pane preview)
879
651
  const optionLines = this.renderOptions(useSplit ? leftWidth : width - 4);
880
652
  const previewLines = useSplit ? this.renderPreview(previewWidth) : [];
881
653
  const maxOptLines = Math.max(optionLines.length, previewLines.length);
@@ -894,213 +666,28 @@ class AskQuestionnaire extends Container {
894
666
  previewWidth - 2,
895
667
  "",
896
668
  );
897
- const paintedLeft = left || " ".repeat(leftWidth - 1);
898
- const paintedRight = right || " ".repeat(previewWidth - 2);
899
- const body = `${paintedLeft}${sep}${paintedRight}`;
669
+ const body = `${left || " ".repeat(leftWidth - 1)}${sep}${right || " ".repeat(previewWidth - 2)}`;
900
670
  lines.push(` ${truncateToWidth(body, Math.max(0, width - 1), "")}`);
901
671
  }
902
672
  } else {
903
- for (const line of optionLines) {
904
- lines.push(row(line));
905
- }
673
+ for (const line of optionLines) lines.push(row(line));
906
674
  }
907
675
 
908
676
  // Footer hints
909
- const hintTexts: string[] = [];
910
677
  const navHint =
911
678
  this.params.questions.length > 1 ? "↑↓ nav • ←→ question" : "↑↓ nav";
912
- if (isMulti) {
913
- hintTexts.push(`${navHint} • space toggle • enter commit • esc clear`);
914
- } else {
915
- hintTexts.push(`${navHint} • type filter • enter select • esc clear`);
916
- }
917
- hintTexts.push("ctrl+c cancel");
918
- const hint = dim(t)(hintTexts.join(""));
919
- lines.push(row(hint));
679
+ const hintParts = isMulti
680
+ ? [
681
+ `${navHint} space toggle • enter commit • esc clear`,
682
+ "ctrl+c cancel",
683
+ ]
684
+ : [
685
+ `${navHint} type filterenter select • esc clear`,
686
+ "ctrl+c cancel",
687
+ ];
688
+ lines.push(row(dim(t)(hintParts.join(" • "))));
920
689
  lines.push("");
921
690
 
922
- // Final safety net: never emit a line wider than the terminal.
923
691
  return lines.map((l) => truncateToWidth(l, width, ""));
924
692
  }
925
693
  }
926
-
927
- // ── RPC fallback ───────────────────────────────────────────────────────
928
-
929
- async function rpcFallback(
930
- ui: { select: Function; input: Function },
931
- params: Params,
932
- ): Promise<QuestionnaireResult> {
933
- const answers: QuestionAnswer[] = [];
934
- let cancelled = false;
935
-
936
- for (let i = 0; i < params.questions.length; i++) {
937
- const q = params.questions[i]!;
938
- const header = q.header;
939
-
940
- if (q.multiSelect) {
941
- const lines = q.options.map(
942
- (o, idx) => `${idx + 1}. ${o.label} — ${o.description}`,
943
- );
944
- const raw = await ui.input(
945
- `${header}: ${q.question}\n\n${lines.join("\n")}\n\nEnter numbers separated by commas:`,
946
- "e.g. 1,3",
947
- );
948
- if (raw == null) {
949
- cancelled = true;
950
- break;
951
- }
952
- const indices = String(raw)
953
- .split(",")
954
- .map((s) => Number(s.trim()))
955
- .filter((n) => n >= 1 && n <= q.options.length);
956
- const selected = indices.map((n) => q.options[n - 1]?.label);
957
- if (selected.length > 0) {
958
- answers.push({
959
- questionIndex: i,
960
- question: q.question,
961
- kind: "multi",
962
- answer: null,
963
- selected,
964
- });
965
- } else {
966
- cancelled = true;
967
- break;
968
- }
969
- } else {
970
- const items = q.options.map((o) => `${o.label} — ${o.description}`);
971
- items.push(SENTINEL_FREEFORM);
972
- const chosen = await ui.select(`${header}: ${q.question}`, items);
973
- if (chosen == null) {
974
- cancelled = true;
975
- break;
976
- }
977
- if (chosen === SENTINEL_FREEFORM) {
978
- const text = await ui.input(q.question, "Type your answer...");
979
- if (text == null) {
980
- cancelled = true;
981
- break;
982
- }
983
- answers.push({
984
- questionIndex: i,
985
- question: q.question,
986
- kind: "custom",
987
- answer: String(text),
988
- });
989
- } else {
990
- const opt = q.options.find(
991
- (o) =>
992
- chosen === o.label || `${o.label} — ${o.description}` === chosen,
993
- )!;
994
- answers.push({
995
- questionIndex: i,
996
- question: q.question,
997
- kind: "option",
998
- answer: opt?.label ?? String(chosen),
999
- });
1000
- }
1001
- }
1002
- }
1003
-
1004
- return { answers, cancelled };
1005
- }
1006
-
1007
- // ── Tool registration ──────────────────────────────────────────────────
1008
-
1009
- export default function registerAsk(pi: ExtensionAPI): void {
1010
- pi.registerTool({
1011
- name: "ask_user",
1012
- label: "Ask",
1013
- description: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous.`,
1014
- promptSnippet: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`,
1015
- promptGuidelines: [
1016
- `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.`,
1017
- `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.`,
1018
- `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.`,
1019
- "Do not stack multiple ask calls back-to-back — group all clarifying questions into one invocation.",
1020
- ],
1021
- executionMode: "sequential",
1022
- parameters: ParamsSchema,
1023
-
1024
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1025
- if (signal?.aborted) {
1026
- return {
1027
- content: [{ type: "text", text: "Cancelled" }],
1028
- details: { answers: [], cancelled: true },
1029
- };
1030
- }
1031
-
1032
- const typed = params as unknown as Params;
1033
-
1034
- // Validate
1035
- if (!Array.isArray(typed.questions) || typed.questions.length === 0) {
1036
- return {
1037
- content: [
1038
- { type: "text", text: "At least one question is required." },
1039
- ],
1040
- isError: true,
1041
- details: { answers: [], cancelled: true },
1042
- };
1043
- }
1044
-
1045
- if (!ctx.hasUI) {
1046
- const result = await rpcFallback(ctx.ui, typed);
1047
- const text = result.cancelled
1048
- ? "User cancelled the questionnaire"
1049
- : buildResponseText(result.answers, typed.questions);
1050
- return { content: [{ type: "text", text }], details: result };
1051
- }
1052
-
1053
- // Render inline in the conversation thread (no floating overlay).
1054
- const result = await ctx.ui.custom<QuestionnaireResult | null>(
1055
- (tui, theme, keybindings, done) => {
1056
- if (signal) {
1057
- signal.addEventListener(
1058
- "abort",
1059
- () => done({ answers: [], cancelled: true }),
1060
- { once: true },
1061
- );
1062
- }
1063
- return new AskQuestionnaire(typed, tui, theme, keybindings, done);
1064
- },
1065
- );
1066
-
1067
- if (!result || result.cancelled) {
1068
- return {
1069
- content: [{ type: "text", text: "User cancelled the questionnaire" }],
1070
- details: result ?? { answers: [], cancelled: true },
1071
- };
1072
- }
1073
-
1074
- const text = buildResponseText(result.answers, typed.questions);
1075
- return { content: [{ type: "text", text }], details: result };
1076
- },
1077
-
1078
- renderCall(args, theme) {
1079
- const questions = Array.isArray(args.questions) ? args.questions : [];
1080
- const count = questions.length;
1081
- const firstQ = (questions[0]?.question ?? "") as string;
1082
- let text = theme.fg("toolTitle", theme.bold(`ask (${count}) `));
1083
- text += theme.fg("muted", firstQ);
1084
- if (count > 1) text += theme.fg("dim", ` +${count - 1} more`);
1085
- return new Text(text, 0, 0);
1086
- },
1087
-
1088
- renderResult(result, options, theme) {
1089
- const details = result.details as
1090
- | { answers?: QuestionAnswer[]; cancelled?: boolean }
1091
- | undefined;
1092
- if (options.isPartial) {
1093
- return new Text(theme.fg("muted", "Waiting for user input..."), 0, 0);
1094
- }
1095
- if (!details || details.cancelled || !details.answers?.length) {
1096
- return new Text(theme.fg("warning", "Cancelled"), 0, 0);
1097
- }
1098
- const texts = details.answers.map((a) => {
1099
- const v =
1100
- a.kind === "multi" ? (a.selected ?? []).join(", ") : (a.answer ?? "");
1101
- return `${a.questionIndex + 1}: ${v}`;
1102
- });
1103
- return new Text(theme.fg("success", `✓ ${texts.join(" • ")}`), 0, 0);
1104
- },
1105
- });
1106
- }