@pi-archimedes/ask 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dialog.ts ADDED
@@ -0,0 +1,664 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Editor,
4
+ Markdown,
5
+ type EditorTheme,
6
+ type MarkdownTheme,
7
+ Key,
8
+ matchesKey,
9
+ truncateToWidth,
10
+ visibleWidth,
11
+ } from "@earendil-works/pi-tui";
12
+ import {
13
+ OTHER_OPTION,
14
+ appendRecommendedTagToOptionLabels,
15
+ buildMultiSelectionResult,
16
+ buildSingleSelectionResult,
17
+ type AskQuestion,
18
+ type AskSelection,
19
+ } from "./selection";
20
+ import { getLinearCursorIndexFromEditor } from "./cursor";
21
+ import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./note";
22
+ import { appendWrappedTextLines } from "./wrap";
23
+
24
+ interface PreparedQuestion {
25
+ id: string;
26
+ question: string;
27
+ description: string | undefined;
28
+ options: string[];
29
+ tabLabel: string;
30
+ multi: boolean;
31
+ otherOptionIndex: number;
32
+ }
33
+
34
+ interface TabsUIState {
35
+ cancelled: boolean;
36
+ selectedOptionIndexesByQuestion: number[][];
37
+ noteByQuestionByOption: string[][];
38
+ }
39
+
40
+ export function formatSelectionForSubmitReview(selection: AskSelection, isMulti: boolean): string {
41
+ const hasSelectedOptions = selection.selectedOptions.length > 0;
42
+ const hasCustomInput = Boolean(selection.customInput);
43
+
44
+ if (hasSelectedOptions && hasCustomInput) {
45
+ const selectedPart = isMulti
46
+ ? `[${selection.selectedOptions.join(", ")}]`
47
+ : selection.selectedOptions[0] ?? "";
48
+ return `${selectedPart} + Other: ${selection.customInput}`;
49
+ }
50
+
51
+ if (hasCustomInput) {
52
+ return `Other: ${selection.customInput}`;
53
+ }
54
+
55
+ if (hasSelectedOptions) {
56
+ return isMulti ? `[${selection.selectedOptions.join(", ")}]` : selection.selectedOptions[0] ?? "";
57
+ }
58
+
59
+ return "(not answered)";
60
+ }
61
+
62
+ function clampIndex(index: number | undefined, maxExclusive: number): number {
63
+ if (index == null || Number.isNaN(index) || maxExclusive <= 0) return 0;
64
+ if (index < 0) return 0;
65
+ if (index >= maxExclusive) return maxExclusive - 1;
66
+ return index;
67
+ }
68
+
69
+ function normalizeTabLabel(id: string, fallback: string): string {
70
+ const normalized = id.trim().replace(/[_-]+/g, " ");
71
+ return normalized.length > 0 ? normalized : fallback;
72
+ }
73
+
74
+ function buildSelectionForQuestion(
75
+ question: PreparedQuestion,
76
+ selectedOptionIndexes: number[],
77
+ noteByOptionIndex: string[],
78
+ ): AskSelection {
79
+ if (selectedOptionIndexes.length === 0) {
80
+ return { selectedOptions: [] };
81
+ }
82
+
83
+ if (question.multi) {
84
+ return buildMultiSelectionResult(question.options, selectedOptionIndexes, noteByOptionIndex, question.otherOptionIndex);
85
+ }
86
+
87
+ const selectedOptionIndex = selectedOptionIndexes[0];
88
+ if (selectedOptionIndex == null) return { selectedOptions: [] };
89
+ const selectedOptionLabel = question.options[selectedOptionIndex] ?? OTHER_OPTION;
90
+ const note = noteByOptionIndex[selectedOptionIndex] ?? "";
91
+ return buildSingleSelectionResult(selectedOptionLabel, note);
92
+ }
93
+
94
+ function isQuestionSelectionValid(
95
+ question: PreparedQuestion,
96
+ selectedOptionIndexes: number[],
97
+ noteByOptionIndex: string[],
98
+ ): boolean {
99
+ if (selectedOptionIndexes.length === 0) return false;
100
+ if (!selectedOptionIndexes.includes(question.otherOptionIndex)) return true;
101
+ const otherNote = noteByOptionIndex[question.otherOptionIndex]?.trim() ?? "";
102
+ return otherNote.length > 0;
103
+ }
104
+
105
+ function createTabsUiStateSnapshot(
106
+ cancelled: boolean,
107
+ selectedOptionIndexesByQuestion: number[][],
108
+ noteByQuestionByOption: string[][],
109
+ ): TabsUIState {
110
+ return {
111
+ cancelled,
112
+ selectedOptionIndexesByQuestion: selectedOptionIndexesByQuestion.map((indexes) => [...indexes]),
113
+ noteByQuestionByOption: noteByQuestionByOption.map((notes) => [...notes]),
114
+ };
115
+ }
116
+
117
+ function addIndexToSelection(selectedOptionIndexes: number[], optionIndex: number): number[] {
118
+ if (selectedOptionIndexes.includes(optionIndex)) return selectedOptionIndexes;
119
+ return [...selectedOptionIndexes, optionIndex].sort((a, b) => a - b);
120
+ }
121
+
122
+ function removeIndexFromSelection(selectedOptionIndexes: number[], optionIndex: number): number[] {
123
+ return selectedOptionIndexes.filter((index) => index !== optionIndex);
124
+ }
125
+
126
+ export async function askQuestionsWithTabs(
127
+ ui: ExtensionUIContext,
128
+ questions: AskQuestion[],
129
+ options?: { overlay?: boolean },
130
+ ): Promise<{ cancelled: boolean; selections: AskSelection[] }> {
131
+ const preparedQuestions: PreparedQuestion[] = questions.map((question, questionIndex) => {
132
+ const baseOptionLabels = question.options.map((option) => option.label);
133
+ const optionLabels = [...appendRecommendedTagToOptionLabels(baseOptionLabels, question.recommended), OTHER_OPTION];
134
+ return {
135
+ id: question.id,
136
+ question: question.question,
137
+ description: question.description,
138
+ options: optionLabels,
139
+ tabLabel: normalizeTabLabel(question.id, `Q${questionIndex + 1}`),
140
+ multi: question.multi === true,
141
+ otherOptionIndex: optionLabels.length - 1,
142
+ };
143
+ });
144
+
145
+ const initialCursorOptionIndexByQuestion = preparedQuestions.map((preparedQuestion, questionIndex) =>
146
+ clampIndex(questions[questionIndex]?.recommended, preparedQuestion.options.length),
147
+ );
148
+
149
+ const result = await ui.custom<TabsUIState>((tui, theme, _keybindings, done) => {
150
+ let activeTabIndex = 0;
151
+ let isNoteEditorOpen = false;
152
+ let cachedRenderedLines: string[] | undefined;
153
+ let cachedRenderedWidth: number | undefined;
154
+ const cursorOptionIndexByQuestion = [...initialCursorOptionIndexByQuestion];
155
+ const selectedOptionIndexesByQuestion = preparedQuestions.map(() => [] as number[]);
156
+ const noteByQuestionByOption = preparedQuestions.map((preparedQuestion) =>
157
+ Array(preparedQuestion.options.length).fill("") as string[],
158
+ );
159
+
160
+ const editorTheme: EditorTheme = {
161
+ borderColor: (text) => theme.fg("accent", text),
162
+ selectList: {
163
+ selectedPrefix: (text) => theme.fg("accent", text),
164
+ selectedText: (text) => theme.fg("accent", text),
165
+ description: (text) => theme.fg("muted", text),
166
+ scrollInfo: (text) => theme.fg("dim", text),
167
+ noMatch: (text) => theme.fg("warning", text),
168
+ },
169
+ };
170
+ const noteEditor = new Editor(tui, editorTheme);
171
+ const markdownTheme: MarkdownTheme = {
172
+ heading: (text) => theme.fg("mdHeading", text),
173
+ link: (text) => theme.fg("mdLink", text),
174
+ linkUrl: (text) => theme.fg("mdLinkUrl", text),
175
+ code: (text) => theme.fg("mdCode", text),
176
+ codeBlock: (text) => theme.fg("mdCodeBlock", text),
177
+ codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
178
+ quote: (text) => theme.fg("mdQuote", text),
179
+ quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
180
+ hr: (text) => theme.fg("mdHr", text),
181
+ listBullet: (text) => theme.fg("mdListBullet", text),
182
+ bold: (text) => theme.bold(text),
183
+ italic: (text) => theme.italic(text),
184
+ strikethrough: (text) => theme.strikethrough(text),
185
+ underline: (text) => theme.underline(text),
186
+ };
187
+ const descriptionMarkdownByQuestion = preparedQuestions.map((preparedQuestion) =>
188
+ preparedQuestion.description && preparedQuestion.description.trim().length > 0
189
+ ? new Markdown(preparedQuestion.description, 0, 0, markdownTheme, {
190
+ color: (text) => theme.fg("muted", text),
191
+ })
192
+ : undefined,
193
+ );
194
+
195
+ const submitTabIndex = preparedQuestions.length;
196
+
197
+ const requestUiRerender = () => {
198
+ cachedRenderedLines = undefined;
199
+ cachedRenderedWidth = undefined;
200
+ tui.requestRender();
201
+ };
202
+
203
+ const getActiveQuestionIndex = (): number | null => {
204
+ if (activeTabIndex >= preparedQuestions.length) return null;
205
+ return activeTabIndex;
206
+ };
207
+
208
+ const getPreparedQuestion = (questionIndex: number): PreparedQuestion =>
209
+ preparedQuestions[questionIndex]!;
210
+
211
+ const getCursorOptionIndex = (questionIndex: number): number =>
212
+ cursorOptionIndexByQuestion[questionIndex] ?? 0;
213
+
214
+ const getSelectedOptionIndexes = (questionIndex: number): number[] =>
215
+ selectedOptionIndexesByQuestion[questionIndex] ?? [];
216
+
217
+ const getQuestionNote = (questionIndex: number, optionIndex: number): string =>
218
+ noteByQuestionByOption[questionIndex]?.[optionIndex] ?? "";
219
+
220
+ const getTrimmedQuestionNote = (questionIndex: number, optionIndex: number): string =>
221
+ getQuestionNote(questionIndex, optionIndex).trim();
222
+
223
+ const isAllQuestionSelectionsValid = (): boolean =>
224
+ preparedQuestions.every((preparedQuestion, questionIndex) =>
225
+ isQuestionSelectionValid(
226
+ preparedQuestion,
227
+ selectedOptionIndexesByQuestion[questionIndex] ?? [],
228
+ noteByQuestionByOption[questionIndex] ?? [],
229
+ ),
230
+ );
231
+
232
+ const openNoteEditorForActiveOption = () => {
233
+ const questionIndex = getActiveQuestionIndex();
234
+ if (questionIndex == null) return;
235
+
236
+ isNoteEditorOpen = true;
237
+ const optionIndex = getCursorOptionIndex(questionIndex);
238
+ noteEditor.setText(getQuestionNote(questionIndex, optionIndex));
239
+ requestUiRerender();
240
+ };
241
+
242
+ const advanceToNextTabOrSubmit = () => {
243
+ activeTabIndex = Math.min(submitTabIndex, activeTabIndex + 1);
244
+ };
245
+
246
+ noteEditor.onChange = (value) => {
247
+ const questionIndex = getActiveQuestionIndex();
248
+ if (questionIndex == null) return;
249
+ const optionIndex = getCursorOptionIndex(questionIndex);
250
+ const notes = noteByQuestionByOption[questionIndex];
251
+ if (notes) notes[optionIndex] = value;
252
+ requestUiRerender();
253
+ };
254
+
255
+ noteEditor.onSubmit = (value) => {
256
+ const questionIndex = getActiveQuestionIndex();
257
+ if (questionIndex == null) return;
258
+
259
+ const preparedQuestion = getPreparedQuestion(questionIndex);
260
+ const optionIndex = getCursorOptionIndex(questionIndex);
261
+ const notes = noteByQuestionByOption[questionIndex];
262
+ if (notes) notes[optionIndex] = value;
263
+ const trimmedNote = value.trim();
264
+
265
+ if (preparedQuestion.multi) {
266
+ if (trimmedNote.length > 0) {
267
+ selectedOptionIndexesByQuestion[questionIndex] = addIndexToSelection(
268
+ getSelectedOptionIndexes(questionIndex),
269
+ optionIndex,
270
+ );
271
+ }
272
+ if (optionIndex === preparedQuestion.otherOptionIndex && trimmedNote.length === 0) {
273
+ requestUiRerender();
274
+ return;
275
+ }
276
+ isNoteEditorOpen = false;
277
+ requestUiRerender();
278
+ return;
279
+ }
280
+
281
+ selectedOptionIndexesByQuestion[questionIndex] = [optionIndex];
282
+ if (optionIndex === preparedQuestion.otherOptionIndex && trimmedNote.length === 0) {
283
+ requestUiRerender();
284
+ return;
285
+ }
286
+
287
+ isNoteEditorOpen = false;
288
+ advanceToNextTabOrSubmit();
289
+ requestUiRerender();
290
+ };
291
+
292
+ const renderTabs = (): string => {
293
+ const tabParts: string[] = ["← "];
294
+ for (let questionIndex = 0; questionIndex < preparedQuestions.length; questionIndex++) {
295
+ const preparedQuestion = getPreparedQuestion(questionIndex);
296
+ const isActiveTab = questionIndex === activeTabIndex;
297
+ const isQuestionValid = isQuestionSelectionValid(
298
+ preparedQuestion,
299
+ getSelectedOptionIndexes(questionIndex),
300
+ noteByQuestionByOption[questionIndex] ?? [],
301
+ );
302
+ const statusIcon = isQuestionValid ? "■" : "□";
303
+ const tabLabel = ` ${statusIcon} ${preparedQuestion.tabLabel} `;
304
+ const styledTabLabel = isActiveTab
305
+ ? theme.bg("selectedBg", theme.fg("text", tabLabel))
306
+ : theme.fg(isQuestionValid ? "success" : "muted", tabLabel);
307
+ tabParts.push(`${styledTabLabel} `);
308
+ }
309
+
310
+ const isSubmitTabActive = activeTabIndex === submitTabIndex;
311
+ const canSubmit = isAllQuestionSelectionsValid();
312
+ const submitLabel = " ✓ Submit ";
313
+ const styledSubmitLabel = isSubmitTabActive
314
+ ? theme.bg("selectedBg", theme.fg("text", submitLabel))
315
+ : theme.fg(canSubmit ? "success" : "dim", submitLabel);
316
+ tabParts.push(`${styledSubmitLabel} →`);
317
+ return tabParts.join("");
318
+ };
319
+
320
+ const renderSubmitTab = (width: number, renderedLines: string[]): void => {
321
+ const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
322
+
323
+ addLine(theme.fg("accent", theme.bold(" Review answers")));
324
+ renderedLines.push("");
325
+
326
+ for (let questionIndex = 0; questionIndex < preparedQuestions.length; questionIndex++) {
327
+ const preparedQuestion = getPreparedQuestion(questionIndex);
328
+ const selection = buildSelectionForQuestion(
329
+ preparedQuestion,
330
+ getSelectedOptionIndexes(questionIndex),
331
+ noteByQuestionByOption[questionIndex] ?? [],
332
+ );
333
+ const value = formatSelectionForSubmitReview(selection, preparedQuestion.multi);
334
+ const isValid = isQuestionSelectionValid(
335
+ preparedQuestion,
336
+ getSelectedOptionIndexes(questionIndex),
337
+ noteByQuestionByOption[questionIndex] ?? [],
338
+ );
339
+ const statusIcon = isValid ? theme.fg("success", "●") : theme.fg("warning", "○");
340
+ addLine(` ${statusIcon} ${theme.fg("muted", `${preparedQuestion.tabLabel}:`)} ${theme.fg("text", value)}`);
341
+ }
342
+
343
+ renderedLines.push("");
344
+ if (isAllQuestionSelectionsValid()) {
345
+ addLine(theme.fg("success", " Press Enter to submit"));
346
+ } else {
347
+ const missingQuestions = preparedQuestions
348
+ .filter((preparedQuestion, questionIndex) =>
349
+ !isQuestionSelectionValid(
350
+ preparedQuestion,
351
+ getSelectedOptionIndexes(questionIndex),
352
+ noteByQuestionByOption[questionIndex] ?? [],
353
+ ),
354
+ )
355
+ .map((preparedQuestion) => preparedQuestion.tabLabel)
356
+ .join(", ");
357
+ addLine(theme.fg("warning", ` Complete required answers: ${missingQuestions}`));
358
+ }
359
+ addLine(theme.fg("dim", " ←/→ switch tabs • Esc cancel"));
360
+ };
361
+
362
+ const renderQuestionTab = (width: number, renderedLines: string[], questionIndex: number): void => {
363
+ const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
364
+ const preparedQuestion = getPreparedQuestion(questionIndex);
365
+ const cursorOptionIndex = getCursorOptionIndex(questionIndex);
366
+ const selectedOptionIndexes = getSelectedOptionIndexes(questionIndex);
367
+
368
+ appendWrappedTextLines(renderedLines, preparedQuestion.question, width, {
369
+ indent: 1,
370
+ formatLine: (line) => theme.fg("text", line),
371
+ });
372
+ const questionDescriptionMarkdown = descriptionMarkdownByQuestion[questionIndex];
373
+ if (questionDescriptionMarkdown) {
374
+ renderedLines.push("");
375
+ const descriptionLines = questionDescriptionMarkdown.render(Math.max(1, width - 1));
376
+ for (const descriptionLine of descriptionLines) {
377
+ addLine(` ${descriptionLine}`);
378
+ }
379
+ }
380
+ renderedLines.push("");
381
+
382
+ const activeEditingCursorIndex = isNoteEditorOpen
383
+ ? getLinearCursorIndexFromEditor(noteEditor)
384
+ : undefined;
385
+ for (let optionIndex = 0; optionIndex < preparedQuestion.options.length; optionIndex++) {
386
+ const optionLabel = preparedQuestion.options[optionIndex] ?? "";
387
+ const isCursorOption = optionIndex === cursorOptionIndex;
388
+ const isOptionSelected = selectedOptionIndexes.includes(optionIndex);
389
+ const isEditingThisOption = isNoteEditorOpen && isCursorOption;
390
+ const cursorPrefixText = isCursorOption ? "→ " : " ";
391
+ const cursorPrefix = isCursorOption ? theme.fg("accent", cursorPrefixText) : cursorPrefixText;
392
+ const markerText = preparedQuestion.multi
393
+ ? `${isOptionSelected ? "[x]" : "[ ]"} `
394
+ : `${isOptionSelected ? "●" : "○"} `;
395
+ const optionColor = isCursorOption ? "accent" : isOptionSelected ? "success" : "text";
396
+ const prefixWidth = visibleWidth(cursorPrefixText) + visibleWidth(markerText);
397
+ const wrappedInlineLabelLines = buildWrappedOptionLabelWithInlineNote(
398
+ optionLabel,
399
+ getQuestionNote(questionIndex, optionIndex),
400
+ isEditingThisOption,
401
+ Math.max(1, width - prefixWidth),
402
+ INLINE_NOTE_WRAP_PADDING,
403
+ isEditingThisOption ? activeEditingCursorIndex : undefined,
404
+ isEditingThisOption,
405
+ );
406
+ const continuationPrefix = " ".repeat(prefixWidth);
407
+ addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
408
+ for (const wrappedLine of wrappedInlineLabelLines.slice(1)) {
409
+ addLine(`${continuationPrefix}${theme.fg(optionColor, wrappedLine)}`);
410
+ }
411
+ }
412
+
413
+ renderedLines.push("");
414
+ if (isNoteEditorOpen) {
415
+ addLine(theme.fg("dim", " Typing note inline • Enter save note • Tab/Esc stop editing"));
416
+ } else {
417
+ if (preparedQuestion.multi) {
418
+ addLine(
419
+ theme.fg(
420
+ "dim",
421
+ " ↑↓ move • Space toggle/select • Enter next • Tab add note • ←/→ switch tabs • Esc cancel",
422
+ ),
423
+ );
424
+ } else {
425
+ addLine(
426
+ theme.fg("dim", " ↑↓ move • Space/Enter select • Tab add note • ←/→ switch tabs • Esc cancel"),
427
+ );
428
+ }
429
+ }
430
+ };
431
+
432
+ const render = (width: number): string[] => {
433
+ if (cachedRenderedLines && cachedRenderedWidth === width) return cachedRenderedLines;
434
+
435
+ const renderedLines: string[] = [];
436
+ const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
437
+
438
+ addLine(theme.fg("accent", "─".repeat(width)));
439
+ addLine(` ${renderTabs()}`);
440
+ renderedLines.push("");
441
+
442
+ if (activeTabIndex === submitTabIndex) {
443
+ renderSubmitTab(width, renderedLines);
444
+ } else {
445
+ renderQuestionTab(width, renderedLines, activeTabIndex);
446
+ }
447
+
448
+ addLine(theme.fg("accent", "─".repeat(width)));
449
+ cachedRenderedLines = renderedLines;
450
+ cachedRenderedWidth = width;
451
+ return renderedLines;
452
+ };
453
+
454
+ const handleInput = (data: string) => {
455
+ if (matchesKey(data, Key.ctrl("c"))) {
456
+ done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption));
457
+ return;
458
+ }
459
+
460
+ if (isNoteEditorOpen) {
461
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
462
+ isNoteEditorOpen = false;
463
+ requestUiRerender();
464
+ return;
465
+ }
466
+
467
+ const questionIndex = getActiveQuestionIndex();
468
+ const cursorOptionIndex = questionIndex == null ? 0 : getCursorOptionIndex(questionIndex);
469
+ const noteIsEmpty = questionIndex == null || getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0;
470
+ if (
471
+ noteIsEmpty &&
472
+ (matchesKey(data, Key.up) || matchesKey(data, Key.down) || matchesKey(data, Key.left) || matchesKey(data, Key.right))
473
+ ) {
474
+ isNoteEditorOpen = false;
475
+ } else {
476
+ noteEditor.handleInput(data);
477
+ requestUiRerender();
478
+ return;
479
+ }
480
+ }
481
+
482
+ if (matchesKey(data, Key.left)) {
483
+ activeTabIndex = (activeTabIndex - 1 + preparedQuestions.length + 1) % (preparedQuestions.length + 1);
484
+ const qIdx = getActiveQuestionIndex();
485
+ if (qIdx != null) {
486
+ const preparedQuestion = getPreparedQuestion(qIdx);
487
+ const optIdx = getCursorOptionIndex(qIdx);
488
+ if (preparedQuestion.options[optIdx] === OTHER_OPTION) {
489
+ openNoteEditorForActiveOption();
490
+ return;
491
+ }
492
+ }
493
+ requestUiRerender();
494
+ return;
495
+ }
496
+
497
+ if (matchesKey(data, Key.right)) {
498
+ activeTabIndex = (activeTabIndex + 1) % (preparedQuestions.length + 1);
499
+ const qIdx = getActiveQuestionIndex();
500
+ if (qIdx != null) {
501
+ const preparedQuestion = getPreparedQuestion(qIdx);
502
+ const optIdx = getCursorOptionIndex(qIdx);
503
+ if (preparedQuestion.options[optIdx] === OTHER_OPTION) {
504
+ openNoteEditorForActiveOption();
505
+ return;
506
+ }
507
+ }
508
+ requestUiRerender();
509
+ return;
510
+ }
511
+
512
+ if (activeTabIndex === submitTabIndex) {
513
+ if (matchesKey(data, Key.enter) && isAllQuestionSelectionsValid()) {
514
+ done(createTabsUiStateSnapshot(false, selectedOptionIndexesByQuestion, noteByQuestionByOption));
515
+ return;
516
+ }
517
+ if (matchesKey(data, Key.escape)) {
518
+ done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption));
519
+ }
520
+ return;
521
+ }
522
+
523
+ const questionIndex = activeTabIndex;
524
+ const preparedQuestion = getPreparedQuestion(questionIndex);
525
+
526
+ if (matchesKey(data, Key.up)) {
527
+ cursorOptionIndexByQuestion[questionIndex] = Math.max(0, getCursorOptionIndex(questionIndex) - 1);
528
+ if (preparedQuestion.options[getCursorOptionIndex(questionIndex)] === OTHER_OPTION) {
529
+ openNoteEditorForActiveOption();
530
+ return;
531
+ }
532
+ requestUiRerender();
533
+ return;
534
+ }
535
+
536
+ if (matchesKey(data, Key.down)) {
537
+ cursorOptionIndexByQuestion[questionIndex] = Math.min(
538
+ preparedQuestion.options.length - 1,
539
+ getCursorOptionIndex(questionIndex) + 1,
540
+ );
541
+ if (preparedQuestion.options[getCursorOptionIndex(questionIndex)] === OTHER_OPTION) {
542
+ openNoteEditorForActiveOption();
543
+ return;
544
+ }
545
+ requestUiRerender();
546
+ return;
547
+ }
548
+
549
+ if (matchesKey(data, Key.tab)) {
550
+ openNoteEditorForActiveOption();
551
+ return;
552
+ }
553
+
554
+ if (matchesKey(data, Key.space)) {
555
+ const cursorOptionIndex = getCursorOptionIndex(questionIndex);
556
+
557
+ if (preparedQuestion.multi) {
558
+ const currentlySelected = getSelectedOptionIndexes(questionIndex);
559
+ if (currentlySelected.includes(cursorOptionIndex)) {
560
+ selectedOptionIndexesByQuestion[questionIndex] = removeIndexFromSelection(currentlySelected, cursorOptionIndex);
561
+ } else {
562
+ selectedOptionIndexesByQuestion[questionIndex] = addIndexToSelection(currentlySelected, cursorOptionIndex);
563
+ }
564
+
565
+ if (
566
+ cursorOptionIndex === preparedQuestion.otherOptionIndex &&
567
+ getSelectedOptionIndexes(questionIndex).includes(cursorOptionIndex) &&
568
+ getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0
569
+ ) {
570
+ openNoteEditorForActiveOption();
571
+ return;
572
+ }
573
+
574
+ requestUiRerender();
575
+ return;
576
+ }
577
+
578
+ selectedOptionIndexesByQuestion[questionIndex] = [cursorOptionIndex];
579
+ if (
580
+ cursorOptionIndex === preparedQuestion.otherOptionIndex &&
581
+ getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0
582
+ ) {
583
+ openNoteEditorForActiveOption();
584
+ return;
585
+ }
586
+
587
+ requestUiRerender();
588
+ return;
589
+ }
590
+
591
+ if (matchesKey(data, Key.enter)) {
592
+ const cursorOptionIndex = getCursorOptionIndex(questionIndex);
593
+
594
+ if (preparedQuestion.multi) {
595
+ if (
596
+ cursorOptionIndex === preparedQuestion.otherOptionIndex &&
597
+ getSelectedOptionIndexes(questionIndex).includes(cursorOptionIndex) &&
598
+ getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0
599
+ ) {
600
+ openNoteEditorForActiveOption();
601
+ return;
602
+ }
603
+
604
+ advanceToNextTabOrSubmit();
605
+ requestUiRerender();
606
+ return;
607
+ }
608
+
609
+ selectedOptionIndexesByQuestion[questionIndex] = [cursorOptionIndex];
610
+ if (
611
+ cursorOptionIndex === preparedQuestion.otherOptionIndex &&
612
+ getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0
613
+ ) {
614
+ openNoteEditorForActiveOption();
615
+ return;
616
+ }
617
+
618
+ advanceToNextTabOrSubmit();
619
+ requestUiRerender();
620
+ return;
621
+ }
622
+
623
+ if (matchesKey(data, Key.escape)) {
624
+ done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption));
625
+ return;
626
+ }
627
+
628
+ const currentCursorOptIdx = getCursorOptionIndex(questionIndex);
629
+ if (preparedQuestion.options[currentCursorOptIdx] === OTHER_OPTION) {
630
+ openNoteEditorForActiveOption();
631
+ noteEditor.handleInput(data);
632
+ requestUiRerender();
633
+ return;
634
+ }
635
+ };
636
+
637
+ return {
638
+ focused: true,
639
+ render,
640
+ invalidate: () => {
641
+ cachedRenderedLines = undefined;
642
+ cachedRenderedWidth = undefined;
643
+ },
644
+ handleInput,
645
+ };
646
+ }, options?.overlay ? { overlay: true } : undefined);
647
+
648
+ if (!result || result.cancelled) {
649
+ return {
650
+ cancelled: true,
651
+ selections: preparedQuestions.map(() => ({ selectedOptions: [] } satisfies AskSelection)),
652
+ };
653
+ }
654
+
655
+ const selections = preparedQuestions.map((preparedQuestion, questionIndex) =>
656
+ buildSelectionForQuestion(
657
+ preparedQuestion,
658
+ result.selectedOptionIndexesByQuestion[questionIndex] ?? [],
659
+ result.noteByQuestionByOption[questionIndex] ?? Array(preparedQuestion.options.length).fill(""),
660
+ ),
661
+ );
662
+
663
+ return { cancelled: result.cancelled, selections };
664
+ }