@fyeeme/pi-ask-user 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1115 @@
1
+ /**
2
+ * AskDialogComponent — migrated from oh-my-pi
3
+ * (packages/coding-agent/src/modes/components/ask-dialog.ts).
4
+ *
5
+ * The multi-question tabbed ask dialog: one tab per question (plus a Submit
6
+ * review tab for 3+ questions or any multi question), radio/checkbox markers,
7
+ * markdown/code option previews, an inactivity countdown that auto-picks
8
+ * recommended options, and an embedded single-line prompt for "Other" answers.
9
+ *
10
+ * Adaptations for pi's extension API (everything else is omp code):
11
+ * - omp's global `theme` singleton and `getMarkdownTheme()` become a pi Theme
12
+ * instance injected via the constructor plus `markdownThemeFrom()`.
13
+ * - omp's symbol table (theme.radio/checkbox/nav/boxRound) becomes SYMBOLS.
14
+ * - omp's TabBar component becomes a self-drawn tab chip row (pi-tui has no
15
+ * TabBar), visually aligned with omp's chips.
16
+ * - omp's ScrollView string window becomes windowLines + clipIndicator (pi's
17
+ * ScrollView is a layout container, not a string[] window).
18
+ * - omp's global keybinding matchers (matchesSelectUp/…) and editorKey labels
19
+ * resolve through the KeybindingsManager pi injects into ui.custom().
20
+ * - omp's nested HookEditorComponent prompts (onPrompt callback swapping the
21
+ * editor container) become an embedded prompt mode inside this component:
22
+ * pi extensions own a single custom component slot and cannot mount a
23
+ * second focused component, so the dialog itself renders the input row and
24
+ * handles its keystrokes while #promptActive.
25
+ * - The inputGuard (draft editor proxy) is dropped: pi extensions cannot
26
+ * access the draft editor.
27
+ */
28
+
29
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
30
+ import { getMarkdownTheme, highlightCode } from "@earendil-works/pi-coding-agent";
31
+ import type { MarkdownTheme } from "@earendil-works/pi-tui";
32
+ import { Markdown } from "@earendil-works/pi-tui";
33
+ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, Input } from "@earendil-works/pi-tui";
34
+ import {
35
+ clamp,
36
+ ELLIPSIS,
37
+ keyLabel,
38
+ replaceTabs,
39
+ renderInlineMarkdown,
40
+ SYMBOLS,
41
+ windowLines,
42
+ clipIndicator,
43
+ } from "./compat.ts";
44
+ import { CountdownTimer, type TuiRenderHandle } from "./countdown-timer.ts";
45
+ import { bottomBorder, divider, fit, row, topBorder } from "./overlay-box.ts";import type {
46
+ ExtensionAskDialogOption,
47
+ ExtensionAskDialogQuestion,
48
+ ExtensionAskDialogResultItem,
49
+ ExtensionAskDialogSubmitResult,
50
+ } from "./types.ts";
51
+
52
+ const OTHER_OPTION = "Other (type your own)";
53
+ const SUBMIT_OPTION = "Submit";
54
+
55
+ /** Fraction of the terminal the dialog may occupy. The box height is fixed
56
+ * at spawn from the tallest tab's content (re-measured only on viewport
57
+ * resize) and clamped to this ratio; it rises from the bottom as a stable
58
+ * panel that never resizes on tab switches or cursor moves. */
59
+ const DIALOG_HEIGHT_RATIO = 0.7;
60
+ const MIN_DIALOG_ROWS = 12;
61
+ const MIN_BODY_ROWS = 5;
62
+ const MAX_HEADER_CHIP_WIDTH = 16;
63
+ /** Maximum number of wrapped lines for an in-body question header, so a long
64
+ * or multi-line question cannot push the option list off-screen. */
65
+ const MAX_HEADER_ROWS = 4;
66
+ /** Maximum rows of the embedded prompt view (title + input + hint) rendered
67
+ * inside the dialog frame while the user types an Other answer. */
68
+ const MAX_PROMPT_TITLE_ROWS = 3;
69
+
70
+ /** Minimum inner width for the side-by-side option/preview split. */
71
+ const SPLIT_MIN_WIDTH = 80;
72
+
73
+ interface AskDialogCallbacks {
74
+ onSubmit(result: ExtensionAskDialogSubmitResult): void;
75
+ onCancel(): void;
76
+ }
77
+
78
+ interface AskDialogOptions {
79
+ timeout?: number;
80
+ onTimeout?: () => void;
81
+ tui?: TuiRenderHandle;
82
+ }
83
+
84
+ interface QuestionState {
85
+ selectedOptions: Set<string>;
86
+ customInput: string | undefined;
87
+ cursorIndex: number;
88
+ scrollOffset: number;
89
+ manualScroll: boolean;
90
+ timedOut: boolean;
91
+ }
92
+
93
+ type QuestionRowKind = "option" | "other";
94
+
95
+ interface QuestionRow {
96
+ kind: QuestionRowKind;
97
+ key: string;
98
+ label: string;
99
+ optionIndex: number | undefined;
100
+ }
101
+
102
+ interface RenderedList {
103
+ lines: string[];
104
+ scrollOffset: number;
105
+ indicator: string;
106
+ }
107
+
108
+ interface PreviewSegment {
109
+ kind: "markdown" | "code";
110
+ text: string;
111
+ language: string | undefined;
112
+ }
113
+
114
+ type PreviewRenderCache = Map<string, Map<number, readonly string[]>>;
115
+
116
+ /** Embedded prompt state (replaces omp's nested HookEditorComponent):
117
+ * a pi-tui Input instance owns the input line — cursor marker (IME
118
+ * positioning), horizontal scroll, grapheme edits, paste, undo — while the
119
+ * dialog keeps the question/state snapshot. */
120
+ interface EmbeddedPrompt {
121
+ title: string;
122
+ input: Input;
123
+ prefill: string | undefined;
124
+ /** State snapshot of the question the prompt belongs to. */
125
+ question: ExtensionAskDialogQuestion;
126
+ state: QuestionState;
127
+ }
128
+
129
+ function stripRecommendedSuffix(label: string): string {
130
+ const suffix = " (Recommended)";
131
+ return label.endsWith(suffix) ? label.slice(0, -suffix.length) : label;
132
+ }
133
+
134
+ function questionTabLabel(question: ExtensionAskDialogQuestion, index: number): string {
135
+ const base = question.header?.trim() || question.id || `Q${index + 1}`;
136
+ return truncateToWidth(replaceTabs(base), MAX_HEADER_CHIP_WIDTH, ELLIPSIS);
137
+ }
138
+
139
+ function renderQuestionTitle(theme: Theme, question: ExtensionAskDialogQuestion, width: number): string[] {
140
+ const questionText = renderInlineMarkdown(replaceTabs(question.question), t => theme.fg("text", t));
141
+ const wrapped = wrapTextWithAnsi(questionText, Math.max(1, width));
142
+ if (wrapped.length <= MAX_HEADER_ROWS) return wrapped;
143
+ return [
144
+ ...wrapped.slice(0, MAX_HEADER_ROWS - 1),
145
+ truncateToWidth(wrapped.slice(MAX_HEADER_ROWS - 1).join(" "), Math.max(1, width), ELLIPSIS),
146
+ ];
147
+ }
148
+
149
+ function splitPreviewSegments(preview: string): PreviewSegment[] {
150
+ const segments: PreviewSegment[] = [];
151
+ const markdownBuffer: string[] = [];
152
+ let fenceChar: string | undefined;
153
+ let fenceLength = 0;
154
+ let fenceLanguage: string | undefined;
155
+ let codeBuffer: string[] = [];
156
+
157
+ const flushMarkdown = (): void => {
158
+ if (markdownBuffer.length === 0) return;
159
+ segments.push({ kind: "markdown", text: markdownBuffer.join("\n"), language: undefined });
160
+ markdownBuffer.length = 0;
161
+ };
162
+ const flushCode = (): void => {
163
+ segments.push({ kind: "code", text: codeBuffer.join("\n"), language: fenceLanguage });
164
+ codeBuffer = [];
165
+ fenceChar = undefined;
166
+ fenceLength = 0;
167
+ fenceLanguage = undefined;
168
+ };
169
+
170
+ for (const line of replaceTabs(preview).split("\n")) {
171
+ const fenceMatch = /^(\s{0,3})(`{3,}|~{3,})(.*)$/.exec(line);
172
+ if (fenceChar !== undefined) {
173
+ if (fenceMatch) {
174
+ const marker = fenceMatch[2] ?? "";
175
+ const info = fenceMatch[3]?.trim() ?? "";
176
+ if (marker.startsWith(fenceChar) && marker.length >= fenceLength && info === "") {
177
+ flushCode();
178
+ continue;
179
+ }
180
+ }
181
+ codeBuffer.push(line);
182
+ continue;
183
+ }
184
+ if (fenceMatch) {
185
+ flushMarkdown();
186
+ const marker = fenceMatch[2] ?? "";
187
+ fenceChar = marker[0];
188
+ fenceLength = marker.length;
189
+ fenceLanguage = fenceMatch[3]?.trim().split(/\s+/, 1)[0] || undefined;
190
+ codeBuffer = [];
191
+ continue;
192
+ }
193
+ markdownBuffer.push(line);
194
+ }
195
+
196
+ if (fenceChar !== undefined) {
197
+ segments.push({ kind: "code", text: codeBuffer.join("\n"), language: fenceLanguage });
198
+ } else {
199
+ flushMarkdown();
200
+ }
201
+ return segments;
202
+ }
203
+
204
+ function renderPreviewContent(theme: Theme, preview: string, width: number): string[] {
205
+ const out: string[] = [];
206
+ const mdTheme: MarkdownTheme = getMarkdownTheme();
207
+ const accentStyle = { color: (text: string) => theme.fg("muted", text) };
208
+ for (const segment of splitPreviewSegments(preview)) {
209
+ if (segment.kind === "code") {
210
+ // pi's highlightCode returns ANSI-styled lines (same signature as
211
+ // omp's); overflow is clipped by the surrounding row() fit().
212
+ out.push(...highlightCode(segment.text, segment.language));
213
+ continue;
214
+ }
215
+ const markdown = new Markdown(segment.text, 0, 0, mdTheme, accentStyle);
216
+ out.push(...markdown.render(Math.max(1, width)));
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /** Cached preview render at a given width. Returns raw lines; callers add
222
+ * their own gutter (inline mode indents under the option, split mode draws
223
+ * the right pane). */
224
+ function renderCachedPreview(theme: Theme, cache: PreviewRenderCache, preview: string, width: number): readonly string[] {
225
+ let byWidth = cache.get(preview);
226
+ if (!byWidth) {
227
+ byWidth = new Map();
228
+ cache.set(preview, byWidth);
229
+ }
230
+ let rendered = byWidth.get(width);
231
+ if (!rendered) {
232
+ rendered = renderPreviewContent(theme, preview, width);
233
+ byWidth.set(width, rendered);
234
+ }
235
+ return rendered;
236
+ }
237
+
238
+ function normalizedInlineInput(input: string): string {
239
+ return replaceTabs(input).replace(/\s+/g, " ").trim();
240
+ }
241
+
242
+ function renderAnswerSummary(theme: Theme, question: ExtensionAskDialogQuestion, state: QuestionState): string {
243
+ const selected = question.options.map(option => option.label).filter(label => state.selectedOptions.has(label));
244
+ if (question.multi) {
245
+ const answers = [...selected];
246
+ if (state.customInput !== undefined) answers.push(`Other: “${normalizedInlineInput(state.customInput)}”`);
247
+ return answers.length > 0 ? answers.join(", ") : theme.fg("warning", "unanswered");
248
+ }
249
+ if (state.customInput !== undefined) return `“${normalizedInlineInput(state.customInput)}”`;
250
+ if (selected.length === 0) return theme.fg("warning", "unanswered");
251
+ return selected[0] ?? theme.fg("warning", "unanswered");
252
+ }
253
+
254
+ function optionMarker(theme: Theme, question: ExtensionAskDialogQuestion, checked: boolean): string {
255
+ if (question.multi) return checked ? SYMBOLS.checkbox.checked : SYMBOLS.checkbox.unchecked;
256
+ return checked ? SYMBOLS.radio.selected : SYMBOLS.radio.unselected;
257
+ }
258
+
259
+ function renderRowLabel(
260
+ theme: Theme,
261
+ rowItem: QuestionRow,
262
+ question: ExtensionAskDialogQuestion,
263
+ state: QuestionState,
264
+ selected: boolean,
265
+ previewCache: PreviewRenderCache,
266
+ width: number,
267
+ showInlinePreview: boolean,
268
+ ): string[] {
269
+ const isOption = rowItem.kind === "option";
270
+ const isOther = rowItem.kind === "other";
271
+ const checked = isOption
272
+ ? state.selectedOptions.has(stripRecommendedSuffix(rowItem.label))
273
+ : isOther && state.customInput !== undefined;
274
+ const color = selected ? "accent" : checked ? "toolOutput" : "text";
275
+ const marker = `${theme.fg(checked ? "success" : "dim", optionMarker(theme, question, checked))} `;
276
+ const cursor = selected ? theme.fg("accent", `${SYMBOLS.nav.cursor} `) : " ";
277
+ const label = renderInlineMarkdown(rowItem.label, t => theme.fg(color, t));
278
+ const labelWidth = Math.max(1, width - visibleWidth(cursor) - visibleWidth(marker));
279
+ const wrappedLabel = wrapTextWithAnsi(label, labelWidth);
280
+ const indent = " ".repeat(visibleWidth(cursor) + visibleWidth(marker));
281
+ const lines = [`${cursor}${marker}${wrappedLabel[0] ?? ""}`];
282
+ for (let i = 1; i < wrappedLabel.length; i++) {
283
+ lines.push(`${indent}${wrappedLabel[i] ?? ""}`);
284
+ }
285
+ if (rowItem.kind === "option") {
286
+ const option = question.options[rowItem.optionIndex ?? -1];
287
+ if (option?.description?.trim()) {
288
+ const description = renderInlineMarkdown(option.description.trim(), t => theme.fg("muted", t));
289
+ const wrapped = wrapTextWithAnsi(description, Math.max(1, width - 6));
290
+ for (const line of wrapped.slice(0, 2)) {
291
+ lines.push(` ${truncateToWidth(line, Math.max(1, width - 6), ELLIPSIS)}`);
292
+ }
293
+ }
294
+ if (option?.preview?.trim() && showInlinePreview) {
295
+ const previewWidth = Math.max(1, width - 8);
296
+ lines.push(
297
+ ...renderCachedPreview(theme, previewCache, option.preview, previewWidth).map(
298
+ line => ` ${theme.fg("borderMuted", "│")} ${line}`,
299
+ ),
300
+ );
301
+ }
302
+ }
303
+ if (isOther && state.customInput !== undefined) {
304
+ const preview = replaceTabs(state.customInput).replace(/\s+/g, " ").trim();
305
+ lines.push(theme.fg("muted", ` ${truncateToWidth(preview, Math.max(1, width - 6), ELLIPSIS)}`));
306
+ }
307
+ return lines;
308
+ }
309
+
310
+ /**
311
+ * Coerce untrusted dialog questions into a render-safe shape. The live ask
312
+ * dialog is reached from streamed tool args, where a question entry can arrive
313
+ * with a missing or non-string `question` field. The render helpers assume
314
+ * strings, so a malformed entry would throw and take down the whole TUI render
315
+ * loop — normalize first. (omp ask-dialog.ts)
316
+ */
317
+ function normalizeDialogQuestions(questions: ExtensionAskDialogQuestion[]): ExtensionAskDialogQuestion[] {
318
+ if (!Array.isArray(questions)) return [];
319
+ const out: ExtensionAskDialogQuestion[] = [];
320
+ for (const entry of questions) {
321
+ if (!entry || typeof entry !== "object") continue;
322
+ const q = entry as Partial<ExtensionAskDialogQuestion>;
323
+ const options: ExtensionAskDialogOption[] = [];
324
+ if (Array.isArray(q.options)) {
325
+ for (const opt of q.options) {
326
+ if (!opt || typeof opt !== "object") continue;
327
+ const o = opt as Partial<ExtensionAskDialogOption>;
328
+ options.push({
329
+ label: typeof o.label === "string" ? o.label : "",
330
+ ...(typeof o.description === "string" ? { description: o.description } : {}),
331
+ ...(typeof o.preview === "string" ? { preview: o.preview } : {}),
332
+ });
333
+ }
334
+ }
335
+ out.push({
336
+ id: typeof q.id === "string" ? q.id : "?",
337
+ question: typeof q.question === "string" ? q.question : "",
338
+ ...(typeof q.header === "string" ? { header: q.header } : {}),
339
+ options,
340
+ ...(typeof q.multi === "boolean" ? { multi: q.multi } : {}),
341
+ ...(Number.isInteger(q.recommended) ? { recommended: q.recommended } : {}),
342
+ });
343
+ }
344
+ return out;
345
+ }
346
+
347
+ /** Bound a prompt title to a fixed row budget (omp boundPromptTitle). */
348
+ function boundPromptTitle(theme: Theme, prefix: string, question: string): string[] {
349
+ const cols = process.stdout.columns ?? 80;
350
+ const width = Math.max(1, cols - 4);
351
+ const flat = `${prefix}${normalizedInlineInput(question)}`;
352
+ const wrapped = wrapTextWithAnsi(theme.fg("text", flat), width);
353
+ if (wrapped.length <= MAX_PROMPT_TITLE_ROWS) return wrapped;
354
+ const kept = wrapped.slice(0, MAX_PROMPT_TITLE_ROWS - 1);
355
+ const last = truncateToWidth(wrapped[MAX_PROMPT_TITLE_ROWS - 1] ?? "", width, ELLIPSIS);
356
+ return [...kept, last];
357
+ }
358
+
359
+ export class AskDialogComponent {
360
+ /** Focusable (IME support, pi-tui contract): the TUI sets `focused` when
361
+ * this component gains keyboard focus. While the embedded prompt is
362
+ * open, focus propagates to its pi-tui Input so CURSOR_MARKER (and with
363
+ * it the hardware cursor / IME candidate window) tracks the input point;
364
+ * outside the prompt the dialog renders no marker and keeps the cursor
365
+ * hidden. */
366
+ #focused = false;
367
+ get focused(): boolean {
368
+ return this.#focused;
369
+ }
370
+ set focused(value: boolean) {
371
+ this.#focused = value;
372
+ if (this.#embeddedPrompt) this.#embeddedPrompt.input.focused = value;
373
+ }
374
+
375
+ #states: QuestionState[];
376
+ #activeTabIndex = 0;
377
+ #submitScrollOffset = 0;
378
+ #bodyRows = MIN_BODY_ROWS;
379
+ #questionCanPage = false;
380
+ #remainingSeconds: number | undefined;
381
+ #countdown: CountdownTimer | undefined;
382
+ #promptActive = false;
383
+ #embeddedPrompt: EmbeddedPrompt | undefined;
384
+ #timeoutExpired = false;
385
+ #closed = false;
386
+ #stableHeight: { key: string; total: number } | undefined;
387
+ #previewCache: PreviewRenderCache = new Map();
388
+ readonly #questions: ExtensionAskDialogQuestion[];
389
+ readonly #theme: Theme;
390
+ readonly #keybindings: KeybindingsManager;
391
+ readonly #callbacks: AskDialogCallbacks;
392
+ readonly #options: AskDialogOptions;
393
+
394
+ constructor(
395
+ theme: Theme,
396
+ keybindings: KeybindingsManager,
397
+ questions: ExtensionAskDialogQuestion[],
398
+ callbacks: AskDialogCallbacks,
399
+ options: AskDialogOptions = {},
400
+ ) {
401
+ this.#theme = theme;
402
+ this.#keybindings = keybindings;
403
+ this.#callbacks = callbacks;
404
+ this.#options = options;
405
+ this.#questions = normalizeDialogQuestions(questions);
406
+ this.#states = this.#questions.map(question => {
407
+ const recommended = Number.isInteger(question.recommended) ? question.recommended : 0;
408
+ const maxIndex = Math.max(0, question.options.length - 1);
409
+ return {
410
+ selectedOptions: new Set<string>(),
411
+ customInput: undefined,
412
+ cursorIndex: clamp(recommended ?? 0, 0, maxIndex),
413
+ scrollOffset: 0,
414
+ manualScroll: false,
415
+ timedOut: false,
416
+ };
417
+ });
418
+ if (options.timeout && options.timeout > 0) {
419
+ this.#countdown = new CountdownTimer(
420
+ options.timeout,
421
+ options.tui,
422
+ seconds => {
423
+ this.#remainingSeconds = seconds;
424
+ },
425
+ () => this.#handleTimeout(),
426
+ );
427
+ }
428
+ }
429
+
430
+ invalidate(): void {
431
+ this.#stableHeight = undefined;
432
+ this.#previewCache.clear();
433
+ }
434
+
435
+ dispose(): void {
436
+ this.#closed = true;
437
+ this.#countdown?.dispose();
438
+ }
439
+
440
+ handleInput(keyData: string): void {
441
+ if (this.#closed) return;
442
+ if (this.#promptActive) {
443
+ this.#handleEmbeddedPromptInput(keyData);
444
+ return;
445
+ }
446
+ // Reset the inactivity countdown on any key that reaches past the
447
+ // closed/prompt guards, matching HookSelector/HookInput semantics.
448
+ this.#countdown?.reset();
449
+ if (this.#keybindings.matches(keyData, "tui.select.cancel")) {
450
+ this.#finishCancel();
451
+ return;
452
+ }
453
+ if (this.#hasTabBar() && this.#handleTabSwitchKey(keyData)) {
454
+ this.#requestRender();
455
+ return;
456
+ }
457
+ if (this.#isSubmitTab()) {
458
+ this.#handleSubmitTabInput(keyData);
459
+ return;
460
+ }
461
+ this.#handleQuestionInput(keyData);
462
+ }
463
+
464
+ render(width: number): string[] {
465
+ const theme = this.#theme;
466
+ const innerWidth = Math.max(1, width - 4);
467
+ // Fixed panel height: measured from the tallest tab at spawn and
468
+ // re-measured only when the viewport changes. Tab switches, cursor
469
+ // moves, and later answers never resize the box; content that
470
+ // outgrows it scrolls.
471
+ const totalRows = this.#dialogHeight(innerWidth, process.stdout.rows || 40);
472
+ const headerLines = this.#renderHeader(innerWidth);
473
+ const fixedRows = 1 + headerLines.length + 1 + 1 + 1 + 1;
474
+ const bodyRows = Math.max(MIN_BODY_ROWS, totalRows - fixedRows);
475
+ this.#bodyRows = bodyRows;
476
+ const bodyLines = this.#promptActive
477
+ ? this.#renderEmbeddedPromptBody(innerWidth, bodyRows)
478
+ : this.#isSubmitTab()
479
+ ? this.#renderSubmitBody(innerWidth, bodyRows)
480
+ : this.#renderQuestionBody(innerWidth, bodyRows);
481
+ const footer = this.#footerHintText(bodyLines.indicator);
482
+ return [
483
+ topBorder(theme, width, this.#promptActive ? "Ask — input" : this.#titleText()),
484
+ ...headerLines.map(line => row(theme, line, width)),
485
+ divider(theme, width),
486
+ ...bodyLines.lines.map(line => row(theme, line, width)),
487
+ divider(theme, width),
488
+ row(theme, theme.fg("dim", footer), width),
489
+ bottomBorder(theme, width),
490
+ ];
491
+ }
492
+
493
+ #dialogHeight(width: number, termRows: number): number {
494
+ const key = `${width}:${termRows}`;
495
+ if (this.#stableHeight?.key === key) return this.#stableHeight.total;
496
+ const total = this.#measureHeight(width, termRows);
497
+ this.#stableHeight = { key, total };
498
+ return total;
499
+ }
500
+
501
+ /** Measure the tallest tab's natural content height, clamped to
502
+ * DIALOG_HEIGHT_RATIO of the terminal. Derived from questions and
503
+ * viewport only — never from cursor, tab, or answer state — so the box
504
+ * size is stable for the dialog's lifetime at a given terminal size. */
505
+ #measureHeight(width: number, termRows: number): number {
506
+ const maxHeight = Math.max(MIN_DIALOG_ROWS, Math.floor(termRows * DIALOG_HEIGHT_RATIO));
507
+ const chrome = 5; // topBorder + divider + divider + footer + bottomBorder
508
+ const tabBarRows = this.#hasTabBar() ? 1 : 0;
509
+ let needed = MIN_DIALOG_ROWS;
510
+ for (let index = 0; index < this.#questions.length; index++) {
511
+ const question = this.#questions[index];
512
+ const state = this.#states[index];
513
+ if (!question || !state) continue;
514
+ const headerRows = tabBarRows + renderQuestionTitle(this.#theme, question, width).length;
515
+ const rowItems = this.#questionRows(question);
516
+ let body = 0;
517
+ const split = this.#useSplitLayout(question, width);
518
+ const { leftWidth, rightWidth } = this.#splitWidths(width);
519
+ for (const rowItem of rowItems) {
520
+ body += renderRowLabel(
521
+ this.#theme,
522
+ rowItem,
523
+ question,
524
+ state,
525
+ false,
526
+ this.#previewCache,
527
+ split ? leftWidth : width,
528
+ !split,
529
+ ).length;
530
+ }
531
+ if (split) {
532
+ // The right pane must fit the tallest preview at its width.
533
+ let previewRows = 0;
534
+ for (const option of question.options) {
535
+ if (option.preview?.trim()) {
536
+ previewRows = Math.max(
537
+ previewRows,
538
+ renderCachedPreview(this.#theme, this.#previewCache, option.preview, rightWidth).length,
539
+ );
540
+ }
541
+ }
542
+ body = Math.max(body, previewRows);
543
+ }
544
+ needed = Math.max(needed, chrome + headerRows + Math.max(MIN_BODY_ROWS, body));
545
+ }
546
+ if (this.#hasSubmitTab()) {
547
+ const body = 2 + this.#questions.length + 2;
548
+ needed = Math.max(needed, chrome + tabBarRows + 1 + Math.max(MIN_BODY_ROWS, body));
549
+ }
550
+ return Math.min(needed, maxHeight);
551
+ }
552
+
553
+ #titleText(): string {
554
+ return this.#remainingSeconds === undefined ? "Ask" : `Ask (${this.#remainingSeconds}s)`;
555
+ }
556
+
557
+ #hasTabBar(): boolean {
558
+ // Multi-question dialogs always get a tab bar for ←/→ navigation, even
559
+ // when there is no Submit review tab to switch to; a Submit tab alone
560
+ // (single multi question) also needs its chip row.
561
+ return this.#questions.length > 1 || this.#hasSubmitTab();
562
+ }
563
+
564
+ #hasSubmitTab(): boolean {
565
+ // The Submit review tab appears for 3+ questions or any multi question.
566
+ // Multi questions confirm on the Submit tab (Enter toggles, never
567
+ // submits), so any multi question forces the tab even when there is
568
+ // only one question. Fewer than three single-select questions advance
569
+ // Enter-to-submit without a review page.
570
+ return this.#questions.length >= 3 || this.#questions.some(question => question.multi);
571
+ }
572
+
573
+ #submitTabIndex(): number {
574
+ return this.#questions.length;
575
+ }
576
+
577
+ #isSubmitTab(): boolean {
578
+ return this.#hasSubmitTab() && this.#activeTabIndex === this.#submitTabIndex();
579
+ }
580
+
581
+ #currentQuestionIndex(): number {
582
+ return clamp(this.#activeTabIndex, 0, Math.max(0, this.#questions.length - 1));
583
+ }
584
+
585
+ #requestRender(): void {
586
+ this.#options.tui?.requestRender();
587
+ }
588
+
589
+ #renderHeader(width: number): string[] {
590
+ const theme = this.#theme;
591
+ const lines: string[] = [];
592
+ if (this.#hasTabBar()) {
593
+ lines.push(...this.#renderTabBar(width));
594
+ }
595
+ if (this.#isSubmitTab()) {
596
+ lines.push(theme.bold(theme.fg("accent", "Review answers")));
597
+ return lines;
598
+ }
599
+ const questionIndex = this.#currentQuestionIndex();
600
+ const question = this.#questions[questionIndex];
601
+ if (!question) return lines;
602
+ lines.push(...renderQuestionTitle(theme, question, width));
603
+ return lines;
604
+ }
605
+
606
+ /** Self-drawn tab chip row standing in for omp's TabBar component. */
607
+ #renderTabBar(width: number): string[] {
608
+ const theme = this.#theme;
609
+ const chips: string[] = [];
610
+ this.#questions.forEach((question, index) => {
611
+ const label = questionTabLabel(question, index);
612
+ const active = index === this.#activeTabIndex;
613
+ const answered = this.#states[index]?.selectedOptions.size || this.#states[index]?.customInput !== undefined;
614
+ const dot = answered ? theme.fg("success", "●") : theme.fg("dim", "○");
615
+ chips.push(active ? theme.fg("accent", `${SYMBOLS.nav.cursor} ${label} `) : `${dot}${theme.fg("muted", ` ${label} `)}`);
616
+ });
617
+ if (this.#hasSubmitTab()) {
618
+ if (this.#isSubmitTab()) {
619
+ chips.push(theme.fg("accent", `${SYMBOLS.nav.cursor} ${SUBMIT_OPTION} `));
620
+ } else {
621
+ chips.push(theme.fg("muted", ` ${SUBMIT_OPTION} `));
622
+ }
623
+ }
624
+ const joined = chips.join("");
625
+ return [truncateToWidth(joined, Math.max(1, width), "")];
626
+ }
627
+
628
+ #footerHintText(indicator: string): string {
629
+ const theme = this.#theme;
630
+ const cancelKey = keyLabel(this.#keybindings.getKeys("tui.select.cancel"), "esc");
631
+ const cancel = `${cancelKey} cancel`;
632
+ if (this.#promptActive) {
633
+ return `Enter confirm · Esc back · ${cancel}`;
634
+ }
635
+ if (this.#isSubmitTab()) {
636
+ const scroll = indicator ? ` ${indicator} scroll ·` : "";
637
+ return `Enter submit · ↑/↓ scroll ·${scroll} ${cancel}`;
638
+ }
639
+ const question = this.#questions[this.#currentQuestionIndex()];
640
+ // Enter advances in multi-question dialogs and submits on the last
641
+ // question when there is no review tab.
642
+ const isLast = this.#currentQuestionIndex() >= this.#questions.length - 1;
643
+ const enterAction = !this.#hasSubmitTab() && isLast ? "submit" : "next";
644
+ const action = question?.multi ? `Space toggle · Enter ${enterAction}` : "Enter select";
645
+ const tabs = this.#hasTabBar() ? " · Tab/←/→" : "";
646
+ if (this.#questionCanPage && indicator) {
647
+ const pageUp = keyLabel(this.#keybindings.getKeys("tui.select.pageUp"), "PgUp");
648
+ const pageDown = keyLabel(this.#keybindings.getKeys("tui.select.pageDown"), "PgDn");
649
+ return `${action} · ↑/↓${tabs} · ${cancel} · ${pageUp}/${pageDown} ${indicator}`;
650
+ }
651
+ const scroll = indicator ? ` ${indicator} scroll ·` : "";
652
+ return `${action} · ↑/↓ move${tabs} ·${scroll} ${cancel}`;
653
+ }
654
+
655
+ #questionRows(question: ExtensionAskDialogQuestion): QuestionRow[] {
656
+ const rows: QuestionRow[] = question.options.map((option, index) => ({
657
+ kind: "option",
658
+ key: `option:${index}`,
659
+ label: this.#optionLabel(question, option.label, index),
660
+ optionIndex: index,
661
+ }));
662
+ rows.push({ kind: "other", key: "other", label: OTHER_OPTION, optionIndex: undefined });
663
+ return rows;
664
+ }
665
+
666
+ #optionLabel(question: ExtensionAskDialogQuestion, label: string, index: number): string {
667
+ return question.recommended === index ? `${label} (Recommended)` : label;
668
+ }
669
+
670
+ #activeQuestionState(): { question: ExtensionAskDialogQuestion; state: QuestionState } | undefined {
671
+ const question = this.#questions[this.#currentQuestionIndex()];
672
+ const state = this.#states[this.#currentQuestionIndex()];
673
+ if (!question || !state) return undefined;
674
+ return { question, state };
675
+ }
676
+
677
+ #handleTabSwitchKey(keyData: string): boolean {
678
+ const switchTab = (direction: 1 | -1): void => {
679
+ const tabCount = this.#questions.length + (this.#hasSubmitTab() ? 1 : 0);
680
+ this.#activeTabIndex = (this.#activeTabIndex + direction + tabCount) % tabCount;
681
+ this.#submitScrollOffset = 0;
682
+ };
683
+ if (matchesKey(keyData, Key.tab) || matchesKey(keyData, Key.right)) {
684
+ switchTab(1);
685
+ return true;
686
+ }
687
+ if (matchesKey(keyData, "shift+tab") || matchesKey(keyData, Key.left)) {
688
+ switchTab(-1);
689
+ return true;
690
+ }
691
+ return false;
692
+ }
693
+
694
+ #handleQuestionInput(keyData: string): void {
695
+ const active = this.#activeQuestionState();
696
+ if (!active) return;
697
+ const { question, state } = active;
698
+ const rows = this.#questionRows(question);
699
+ if (this.#keybindings.matches(keyData, "tui.select.pageUp")) {
700
+ state.scrollOffset = Math.max(0, state.scrollOffset - Math.max(1, this.#bodyRows - 1));
701
+ state.manualScroll = true;
702
+ this.#requestRender();
703
+ return;
704
+ }
705
+ if (this.#keybindings.matches(keyData, "tui.select.pageDown")) {
706
+ state.scrollOffset += Math.max(1, this.#bodyRows - 1);
707
+ state.manualScroll = true;
708
+ this.#requestRender();
709
+ return;
710
+ }
711
+ if (this.#keybindings.matches(keyData, "tui.select.up")) {
712
+ state.cursorIndex = clamp(state.cursorIndex - 1, 0, Math.max(0, rows.length - 1));
713
+ state.manualScroll = false;
714
+ this.#requestRender();
715
+ return;
716
+ }
717
+ if (this.#keybindings.matches(keyData, "tui.select.down")) {
718
+ state.cursorIndex = clamp(state.cursorIndex + 1, 0, Math.max(0, rows.length - 1));
719
+ state.manualScroll = false;
720
+ this.#requestRender();
721
+ return;
722
+ }
723
+ const rowItem = rows[state.cursorIndex];
724
+ if (!rowItem) return;
725
+ const isEnter = this.#keybindings.matches(keyData, "tui.select.confirm") || keyData === "\n";
726
+ const isSpace = matchesKey(keyData, Key.space) || keyData === " ";
727
+ if (!isEnter && !(question.multi && isSpace)) return;
728
+ if (rowItem.kind === "other") {
729
+ this.#openEmbeddedPrompt(question, state);
730
+ return;
731
+ }
732
+ const option = question.options[rowItem.optionIndex ?? -1];
733
+ if (!option) return;
734
+ if (question.multi) {
735
+ if (isEnter) {
736
+ // Enter confirms the current selection without toggling the
737
+ // focused option; Space toggles. Advances to the next question
738
+ // (submitting only when no review tab remains).
739
+ this.#advanceAfterQuestion();
740
+ return;
741
+ }
742
+ if (state.selectedOptions.has(option.label)) {
743
+ state.selectedOptions.delete(option.label);
744
+ } else {
745
+ state.selectedOptions.add(option.label);
746
+ }
747
+ this.#requestRender();
748
+ return;
749
+ }
750
+ state.selectedOptions = new Set([option.label]);
751
+ state.customInput = undefined;
752
+ this.#advanceAfterQuestion();
753
+ }
754
+
755
+ #handleSubmitTabInput(keyData: string): void {
756
+ if (this.#keybindings.matches(keyData, "tui.select.up")) {
757
+ this.#submitScrollOffset = Math.max(0, this.#submitScrollOffset - 1);
758
+ this.#requestRender();
759
+ return;
760
+ }
761
+ if (this.#keybindings.matches(keyData, "tui.select.down")) {
762
+ // Clamped against the rendered line count in #renderSubmitBody.
763
+ this.#submitScrollOffset += 1;
764
+ this.#requestRender();
765
+ return;
766
+ }
767
+ const isEnter = this.#keybindings.matches(keyData, "tui.select.confirm") || keyData === "\n";
768
+ if (isEnter) this.#finishSubmit();
769
+ }
770
+
771
+ #advanceAfterQuestion(): void {
772
+ const current = this.#currentQuestionIndex();
773
+ if (this.#hasSubmitTab()) {
774
+ this.#activeTabIndex = current + 1 < this.#submitTabIndex() ? current + 1 : this.#submitTabIndex();
775
+ this.#submitScrollOffset = 0;
776
+ this.#requestRender();
777
+ return;
778
+ }
779
+ if (current + 1 < this.#questions.length) {
780
+ this.#activeTabIndex = current + 1;
781
+ this.#requestRender();
782
+ return;
783
+ }
784
+ this.#finishSubmit();
785
+ }
786
+
787
+ // --- embedded prompt (replaces omp's nested HookEditorComponent) --------
788
+
789
+ #openEmbeddedPrompt(question: ExtensionAskDialogQuestion, state: QuestionState): void {
790
+ const prefix = "Custom answer: ";
791
+ const prefill = state.customInput;
792
+ const input = new Input();
793
+ if (prefill !== undefined) input.setValue(prefill);
794
+ input.onSubmit = value => this.#submitEmbeddedPrompt(value);
795
+ input.onEscape = () => this.#closeEmbeddedPrompt();
796
+ input.focused = this.#focused;
797
+ this.#promptActive = true;
798
+ this.#embeddedPrompt = {
799
+ title: boundPromptTitle(this.#theme, prefix, question.question).join("\n"),
800
+ input,
801
+ prefill,
802
+ question,
803
+ state,
804
+ };
805
+ this.#requestRender();
806
+ }
807
+
808
+ #handleEmbeddedPromptInput(keyData: string): void {
809
+ // The embedded pi-tui Input owns the full input semantics (grapheme
810
+ // edits, paste buffering, kitty CSI-u, undo, kill ring) and emits
811
+ // CURSOR_MARKER at the cursor for IME candidate-window positioning.
812
+ this.#embeddedPrompt?.input.handleInput(keyData);
813
+ }
814
+
815
+ #submitEmbeddedPrompt(input: string): void {
816
+ const prompt = this.#embeddedPrompt;
817
+ if (!prompt) return;
818
+ const { question, state } = prompt;
819
+ prompt.input.focused = false;
820
+ this.#promptActive = false;
821
+ this.#embeddedPrompt = undefined;
822
+ // omp ordering (#promptForCustomInput): the input is applied to state
823
+ // first — and Enter may advance/submit — with the deferred timeout only
824
+ // running afterwards, so a countdown that expired mid-prompt sees the
825
+ // just-typed answer instead of discarding it and force-picking.
826
+ try {
827
+ if (input.trim() === "") {
828
+ // Submitting an empty value unselects the custom answer.
829
+ state.customInput = undefined;
830
+ return;
831
+ }
832
+ state.customInput = input;
833
+ if (!question.multi) {
834
+ state.selectedOptions.clear();
835
+ this.#advanceAfterQuestion();
836
+ }
837
+ } finally {
838
+ this.#runDeferredTimeout();
839
+ this.#requestRender();
840
+ }
841
+ }
842
+
843
+ #closeEmbeddedPrompt(): void {
844
+ if (this.#embeddedPrompt) this.#embeddedPrompt.input.focused = false;
845
+ this.#promptActive = false;
846
+ this.#embeddedPrompt = undefined;
847
+ this.#runDeferredTimeout();
848
+ this.#requestRender();
849
+ }
850
+
851
+ #renderEmbeddedPromptBody(width: number, rows: number): RenderedList {
852
+ const theme = this.#theme;
853
+ const prompt = this.#embeddedPrompt;
854
+ if (!prompt) return { lines: windowLines([], 0, rows), scrollOffset: 0, indicator: "" };
855
+ const lines: string[] = [];
856
+ for (const titleLine of prompt.title.split("\n")) {
857
+ lines.push(theme.fg("text", titleLine));
858
+ }
859
+ lines.push("");
860
+ // pi-tui Input renders the input line itself: "> " prompt, horizontal
861
+ // scrolling, reverse-video fake cursor, and CURSOR_MARKER at the input
862
+ // point (zero-width) so the TUI positions the hardware cursor there —
863
+ // that is what keeps the IME candidate window anchored.
864
+ lines.push(prompt.input.render(width)[0] ?? "");
865
+ if (prompt.prefill !== undefined) {
866
+ lines.push(theme.fg("dim", "Enter keeps the value above when unchanged; empty clears it"));
867
+ }
868
+ const window = windowLines(lines, 0, rows);
869
+ return { lines: window, scrollOffset: 0, indicator: "" };
870
+ }
871
+
872
+ // --- body rendering -----------------------------------------------------
873
+
874
+ #renderQuestionBody(width: number, maxRows: number): RenderedList {
875
+ const active = this.#activeQuestionState();
876
+ if (!active) return { lines: windowLines([], 0, maxRows), scrollOffset: 0, indicator: "" };
877
+ const { question, state } = active;
878
+ const rowItems = this.#questionRows(question);
879
+ state.cursorIndex = clamp(state.cursorIndex, 0, Math.max(0, rowItems.length - 1));
880
+ return this.#renderQuestionList(question, state, rowItems, width, maxRows);
881
+ }
882
+
883
+ #renderQuestionList(
884
+ question: ExtensionAskDialogQuestion,
885
+ state: QuestionState,
886
+ rowItems: QuestionRow[],
887
+ width: number,
888
+ rows: number,
889
+ ): RenderedList {
890
+ const allLines: string[] = [];
891
+ const lineStartByRow: number[] = [];
892
+ const split = this.#useSplitLayout(question, width);
893
+ const leftWidth = split ? this.#splitWidths(width).leftWidth : width;
894
+ for (let index = 0; index < rowItems.length; index++) {
895
+ lineStartByRow.push(allLines.length);
896
+ const rowItem = rowItems[index];
897
+ if (!rowItem) continue;
898
+ allLines.push(
899
+ ...renderRowLabel(
900
+ this.#theme,
901
+ rowItem,
902
+ question,
903
+ state,
904
+ index === state.cursorIndex,
905
+ this.#previewCache,
906
+ leftWidth,
907
+ !split,
908
+ ),
909
+ );
910
+ }
911
+ const cursorStart = lineStartByRow[state.cursorIndex] ?? 0;
912
+ const cursorEnd = lineStartByRow[state.cursorIndex + 1] ?? allLines.length;
913
+ this.#questionCanPage = cursorEnd - cursorStart > rows;
914
+ state.scrollOffset = this.#scrollOffsetForCursor(
915
+ state.scrollOffset,
916
+ cursorStart,
917
+ cursorEnd,
918
+ rows,
919
+ allLines.length,
920
+ state.manualScroll,
921
+ );
922
+ const lines = windowLines(allLines, state.scrollOffset, rows);
923
+ if (!split) {
924
+ return {
925
+ lines,
926
+ scrollOffset: state.scrollOffset,
927
+ indicator: clipIndicator(state.scrollOffset, rows, allLines.length),
928
+ };
929
+ }
930
+ // Side-by-side: options on the left, the cursored option's preview on
931
+ // the right, sharing the body window and the cursor-following scroll.
932
+ const theme = this.#theme;
933
+ const { leftWidth: lw, rightWidth } = this.#splitWidths(width);
934
+ const divider = theme.fg("borderMuted", " │ ");
935
+ const rightLines = this.#previewLinesForRow(question, state, rowItems[state.cursorIndex], rightWidth);
936
+ const rightOffset = Math.min(state.scrollOffset, Math.max(0, rightLines.length - rows));
937
+ const rightWindow = windowLines(rightLines, rightOffset, rows);
938
+ const merged = lines.map((line, index) => `${fit(line, lw)}${divider}${rightWindow[index] ?? ""}`);
939
+ return {
940
+ lines: merged,
941
+ scrollOffset: state.scrollOffset,
942
+ indicator: clipIndicator(state.scrollOffset, rows, allLines.length),
943
+ };
944
+ }
945
+
946
+ /** Split layout: wide terminal + at least one option carries a preview. */
947
+ #useSplitLayout(question: ExtensionAskDialogQuestion, width: number): boolean {
948
+ return width >= SPLIT_MIN_WIDTH && question.options.some(option => option.preview?.trim());
949
+ }
950
+
951
+ #splitWidths(width: number): { leftWidth: number; rightWidth: number } {
952
+ const leftWidth = Math.max(24, Math.floor(width * 0.42));
953
+ return { leftWidth, rightWidth: Math.max(1, width - leftWidth - 3) };
954
+ }
955
+
956
+ /** Raw preview lines for the right pane (cursored option, or the custom
957
+ * answer preview under Other). */
958
+ #previewLinesForRow(
959
+ question: ExtensionAskDialogQuestion,
960
+ state: QuestionState,
961
+ rowItem: QuestionRow | undefined,
962
+ width: number,
963
+ ): string[] {
964
+ if (!rowItem) return [];
965
+ if (rowItem.kind === "option") {
966
+ const option = question.options[rowItem.optionIndex ?? -1];
967
+ if (option?.preview?.trim()) {
968
+ return [...renderCachedPreview(this.#theme, this.#previewCache, option.preview, width)];
969
+ }
970
+ return [];
971
+ }
972
+ if (state.customInput !== undefined) {
973
+ const preview = replaceTabs(state.customInput).replace(/\s+/g, " ").trim();
974
+ return [this.#theme.fg("muted", truncateToWidth(preview, Math.max(1, width), ELLIPSIS))];
975
+ }
976
+ return [];
977
+ }
978
+
979
+ #renderSubmitBody(width: number, rows: number): RenderedList {
980
+ const theme = this.#theme;
981
+ const allLines: string[] = [];
982
+ const unanswered = this.#unansweredCount();
983
+ if (unanswered > 0) {
984
+ allLines.push(
985
+ theme.fg(
986
+ "warning",
987
+ `${unanswered} unanswered question${unanswered === 1 ? "" : "s"}; Enter still submits.`,
988
+ ),
989
+ );
990
+ allLines.push("");
991
+ }
992
+ for (let index = 0; index < this.#questions.length; index++) {
993
+ const question = this.#questions[index];
994
+ const state = this.#states[index];
995
+ if (!question || !state) continue;
996
+ const label = questionTabLabel(question, index);
997
+ const answer = renderAnswerSummary(theme, question, state);
998
+ allLines.push(`${theme.fg("dim", `${index + 1}. ${label}:`)} ${answer}`);
999
+ }
1000
+ allLines.push("");
1001
+ allLines.push(theme.fg("accent", `${SYMBOLS.nav.cursor} ${SUBMIT_OPTION}`));
1002
+ this.#submitScrollOffset = clamp(this.#submitScrollOffset, 0, Math.max(0, allLines.length - rows));
1003
+ const lines = windowLines(allLines, this.#submitScrollOffset, rows);
1004
+ return {
1005
+ lines,
1006
+ scrollOffset: this.#submitScrollOffset,
1007
+ indicator: clipIndicator(this.#submitScrollOffset, rows, allLines.length),
1008
+ };
1009
+ }
1010
+
1011
+ #scrollOffsetForCursor(
1012
+ currentOffset: number,
1013
+ cursorStart: number,
1014
+ cursorEnd: number,
1015
+ rows: number,
1016
+ totalRows: number,
1017
+ manualScroll: boolean,
1018
+ ): number {
1019
+ const maxOffset = Math.max(0, totalRows - rows);
1020
+ if (maxOffset === 0) return 0;
1021
+ let nextOffset = clamp(currentOffset, 0, maxOffset);
1022
+ const cursorRows = cursorEnd - cursorStart;
1023
+ if (manualScroll && cursorRows > rows) {
1024
+ // A page must not expose another option while Enter still targets this one.
1025
+ nextOffset = clamp(nextOffset, cursorStart, cursorEnd - rows);
1026
+ } else if (cursorStart < nextOffset || cursorEnd > nextOffset + rows) {
1027
+ nextOffset = cursorRows <= rows ? cursorEnd - rows : cursorStart;
1028
+ }
1029
+ return clamp(nextOffset, 0, maxOffset);
1030
+ }
1031
+
1032
+ #unansweredCount(): number {
1033
+ let count = 0;
1034
+ for (let index = 0; index < this.#questions.length; index++) {
1035
+ const question = this.#questions[index];
1036
+ const state = this.#states[index];
1037
+ if (!question || !state) continue;
1038
+ if (state.selectedOptions.size === 0 && state.customInput === undefined) count += 1;
1039
+ }
1040
+ return count;
1041
+ }
1042
+
1043
+ #handleTimeout(): void {
1044
+ if (this.#closed) return;
1045
+ if (this.#promptActive) {
1046
+ this.#timeoutExpired = true;
1047
+ return;
1048
+ }
1049
+ this.#options.onTimeout?.();
1050
+ for (let index = 0; index < this.#questions.length; index++) {
1051
+ const question = this.#questions[index];
1052
+ const state = this.#states[index];
1053
+ if (!question || !state) continue;
1054
+ if (state.selectedOptions.size === 0 && state.customInput === undefined) {
1055
+ const fallbackIndex = clamp(question.recommended ?? 0, 0, Math.max(0, question.options.length - 1));
1056
+ const fallback = question.options[fallbackIndex];
1057
+ if (fallback) state.selectedOptions.add(fallback.label);
1058
+ state.timedOut = true;
1059
+ }
1060
+ }
1061
+ this.#finishSubmit();
1062
+ }
1063
+
1064
+ #runDeferredTimeout(): void {
1065
+ if (!this.#timeoutExpired) return;
1066
+ this.#timeoutExpired = false;
1067
+ this.#handleTimeout();
1068
+ }
1069
+
1070
+ #finishSubmit(): void {
1071
+ if (this.#closed) return;
1072
+ this.#closed = true;
1073
+ this.#countdown?.dispose();
1074
+ this.#callbacks.onSubmit({ kind: "submit", results: this.#buildResults() });
1075
+ }
1076
+
1077
+ #finishCancel(): void {
1078
+ if (this.#closed) return;
1079
+ this.#closed = true;
1080
+ this.#countdown?.dispose();
1081
+ this.#callbacks.onCancel();
1082
+ }
1083
+
1084
+ /**
1085
+ * Cancel from outside the component (agent turn aborted). Mirrors omp's
1086
+ * #presentDialog(signal) disposal path: pi's ui.custom() has no signal
1087
+ * option, so index.ts routes the agent abort signal here to close the
1088
+ * dialog and settle the custom() promise with undefined.
1089
+ */
1090
+ externalCancel(): void {
1091
+ this.#finishCancel();
1092
+ }
1093
+
1094
+ #buildResults(): ExtensionAskDialogResultItem[] {
1095
+ const results: ExtensionAskDialogResultItem[] = [];
1096
+ for (let index = 0; index < this.#questions.length; index++) {
1097
+ const question = this.#questions[index];
1098
+ const state = this.#states[index];
1099
+ if (!question || !state) continue;
1100
+ const selectedOptions = question.options
1101
+ .map(option => option.label)
1102
+ .filter(label => state.selectedOptions.has(label));
1103
+ results.push({
1104
+ id: question.id,
1105
+ question: question.question,
1106
+ options: question.options.map(option => option.label),
1107
+ multi: question.multi ?? false,
1108
+ selectedOptions,
1109
+ customInput: state.customInput,
1110
+ timedOut: state.timedOut || undefined,
1111
+ });
1112
+ }
1113
+ return results;
1114
+ }
1115
+ }