@xynogen/pix-core 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,27 +9,24 @@
9
9
 
10
10
  import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
11
11
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
12
- import { Type, type Static } from "typebox";
13
12
  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,
13
+ type Component,
14
+ Container,
15
+ decodeKittyPrintable,
16
+ Editor,
17
+ fuzzyFilter,
18
+ Key,
19
+ type KeybindingsManager,
20
+ Markdown,
21
+ type MarkdownTheme,
22
+ matchesKey,
23
+ Spacer,
24
+ Text,
25
+ type TUI,
26
+ truncateToWidth,
27
+ wrapTextWithAnsi,
31
28
  } from "@earendil-works/pi-tui";
32
- import { renderSingleSelectRows } from "./single-select-layout.ts";
29
+ import { type Static, Type } from "typebox";
33
30
 
34
31
  // ── Constants ──────────────────────────────────────────────────────────
35
32
 
@@ -49,54 +46,54 @@ const SEPARATOR = " │ ";
49
46
  // ── Schema ─────────────────────────────────────────────────────────────
50
47
 
51
48
  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
- ),
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
+ ),
65
62
  });
66
63
 
67
64
  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
- ),
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
+ ),
88
85
  });
89
86
 
90
87
  const QuestionsSchema = Type.Array(QuestionSchema, {
91
- minItems: 1,
92
- maxItems: MAX_QUESTIONS,
93
- description: "1-4 questions",
88
+ minItems: 1,
89
+ maxItems: MAX_QUESTIONS,
90
+ description: "1-4 questions",
94
91
  });
95
92
 
96
93
  const ParamsSchema = Type.Object({ questions: QuestionsSchema });
97
94
 
98
- type OptionData = Static<typeof OptionSchema>;
99
- type QuestionData = Static<typeof QuestionSchema>;
95
+ export type OptionData = Static<typeof OptionSchema>;
96
+ export type QuestionData = Static<typeof QuestionSchema>;
100
97
  type Params = Static<typeof ParamsSchema>;
101
98
 
102
99
  // ── Answer types ───────────────────────────────────────────────────────
@@ -104,978 +101,1006 @@ type Params = Static<typeof ParamsSchema>;
104
101
  type AnswerKind = "option" | "custom" | "chat" | "multi";
105
102
 
106
103
  interface QuestionAnswer {
107
- questionIndex: number;
108
- question: string;
109
- kind: AnswerKind;
110
- answer: string | null;
111
- selected?: string[];
112
- preview?: string;
104
+ questionIndex: number;
105
+ question: string;
106
+ kind: AnswerKind;
107
+ answer: string | null;
108
+ selected?: string[];
109
+ preview?: string;
113
110
  }
114
111
 
115
112
  interface QuestionnaireResult {
116
- answers: QuestionAnswer[];
117
- cancelled: boolean;
113
+ answers: QuestionAnswer[];
114
+ cancelled: boolean;
118
115
  }
119
116
 
120
117
  // ── Helpers ────────────────────────────────────────────────────────────
121
118
 
122
119
  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
- }
120
+ try {
121
+ const md = getMarkdownTheme();
122
+ if (!md) return undefined;
123
+ md.bold("");
124
+ return md;
125
+ } catch {
126
+ return undefined;
127
+ }
131
128
  }
132
129
 
133
130
  export function hasAnyPreview(q: QuestionData): boolean {
134
- return q.options.some(
135
- (o) => typeof o.preview === "string" && o.preview.length > 0,
136
- );
131
+ return q.options.some(
132
+ (o) => typeof o.preview === "string" && o.preview.length > 0,
133
+ );
137
134
  }
138
135
 
139
136
  /** Which sentinel rows are auto-appended for a question. */
140
137
  export function sentinelsFor(
141
- q: QuestionData,
138
+ q: QuestionData,
142
139
  ): 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;
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;
151
148
  }
152
149
 
153
150
  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)";
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)";
158
155
  }
159
156
 
160
157
  export function buildResponseText(
161
- answers: QuestionAnswer[],
162
- questions: QuestionData[],
158
+ answers: QuestionAnswer[],
159
+ questions: QuestionData[],
163
160
  ): 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.";
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.";
174
171
  }
175
172
 
176
173
  // ── Scrollbar helper ───────────────────────────────────────────────────
177
174
 
178
175
  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}`;
176
+ if (total <= 1) return "";
177
+ const pos = Math.round((index / (total - 1)) * 6);
178
+ const bar = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦"][pos] ?? "·";
179
+ return ` ${bar} ${index + 1}/${total}`;
183
180
  }
184
181
 
185
182
  // ── TUI Components ─────────────────────────────────────────────────────
186
183
 
187
- function borderColor(theme: Theme): (s: string) => string {
188
- return (s: string) => theme.fg("accent", s);
184
+ function _borderColor(theme: Theme): (s: string) => string {
185
+ return (s: string) => theme.fg("accent", s);
189
186
  }
190
187
 
191
188
  function dim(theme: Theme): (s: string) => string {
192
- return (s: string) => theme.fg("dim", s);
189
+ return (s: string) => theme.fg("dim", s);
193
190
  }
194
191
 
195
192
  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
- }
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
+ }
237
234
  }
238
235
 
239
236
  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
- }
237
+ private params: Params;
238
+ private tui: TUI;
239
+ private theme: Theme;
240
+ private keybindings: KeybindingsManager;
241
+ private onDone: (result: QuestionnaireResult | null) => void;
242
+
243
+ private currentIndex = 0;
244
+ private answers: QuestionAnswer[] = [];
245
+ private searchQuery = "";
246
+ private selectedOptionIndex = 0;
247
+ private multiChecked = new Set<number>();
248
+ private inputMode = false; // typing freeform text
249
+ private freeformText = "";
250
+ private editor?: Editor;
251
+ private mdTheme = safeMarkdownTheme();
252
+ // Resolve panel width once
253
+ private _splitWidth: number | null = null;
254
+
255
+ constructor(
256
+ params: Params,
257
+ tui: TUI,
258
+ theme: Theme,
259
+ keybindings: KeybindingsManager,
260
+ onDone: (result: QuestionnaireResult | null) => void,
261
+ ) {
262
+ super();
263
+ this.params = params;
264
+ this.tui = tui;
265
+ this.theme = theme;
266
+ this.keybindings = keybindings;
267
+ this.onDone = onDone;
268
+ this.renderLayout();
269
+ }
270
+
271
+ private get currentQ(): QuestionData {
272
+ return this.params.questions[this.currentIndex]!;
273
+ }
274
+
275
+ private get filteredOptions(): OptionData[] {
276
+ if (!this.searchQuery) return this.currentQ.options;
277
+ return fuzzyFilter(
278
+ this.currentQ.options,
279
+ this.searchQuery,
280
+ (o) => `${o.label} ${o.description}`,
281
+ );
282
+ }
283
+
284
+ private get mainListItems(): Array<{
285
+ kind: string;
286
+ label?: string;
287
+ option?: OptionData;
288
+ }> {
289
+ const items: Array<{ kind: string; label?: string; option?: OptionData }> =
290
+ [];
291
+ for (const o of this.filteredOptions) {
292
+ items.push({ kind: "option", option: o });
293
+ }
294
+ for (const s of sentinelsFor(this.currentQ)) {
295
+ items.push({ kind: s.kind, label: s.label });
296
+ }
297
+ return items;
298
+ }
299
+
300
+ private get totalItems(): number {
301
+ return this.mainListItems.length;
302
+ }
303
+
304
+ private get selectedItem(): (typeof this.mainListItems)[0] | undefined {
305
+ return this.mainListItems[this.selectedOptionIndex];
306
+ }
307
+
308
+ invalidate(): void {
309
+ super.invalidate();
310
+ this._splitWidth = null;
311
+ }
312
+
313
+ renderLayout(): void {
314
+ this.clear();
315
+ const t = this.theme;
316
+ // Border top
317
+ this.addChild(new Text("", 0, 0)); // placeholder, re-rendered
318
+
319
+ // Tab bar
320
+ if (this.params.questions.length > 1) {
321
+ this.addChild(new TabBar(this.params.questions, this.currentIndex, t));
322
+ }
323
+
324
+ // Question header chip
325
+ const q = this.currentQ;
326
+ const chip = t.fg("accent", t.bold(q.header));
327
+ const prog =
328
+ this.params.questions.length > 1
329
+ ? dim(t)(
330
+ scrollIndicator(this.currentIndex, this.params.questions.length),
331
+ )
332
+ : "";
333
+ this.addChild(new Text(`${chip}${prog}`, 1, 0));
334
+ this.addChild(new Spacer(1));
335
+
336
+ // Question text
337
+ this.addChild(new Text(t.fg("text", t.bold(q.question)), 1, 0));
338
+ this.addChild(new Spacer(1));
339
+
340
+ // Search bar for single-select
341
+ if (!q.multiSelect && !this.inputMode) {
342
+ const searchVal = this.searchQuery
343
+ ? t.fg("text", this.searchQuery)
344
+ : t.fg("dim", "type to filter");
345
+ this.addChild(
346
+ new Text(`${t.fg("accent", "Filter:")} ${searchVal}`, 1, 0),
347
+ );
348
+ }
349
+
350
+ // Options area (filled on render)
351
+ this.addChild(new Spacer(1));
352
+
353
+ // Input mode editor
354
+ if (this.inputMode) {
355
+ this.addChild(this.ensureEditor());
356
+ }
357
+
358
+ // Footer hints
359
+ this.addChild(new Spacer(1));
360
+ this.addChild(this.buildHintText());
361
+
362
+ // Border bottom
363
+ this.addChild(new Text("", 0, 0));
364
+ }
365
+
366
+ private ensureEditor(): Editor {
367
+ if (this.editor) return this.editor;
368
+ const editor = new Editor(this.tui, {
369
+ borderColor: (s: string) => this.theme.fg("accent", s),
370
+ selectList: {
371
+ selectedPrefix: (s: string) => this.theme.fg("accent", s),
372
+ selectedText: (s: string) => this.theme.fg("accent", s),
373
+ description: (s: string) => this.theme.fg("muted", s),
374
+ scrollInfo: (s: string) => this.theme.fg("dim", s),
375
+ noMatch: (s: string) => this.theme.fg("warning", s),
376
+ },
377
+ });
378
+ editor.disableSubmit = false;
379
+ editor.onSubmit = (text: string) => this.handleFreeformSubmit(text);
380
+ (editor as any).focused = true;
381
+ this.editor = editor;
382
+ return editor;
383
+ }
384
+
385
+ private buildHintText(): Text {
386
+ const t = this.theme;
387
+ const isMulti = !!this.currentQ.multiSelect;
388
+ const hints: string[] = [];
389
+ if (this.inputMode) {
390
+ hints.push(dim(t)("enter=submit • esc=back • ^c=cancel"));
391
+ } else {
392
+ const multiQ = this.params.questions.length > 1;
393
+ const nav = multiQ ? "↑↓=nav • ←→=question" : "↑↓=nav";
394
+ if (isMulti) {
395
+ hints.push(
396
+ dim(t)(
397
+ `${nav} • space=toggle • enter=commit & next • esc=clear • ^c=cancel`,
398
+ ),
399
+ );
400
+ } else {
401
+ hints.push(
402
+ dim(t)(`${nav} • type=filter • enter=select • esc=clear • ^c=cancel`),
403
+ );
404
+ }
405
+ }
406
+ return new Text(hints.join("\n"), 1, 0);
407
+ }
408
+
409
+ private recordAnswer(
410
+ kind: AnswerKind,
411
+ answer: string | null,
412
+ selected?: string[],
413
+ preview?: string,
414
+ ): void {
415
+ // Remove any previous answer for this question
416
+ this.answers = this.answers.filter(
417
+ (a) => a.questionIndex !== this.currentIndex,
418
+ );
419
+ this.answers.push({
420
+ questionIndex: this.currentIndex,
421
+ question: this.currentQ.question,
422
+ kind,
423
+ answer,
424
+ selected,
425
+ preview,
426
+ });
427
+ }
428
+
429
+ private commitAnswer(): void {
430
+ const item = this.selectedItem;
431
+ if (!item) {
432
+ this.cancel();
433
+ return;
434
+ }
435
+
436
+ if (item.kind === "option" && item.option) {
437
+ this.recordAnswer(
438
+ "option",
439
+ item.option.label,
440
+ undefined,
441
+ item.option.preview,
442
+ );
443
+ this.nextQuestion();
444
+ } else if (item.kind === "other") {
445
+ this.inputMode = true;
446
+ this.freeformText = "";
447
+ (this.ensureEditor() as any).focused = true;
448
+ this.invalidate();
449
+ this.renderLayout();
450
+ this.tui.requestRender();
451
+ } else if (item.kind === "next") {
452
+ // multi-select commit
453
+ const selected = Array.from(this.multiChecked)
454
+ .sort((a, b) => a - b)
455
+ .map((i) => this.currentQ.options[i]?.label);
456
+ if (selected.length === 0) {
457
+ this.cancel();
458
+ return;
459
+ }
460
+ this.recordAnswer("multi", null, selected);
461
+ this.nextQuestion();
462
+ }
463
+ }
464
+
465
+ private handleFreeformSubmit(text: string): void {
466
+ if (!text.trim()) {
467
+ this.cancel();
468
+ return;
469
+ }
470
+ this.recordAnswer("custom", text.trim());
471
+ this.nextQuestion();
472
+ }
473
+
474
+ private gotoQuestion(index: number): void {
475
+ if (index < 0 || index >= this.params.questions.length) return;
476
+ this.currentIndex = index;
477
+ this.searchQuery = "";
478
+ this.multiChecked.clear();
479
+ this.inputMode = false;
480
+ this.selectedOptionIndex = 0;
481
+ this.freeformText = "";
482
+ this.editor = undefined;
483
+ this.restoreAnswerState();
484
+ this.invalidate();
485
+ this.renderLayout();
486
+ this.tui.requestRender();
487
+ }
488
+
489
+ /** Re-select the previously recorded answer when revisiting a question. */
490
+ private restoreAnswerState(): void {
491
+ const prev = this.answers.find(
492
+ (a) => a.questionIndex === this.currentIndex,
493
+ );
494
+ if (!prev) return;
495
+ const q = this.currentQ;
496
+ if (prev.kind === "multi") {
497
+ for (let i = 0; i < q.options.length; i++) {
498
+ if (prev.selected?.includes(q.options[i]!.label)) {
499
+ this.multiChecked.add(i);
500
+ }
501
+ }
502
+ } else if (prev.kind === "option" && prev.answer) {
503
+ const idx = this.mainListItems.findIndex(
504
+ (it) => it.kind === "option" && it.option?.label === prev.answer,
505
+ );
506
+ if (idx >= 0) this.selectedOptionIndex = idx;
507
+ } else if (prev.kind === "custom") {
508
+ const idx = this.mainListItems.findIndex((it) => it.kind === "other");
509
+ if (idx >= 0) this.selectedOptionIndex = idx;
510
+ }
511
+ }
512
+
513
+ private nextQuestion(): void {
514
+ const total = this.params.questions.length;
515
+ const answered = new Set(this.answers.map((a) => a.questionIndex));
516
+ // Advance to the next unanswered question (wrapping), finish when none left.
517
+ for (let step = 1; step <= total; step++) {
518
+ const idx = (this.currentIndex + step) % total;
519
+ if (!answered.has(idx)) {
520
+ this.gotoQuestion(idx);
521
+ return;
522
+ }
523
+ }
524
+ this.answers.sort((a, b) => a.questionIndex - b.questionIndex);
525
+ this.onDone({ answers: this.answers, cancelled: false });
526
+ }
527
+
528
+ private cancel(): void {
529
+ this.onDone({ answers: this.answers, cancelled: true });
530
+ }
531
+
532
+ private toggleMulti(index: number): void {
533
+ if (index < 0 || index >= this.currentQ.options.length) return;
534
+ if (this.multiChecked.has(index)) this.multiChecked.delete(index);
535
+ else this.multiChecked.add(index);
536
+ this.invalidate();
537
+ }
538
+
539
+ handleInput(data: string): void {
540
+ // Global: cancel
541
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
542
+ this.cancel();
543
+ return;
544
+ }
545
+
546
+ // Input mode: handle editor keys
547
+ if (this.inputMode) {
548
+ if (matchesKey(data, Key.escape)) {
549
+ this.inputMode = false;
550
+ this.editor = undefined;
551
+ this.invalidate();
552
+ this.renderLayout();
553
+ this.tui.requestRender();
554
+ return;
555
+ }
556
+ // Forward all other keys to the Editor (typing, enter=submit, etc.)
557
+ this.ensureEditor().handleInput(data);
558
+ this.tui.requestRender();
559
+ return;
560
+ }
561
+
562
+ const isMulti = !!this.currentQ.multiSelect;
563
+ const total = this.totalItems;
564
+
565
+ // Navigation
566
+ if (
567
+ this.keybindings.matches(data, "tui.select.up") ||
568
+ matchesKey(data, Key.shift("tab")) ||
569
+ matchesKey(data, Key.ctrl("k"))
570
+ ) {
571
+ if (total > 0) {
572
+ this.selectedOptionIndex =
573
+ (this.selectedOptionIndex - 1 + total) % total;
574
+ this.invalidate();
575
+ this.tui.requestRender();
576
+ }
577
+ return;
578
+ }
579
+
580
+ if (
581
+ this.keybindings.matches(data, "tui.select.down") ||
582
+ matchesKey(data, Key.tab) ||
583
+ matchesKey(data, Key.ctrl("j"))
584
+ ) {
585
+ if (total > 0) {
586
+ this.selectedOptionIndex = (this.selectedOptionIndex + 1) % total;
587
+ this.invalidate();
588
+ this.tui.requestRender();
589
+ }
590
+ return;
591
+ }
592
+
593
+ // Left/Right: move between questions (answers preserved)
594
+ if (matchesKey(data, Key.left)) {
595
+ this.gotoQuestion(this.currentIndex - 1);
596
+ return;
597
+ }
598
+ if (matchesKey(data, Key.right)) {
599
+ this.gotoQuestion(this.currentIndex + 1);
600
+ return;
601
+ }
602
+
603
+ // Backspace: pop search
604
+ if (
605
+ this.keybindings.matches(data, "tui.editor.deleteCharBackward") ||
606
+ matchesKey(data, Key.backspace)
607
+ ) {
608
+ if (this.searchQuery) {
609
+ const chars = [...this.searchQuery];
610
+ chars.pop();
611
+ this.searchQuery = chars.join("");
612
+ this.selectedOptionIndex = 0;
613
+ this.invalidate();
614
+ this.tui.requestRender();
615
+ }
616
+ return;
617
+ }
618
+
619
+ // Escape: clear search
620
+ if (matchesKey(data, Key.escape)) {
621
+ if (this.searchQuery) {
622
+ this.searchQuery = "";
623
+ this.selectedOptionIndex = 0;
624
+ this.invalidate();
625
+ this.tui.requestRender();
626
+ }
627
+ return;
628
+ }
629
+
630
+ // Space: toggle multi-select
631
+ if (matchesKey(data, Key.space) && isMulti) {
632
+ if (this.selectedItem?.kind === "option" && this.selectedItem.option) {
633
+ const idx = this.filteredOptions.indexOf(this.selectedItem.option);
634
+ if (idx >= 0) this.toggleMulti(idx);
635
+ this.invalidate();
636
+ this.tui.requestRender();
637
+ }
638
+ return;
639
+ }
640
+
641
+ // Number shortcut
642
+ const numMatch = data.match(/^[1-9]$/);
643
+ if (numMatch && this.filteredOptions.length > 0) {
644
+ const idx = Number(numMatch[0]) - 1;
645
+ if (idx >= 0 && idx < this.filteredOptions.length) {
646
+ if (isMulti) {
647
+ this.toggleMulti(idx);
648
+ this.selectedOptionIndex = Math.min(idx, this.totalItems - 1);
649
+ this.invalidate();
650
+ this.tui.requestRender();
651
+ } else {
652
+ // Direct select
653
+ const opt = this.filteredOptions[idx]!;
654
+ this.recordAnswer("option", opt.label, undefined, opt.preview);
655
+ this.nextQuestion();
656
+ }
657
+ return;
658
+ }
659
+ }
660
+
661
+ // Submit / select
662
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
663
+ this.commitAnswer();
664
+ return;
665
+ }
666
+
667
+ // Search input: type to filter
668
+ if (!isMulti) {
669
+ const printable = decodeKittyPrintable(data);
670
+ if (printable !== undefined) {
671
+ this.searchQuery += printable;
672
+ this.selectedOptionIndex = 0;
673
+ this.invalidate();
674
+ this.tui.requestRender();
675
+ return;
676
+ }
677
+ const chars = [...data];
678
+ if (
679
+ chars.length === 1 &&
680
+ chars[0] &&
681
+ chars[0].charCodeAt(0) >= 32 &&
682
+ chars[0].charCodeAt(0) < 127
683
+ ) {
684
+ this.searchQuery += chars[0];
685
+ this.selectedOptionIndex = 0;
686
+ this.invalidate();
687
+ this.tui.requestRender();
688
+ }
689
+ }
690
+ }
691
+
692
+ private renderOptions(width: number): string[] {
693
+ const t = this.theme;
694
+ const inner = Math.max(20, width - 6);
695
+ const isMulti = !!this.currentQ.multiSelect;
696
+ const items = this.mainListItems;
697
+ const total = items.length;
698
+ const chk = (i: number) =>
699
+ isMulti
700
+ ? this.multiChecked.has(i)
701
+ ? t.fg("success", "")
702
+ : t.fg("dim", "○")
703
+ : "";
704
+
705
+ if (total === 0) return [t.fg("warning", "No options")];
706
+
707
+ const maxVisible = Math.min(total, 12);
708
+ const start = Math.max(
709
+ 0,
710
+ Math.min(
711
+ this.selectedOptionIndex - Math.floor(maxVisible / 2),
712
+ total - maxVisible,
713
+ ),
714
+ );
715
+ const end = Math.min(start + maxVisible, total);
716
+
717
+ const lines: string[] = [];
718
+ const pad = " ";
719
+
720
+ for (let i = start; i < end; i++) {
721
+ const item = items[i]!;
722
+ const sel = i === this.selectedOptionIndex;
723
+ const ptr = sel ? t.fg("accent", "") : " ";
724
+
725
+ if (item.kind === "option" && item.option) {
726
+ const optIdx = this.filteredOptions.indexOf(item.option);
727
+ const checkbox = isMulti ? ` ${chk(optIdx)}` : "";
728
+ const num = t.fg("dim", `${optIdx + 1}.`);
729
+ const label = sel
730
+ ? t.fg("accent", t.bold(item.option.label))
731
+ : t.fg("text", t.bold(item.option.label));
732
+ lines.push(
733
+ truncateToWidth(`${ptr} ${num}${checkbox} ${label}`, inner, ""),
734
+ );
735
+ if (item.option.description) {
736
+ const wrapped = wrapTextWithAnsi(
737
+ item.option.description,
738
+ Math.max(10, inner - 6),
739
+ );
740
+ for (const w of wrapped) {
741
+ lines.push(truncateToWidth(`${pad}${t.fg("muted", w)}`, inner, ""));
742
+ }
743
+ }
744
+ } else if (item.kind === "other") {
745
+ const label = sel
746
+ ? t.fg("accent", t.bold(SENTINEL_FREEFORM))
747
+ : t.fg("text", t.bold(SENTINEL_FREEFORM));
748
+ lines.push(
749
+ truncateToWidth(`${ptr} ${t.fg("dim", "✎")} ${label}`, inner, ""),
750
+ );
751
+ } else if (item.kind === "next") {
752
+ const label = sel
753
+ ? t.fg("accent", t.bold(SENTINEL_NEXT))
754
+ : t.fg("text", t.bold(SENTINEL_NEXT));
755
+ lines.push(
756
+ truncateToWidth(`${ptr} ${t.fg("dim", "→")} ${label}`, inner, ""),
757
+ );
758
+ }
759
+ }
760
+
761
+ if (start > 0 || end < total) {
762
+ const count =
763
+ this.filteredOptions.length > 0
764
+ ? `${this.selectedOptionIndex + 1}/${total}`
765
+ : `${total}`;
766
+ lines.push(t.fg("dim", truncateToWidth(` ${count}`, inner, "")));
767
+ }
768
+
769
+ return lines;
770
+ }
771
+
772
+ private renderPreview(width: number): string[] {
773
+ const item = this.selectedItem;
774
+ if (item?.kind !== "option" || !item.option?.preview) {
775
+ return [this.theme.fg("dim", "No preview")];
776
+ }
777
+
778
+ const mdText = item.option.preview;
779
+ const mdWidth = Math.max(10, width);
780
+
781
+ if (this.mdTheme) {
782
+ const md = new Markdown(
783
+ `## ${item.option.label}\n\n${mdText}`,
784
+ 0,
785
+ 0,
786
+ this.mdTheme,
787
+ );
788
+ return md.render(mdWidth);
789
+ }
790
+
791
+ const lines = wrapTextWithAnsi(mdText, mdWidth);
792
+ return lines.map((l) =>
793
+ truncateToWidth(this.theme.fg("muted", l), mdWidth, ""),
794
+ );
795
+ }
796
+
797
+ override render(width: number): string[] {
798
+ const inner = Math.max(20, width - 4);
799
+ const t = this.theme;
800
+ const isMulti = !!this.currentQ.multiSelect;
801
+ const hasPreview =
802
+ !isMulti &&
803
+ this.selectedItem?.kind === "option" &&
804
+ !!this.selectedItem?.option?.preview;
805
+
806
+ // Decide layout: split pane if preview and wide enough
807
+ const useSplit = hasPreview && width >= SPLIT_PANE_MIN_WIDTH;
808
+ const leftWidth = useSplit ? Math.floor((width - 6) * 0.45) : inner;
809
+ const previewWidth = useSplit ? Math.max(20, width - leftWidth - 10) : 0;
810
+
811
+ const lines: string[] = [];
812
+
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
+ };
819
+
820
+ // Tab bar
821
+ if (this.params.questions.length > 1) {
822
+ const tabParts: string[] = [];
823
+ for (let i = 0; i < this.params.questions.length; i++) {
824
+ const active = i === this.currentIndex;
825
+ const tag = `${i + 1}.${this.params.questions[i]?.header}`;
826
+ tabParts.push(active ? t.fg("accent", t.bold(tag)) : t.fg("dim", tag));
827
+ }
828
+ const tabLine = tabParts.join(t.fg("dim", " "));
829
+ lines.push(row(tabLine));
830
+ }
831
+
832
+ // Header chip
833
+ const chip = t.fg("accent", t.bold(this.currentQ.header));
834
+ const prog =
835
+ this.params.questions.length > 1
836
+ ? dim(t)(` ${this.currentIndex + 1}/${this.params.questions.length}`)
837
+ : "";
838
+ lines.push(row(`${chip}${prog}`));
839
+
840
+ // Question text
841
+ const questionWrapped = wrapTextWithAnsi(
842
+ this.currentQ.question,
843
+ Math.max(10, inner),
844
+ );
845
+ for (const w of questionWrapped) {
846
+ lines.push(row(t.fg("text", t.bold(w))));
847
+ }
848
+
849
+ // Input mode: render the freeform editor instead of the options list.
850
+ if (this.inputMode) {
851
+ lines.push("");
852
+ lines.push(row(t.fg("accent", t.bold("Type your response:"))));
853
+ lines.push("");
854
+ const editorLines = this.ensureEditor().render(Math.max(0, width - 1));
855
+ for (const el of editorLines)
856
+ lines.push(` ${truncateToWidth(el, Math.max(0, width - 1), "")}`);
857
+ lines.push("");
858
+ lines.push(row(dim(t)("enter submit • esc back • ctrl+c cancel")));
859
+ lines.push("");
860
+ return lines.map((l) => truncateToWidth(l, width, ""));
861
+ }
862
+
863
+ // Search bar
864
+ if (!isMulti) {
865
+ const searchVal = this.searchQuery
866
+ ? t.fg("text", this.searchQuery)
867
+ : t.fg("dim", "type to filter");
868
+ lines.push(row(`${t.fg("accent", "Filter:")} ${searchVal}`));
869
+ }
870
+
871
+ // Chat sentinel row (above options for single-select, always visible)
872
+ const chatLabel =
873
+ this.selectedOptionIndex === -999
874
+ ? t.fg("accent", t.bold(SENTINEL_CHAT))
875
+ : t.fg("dim", SENTINEL_CHAT);
876
+ lines.push(row(` ${t.fg("dim", "💬")} ${chatLabel}`));
877
+
878
+ // Options (with optional preview pane)
879
+ const optionLines = this.renderOptions(useSplit ? leftWidth : width - 4);
880
+ const previewLines = useSplit ? this.renderPreview(previewWidth) : [];
881
+ const maxOptLines = Math.max(optionLines.length, previewLines.length);
882
+
883
+ if (useSplit) {
884
+ const sep = t.fg("dim", SEPARATOR);
885
+ for (let i = 0; i < maxOptLines; i++) {
886
+ const left = truncateToWidth(
887
+ optionLines[i] ?? "",
888
+ leftWidth - 1,
889
+ "",
890
+ true,
891
+ );
892
+ const right = truncateToWidth(
893
+ previewLines[i] ?? "",
894
+ previewWidth - 2,
895
+ "",
896
+ );
897
+ const paintedLeft = left || " ".repeat(leftWidth - 1);
898
+ const paintedRight = right || " ".repeat(previewWidth - 2);
899
+ const body = `${paintedLeft}${sep}${paintedRight}`;
900
+ lines.push(` ${truncateToWidth(body, Math.max(0, width - 1), "")}`);
901
+ }
902
+ } else {
903
+ for (const line of optionLines) {
904
+ lines.push(row(line));
905
+ }
906
+ }
907
+
908
+ // Footer hints
909
+ const hintTexts: string[] = [];
910
+ const navHint =
911
+ 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));
920
+ lines.push("");
921
+
922
+ // Final safety net: never emit a line wider than the terminal.
923
+ return lines.map((l) => truncateToWidth(l, width, ""));
924
+ }
900
925
  }
901
926
 
902
927
  // ── RPC fallback ───────────────────────────────────────────────────────
903
928
 
904
929
  async function rpcFallback(
905
- ui: { select: Function; input: Function },
906
- params: Params,
930
+ ui: { select: Function; input: Function },
931
+ params: Params,
907
932
  ): 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 };
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 };
980
1005
  }
981
1006
 
982
1007
  // ── Tool registration ──────────────────────────────────────────────────
983
1008
 
984
1009
  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
- });
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
+ });
1081
1106
  }