pi-ask-questions 0.2.0 → 0.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/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ### Changed
11
+
12
+ - Tabs preserve unfinished drafts, caret positions, and option cursors when revisiting questions.
13
+ - Multiline extra answers support arrow-key editing between lines; PageUp and PageDown navigate long option lists.
14
+ - Questionnaires show progress, live selection counts, and optional-question hints. The review tab preserves answer summaries while displaying specific validation errors.
15
+
16
+ ### Fixed
17
+
18
+ - Invalid selections above the maximum are shown as partially answered instead of complete.
19
+ - Validation feedback clears as answers become valid and does not carry over to the next question.
20
+ - Cancellation handles signals aborted before component creation, and disposed components ignore late input.
21
+ - Selecting a single-choice option with Space or a number key now waits for Enter, allowing an extra typed answer alongside the selected option. Confirming preserves the selection even after moving the cursor.
22
+ - Selecting an already selected single-choice option clears it while preserving the typed answer, so presets can be replaced with text alone.
23
+ - Large pasted answers retain their full text instead of returning an editor paste marker, including when submitted through the review tab.
24
+ - Extra typed answers support backslash + Enter for newlines and remain editable after a validation error.
25
+ - Editor undo history is isolated per question so Undo cannot copy an answer from another question.
26
+ - RPC option dialogs support selecting an option and adding text before choosing Done.
27
+ - Selection-limit warnings remain visible when an extra option cannot be selected.
28
+ - Questionnaire rendering recalculates when the terminal width changes.
29
+
30
+ ## [0.2.1] - 2026-10-15
31
+
32
+ ### Fixed
33
+
34
+ - A background questionnaire (`noWait: true`) that fails to open now reports the failure to the user with an error notification instead of failing silently. The background slot is still released so later calls can open a new questionnaire.
35
+
36
+ ### Documentation
37
+
38
+ - Documented the `pending` shape of `structuredContent`: while a background questionnaire is open, each `answers[]` entry is a stub with an empty `answers` array, so consumers must branch on `pending` before reading answers.
39
+
8
40
  ## [0.2.0] - 2026-10-15
9
41
 
10
42
  ### Changed
package/README.md CHANGED
@@ -44,23 +44,23 @@ Once installed, run `/ask-demo` to preview the UI, or just let the model call `a
44
44
 
45
45
  ## Features
46
46
 
47
- - **Tabs** — every question gets a tab, with answered state shown by color (green = answered, accent = partially answered, muted = untouched). Move with `Tab` / `Shift+Tab`.
48
- - **Single-select** radio options with optional descriptions.
49
- - **Multi-select** checkboxes (`multiple: true`), including `min` / `max` validation, `A` to select all, and `C` to clear.
50
- - **Free-form typing** — a question without options is a plain text prompt. Option questions can also hold one typed answer unless `allowOther: false`.
47
+ - **Tabs** — every question gets a tab, with answered state shown by color (green = answered, accent = partially answered, muted = untouched) and a count of questions ready to submit. Move with `Tab` / `Shift+Tab`; each tab remembers your draft, option cursor, and text caret.
48
+ - **Single-select** radio options with optional descriptions. Select an option, add extra text if needed, then confirm with `Enter`.
49
+ - **Multi-select** checkboxes (`multiple: true`), including `min` / `max` validation, a live answer count, `A` to select all, and `C` to clear. Typed answers count toward the selection bounds.
50
+ - **Free-form typing** — a question without options is a plain text prompt. Option questions can also hold one typed answer alongside the selected option(s) unless `allowOther: false`.
51
51
  - **Inline editing** — move the cursor onto “Type something…” and type; the editor appears directly under the row with the options still visible. No separate menu, and the answer can be edited again in place.
52
52
  - **One typed answer per question** — typed answers are never duplicated; editing replaces the previous text.
53
53
  - **Optional questions** (`required: false`) that can be skipped, plus preset answers (`selected: true`) and defaults.
54
- - **Review tab** — see everything before submitting; jump back to any question to edit it.
54
+ - **Review tab** — see your answers and specific validation errors together before submitting; jump back to any question to edit it.
55
55
  - **Validation** — required answers and `min`/`max` selection counts are enforced before submit.
56
56
  - **Background mode** — set `noWait: true` and the tool returns immediately while the questionnaire stays open; the answers arrive later as a follow-up message.
57
57
  - **Safe cancel** — `Esc` arms cancellation, a second `Esc` or `Ctrl+C` cancels immediately.
58
- - **Scrollable option lists** — long lists show “n more above / below” indicators.
58
+ - **Scrollable option lists** — long lists show “n more above / below” indicators. Use `PageUp` / `PageDown` to move eight rows at a time.
59
59
  - **Structured results** — the tool returns text for the model and a machine-readable `structuredContent` payload for codemode scripts.
60
60
 
61
61
  ## How the model uses it
62
62
 
63
- `ask_user` accepts one or more questions. A single-select question resolves immediately; everything else flows into tabs and a submit step.
63
+ `ask_user` accepts one or more questions. `Space` or `1`–`9` selects an option without submitting, so you can move to “Type something…” and add text. Select an already selected option again to clear it and keep just the typed answer. `Enter` confirms the answer. A questionnaire with one question then resolves; multiple questions flow into tabs and a review step.
64
64
 
65
65
  ```json
66
66
  {
@@ -124,7 +124,7 @@ Notes: (skipped)
124
124
  | `questions[].options[].label` | string | Display label. |
125
125
  | `questions[].options[].description` | string | Explanation shown below the label. |
126
126
  | `questions[].options[].selected` | boolean | Preselect the option. |
127
- | `questions[].multiple` | boolean | Allow more than one selection. |
127
+ | `questions[].multiple` | boolean | Allow more than one listed option. A typed answer can accompany either mode. |
128
128
  | `questions[].allowOther` | boolean | Show the typed-answer entry. Default `true`. |
129
129
  | `questions[].required` | boolean | Require an answer before submit. Default `true`. |
130
130
  | `questions[].min` / `max` | integer | Selection bounds for multi-select. |
@@ -132,19 +132,28 @@ Notes: (skipped)
132
132
  | `questions[].otherLabel` | string | Custom label for the typed-answer entry. |
133
133
  | `noWait` | boolean | Show the questionnaire in the background and return immediately (interactive TUI only). The answers arrive as a follow-up message. Only one background questionnaire can be open at a time. |
134
134
 
135
+ ### Structured results
136
+
137
+ `structuredContent` mirrors the answers for codemode scripts: `cancelled`, an optional `pending`, and `answers[]` with `{ id, question, skipped, answers: [{ value, label, custom }] }`.
138
+
139
+ While a background questionnaire is open, `pending` is `true` and each `answers[]` entry is a stub with an empty `answers` array. Branch on `pending` before reading them; the real answers arrive as a follow-up message.
140
+
135
141
  ### Keyboard reference
136
142
 
137
143
  | Keys | Action |
138
144
  | --- | --- |
139
145
  | `Tab` / `Shift+Tab` | Switch tabs |
140
146
  | `↑` / `↓`, `Home` / `End` | Move the option cursor |
141
- | `Space` | Toggle a checkbox / select a radio option |
142
- | `1`–`9` | Jump to an option (toggles it in multi-select) |
147
+ | `PageUp` / `PageDown` | Move a page through the option list |
148
+ | `Space` | Select or clear the highlighted option |
149
+ | `1`–`9` | Select or clear a numbered option, without submitting |
143
150
  | `A` / `C` | Select all / clear (multi-select) |
144
151
  | `Enter` | Confirm the question, or submit on the review tab |
145
152
  | `Esc` | Arm cancel; `Esc` twice cancels |
146
153
  | `Ctrl+C` | Cancel immediately |
147
- | `\` + `Enter` | Newline in a free-form answer |
154
+ | `\` + `Enter` | Newline in a free-form or extra typed answer |
155
+
156
+ In a multiline extra answer, `↑` / `↓` edits between text lines. Moving up from the first line or down from the last line returns to the option list.
148
157
 
149
158
  ## Modes
150
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ask-questions",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Interactive questionnaires for Pi: tabs, multi-select, free-form typing, validation, and structured answers.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/extension.ts CHANGED
@@ -39,9 +39,9 @@ const TOOL_DESCRIPTION = `Ask the user one or more questions and wait for their
39
39
  The interactive UI supports:
40
40
  - Tabs: every question gets its own tab (Tab / Shift+Tab and arrow keys switch).
41
41
  - Single-select and multi-select questions (set multiple: true).
42
- - Free-form typing: a question with no options is a text prompt. Option questions can also hold one typed answer unless allowOther is false, and the answer is edited inline.
42
+ - Free-form typing: a question with no options is a text prompt. Option questions can also hold one typed answer alongside the selected option(s) unless allowOther is false, and the answer is edited inline.
43
43
  - Optional questions (required: false) that can be skipped, preselected options (selected: true), and min/max selection counts.
44
- - Number keys 1-9 jump to options; A selects all; C clears a multi-select.
44
+ - Space or number keys 1-9 select or clear options without submitting; Enter confirms. A selects all; C clears a multi-select.
45
45
  - Set noWait: true to open the questionnaire in the background: the tool returns immediately and the answers arrive later as a follow-up message.
46
46
 
47
47
  Guidelines:
@@ -175,8 +175,19 @@ export default function askQuestionsExtension(pi: ExtensionAPI): void {
175
175
  { triggerTurn: true, deliverAs: "followUp" },
176
176
  );
177
177
  })
178
- .catch(() => {
179
- // The questionnaire could not be shown; there is nothing to deliver.
178
+ .catch((error: unknown) => {
179
+ // The questionnaire could not be shown. The tool result already told
180
+ // the model it was pending, so surface the failure to the user
181
+ // instead of leaving everyone waiting for answers that never come.
182
+ const reason = error instanceof Error ? error.message : String(error);
183
+ try {
184
+ ctx.ui.notify(
185
+ `ask_user could not show the background questionnaire: ${reason}`,
186
+ "error",
187
+ );
188
+ } catch {
189
+ // Reporting is best-effort; the background slot still clears.
190
+ }
180
191
  })
181
192
  .finally(() => {
182
193
  if (backgroundController === controller) {
package/src/fallback.ts CHANGED
@@ -33,10 +33,8 @@ export async function runFallbackSurvey(
33
33
  let answered: boolean;
34
34
  if (question.options.length === 0) {
35
35
  answered = await askFreeForm(ui, question, answers, dialogOptions);
36
- } else if (question.multiple) {
37
- answered = await askMultiple(ui, question, answers, dialogOptions);
38
36
  } else {
39
- answered = await askSingle(ui, question, answers, dialogOptions);
37
+ answered = await askOptions(ui, question, answers, dialogOptions);
40
38
  }
41
39
  if (!answered) {
42
40
  return done(true);
@@ -72,57 +70,7 @@ async function askFreeForm(
72
70
  }
73
71
  }
74
72
 
75
- async function askSingle(
76
- ui: FallbackUI,
77
- question: NormalizedQuestion,
78
- answers: Map<string, QuestionAnswer>,
79
- options: DialogOptions,
80
- ): Promise<boolean> {
81
- const customLabel = `${question.otherLabel} (type an answer)`;
82
- const skipLabel = "Skip this question";
83
- while (true) {
84
- const optionLabels = question.options.map((option, index) => `${index + 1}. ${option.label}`);
85
- const choices = [...optionLabels];
86
- if (question.allowOther) {
87
- choices.push(customLabel);
88
- }
89
- if (!question.required) {
90
- choices.push(skipLabel);
91
- }
92
- const choice = await ui.select(question.question, choices, options);
93
- if (choice === undefined) {
94
- return false;
95
- }
96
- if (choice === skipLabel) {
97
- answers.set(question.id, makeAnswer(question, [], true));
98
- return true;
99
- }
100
- if (choice === customLabel) {
101
- const typed = await ui.input(question.question, question.placeholder ?? "", options);
102
- if (typed === undefined) {
103
- return false;
104
- }
105
- const value = typed.trim();
106
- if (value.length === 0) {
107
- ui.notify("Type an answer first.", "warning");
108
- continue;
109
- }
110
- answers.set(question.id, makeAnswer(question, [{ value, label: value, custom: true }]));
111
- return true;
112
- }
113
- const index = choices.indexOf(choice);
114
- if (index >= 0 && index < question.options.length) {
115
- const option = question.options[index]!;
116
- answers.set(
117
- question.id,
118
- makeAnswer(question, [{ value: option.value, label: option.label, custom: false }]),
119
- );
120
- return true;
121
- }
122
- }
123
- }
124
-
125
- async function askMultiple(
73
+ async function askOptions(
126
74
  ui: FallbackUI,
127
75
  question: NormalizedQuestion,
128
76
  answers: Map<string, QuestionAnswer>,
@@ -130,7 +78,7 @@ async function askMultiple(
130
78
  ): Promise<boolean> {
131
79
  const selected = new Set<number>();
132
80
  question.options.forEach((option, index) => {
133
- if (option.selected) {
81
+ if (option.selected && (question.multiple || selected.size === 0)) {
134
82
  selected.add(index);
135
83
  }
136
84
  });
@@ -140,9 +88,12 @@ async function askMultiple(
140
88
  const skipLabel = "Skip this question";
141
89
 
142
90
  while (true) {
143
- const optionLabels = question.options.map(
144
- (option, index) => `${selected.has(index) ? "[x]" : "[ ]"} ${index + 1}. ${option.label}`,
145
- );
91
+ const optionLabels = question.options.map((option, index) => {
92
+ const marker = question.multiple
93
+ ? (selected.has(index) ? "[x]" : "[ ]")
94
+ : (selected.has(index) ? "(•)" : "( )");
95
+ return `${marker} ${index + 1}. ${option.label}`;
96
+ });
146
97
  const choices = [...optionLabels, doneLabel];
147
98
  if (question.allowOther) {
148
99
  choices.push(customLabel);
@@ -164,12 +115,9 @@ async function askMultiple(
164
115
  return false;
165
116
  }
166
117
  const value = typed.trim();
167
- if (value.length === 0) {
168
- ui.notify("Type an answer first.", "warning");
169
- continue;
170
- }
171
- // A question holds at most one typed answer; editing replaces it.
172
- custom = { value, label: value, custom: true };
118
+ // A question holds at most one typed answer; editing replaces it,
119
+ // and clearing it preserves the listed selection.
120
+ custom = value ? { value, label: value, custom: true } : undefined;
173
121
  continue;
174
122
  }
175
123
  if (choice === doneLabel) {
@@ -184,12 +132,18 @@ async function askMultiple(
184
132
  ui.notify(error, "warning");
185
133
  continue;
186
134
  }
187
- answers.set(question.id, makeAnswer(question, entries));
135
+ answers.set(question.id, makeAnswer(question, entries, !question.required && entries.length === 0));
188
136
  return true;
189
137
  }
190
138
  const index = choices.indexOf(choice);
191
139
  if (index >= 0 && index < question.options.length) {
192
- if (selected.has(index)) {
140
+ if (!question.multiple) {
141
+ const wasSelected = selected.has(index);
142
+ selected.clear();
143
+ if (!wasSelected) {
144
+ selected.add(index);
145
+ }
146
+ } else if (selected.has(index)) {
193
147
  selected.delete(index);
194
148
  } else {
195
149
  selected.add(index);
package/src/questions.ts CHANGED
@@ -128,8 +128,12 @@ export function answerError(
128
128
  if (entries.length === 0) {
129
129
  return question.required ? "This question needs an answer." : null;
130
130
  }
131
- if (!question.multiple && entries.length > 1) {
132
- return "Only one answer is allowed.";
131
+ const customCount = entries.filter((entry) => entry.custom).length;
132
+ if (customCount > 1) {
133
+ return "Only one typed answer is allowed.";
134
+ }
135
+ if (!question.multiple && entries.length - customCount > 1) {
136
+ return "Only one option is allowed.";
133
137
  }
134
138
  if (question.multiple) {
135
139
  const min = question.min ?? (question.required ? 1 : 0);
@@ -167,7 +171,7 @@ export function answerStatus(
167
171
  if (entries.length === 0) {
168
172
  return question.required ? "unanswered" : "skipped";
169
173
  }
170
- if (question.multiple && question.min !== undefined && entries.length < question.min) {
174
+ if (answerError(question, answer)) {
171
175
  return "partial";
172
176
  }
173
177
  return "complete";
package/src/schema.ts CHANGED
@@ -55,13 +55,13 @@ export const QuestionSchema = Type.Object({
55
55
  ),
56
56
  multiple: Type.Optional(
57
57
  Type.Boolean({
58
- description: "Allow multiple selections (default: false).",
58
+ description: "Allow multiple listed options (default: false). One typed answer can accompany either mode.",
59
59
  }),
60
60
  ),
61
61
  allowOther: Type.Optional(
62
62
  Type.Boolean({
63
63
  description:
64
- 'Show a "Type something…" entry so the user can type a custom answer (default: true).',
64
+ 'Show a "Type something…" entry for one typed answer, alone or alongside selected options (default: true).',
65
65
  }),
66
66
  ),
67
67
  required: Type.Optional(
@@ -23,8 +23,9 @@
23
23
  * Keyboard contract:
24
24
  * - Tab / Shift+Tab switch tabs.
25
25
  * - ↑/↓ move the cursor; Home/End jump to the first/last row.
26
- * - Space toggles a multiple-choice option; Enter confirms the question.
27
- * - 1-9 jumps to an option; on multiple-choice it toggles it.
26
+ * - PageUp/PageDown move through long option lists.
27
+ * - Space selects or toggles an option; Enter confirms the question.
28
+ * - 1-9 jumps to an option and toggles it without confirming the question.
28
29
  * - A selects all options, C clears the selection (multiple-choice only).
29
30
  * - Moving the cursor onto "Type something…" starts editing immediately;
30
31
  * the inline editor is always there, no separate menu.
@@ -115,11 +116,16 @@ export function createQuestionnaireComponent(
115
116
  let notice: { text: string; kind: NoticeKind } | undefined;
116
117
  let editorText = "";
117
118
  let cachedLines: string[] | undefined;
119
+ let cachedWidth: number | undefined;
118
120
  let finished = false;
119
121
  let componentFocused = false;
120
122
  let suppressEditorEvents = false;
121
123
 
122
124
  const answers = new Map<string, QuestionAnswer>();
125
+ const editors = new Map<NormalizedQuestion | undefined, Editor>();
126
+ const cursors = new Map<NormalizedQuestion | undefined, number>();
127
+ let preparedQuestion: NormalizedQuestion | undefined;
128
+ let editor: Editor;
123
129
 
124
130
  // ── State helpers ────────────────────────────────────────────────────
125
131
 
@@ -272,8 +278,25 @@ export function createQuestionnaireComponent(
272
278
  }
273
279
 
274
280
  function prepareQuestion(question: NormalizedQuestion | undefined): void {
275
- cursor = 0;
276
- setEditorForQuestion(question);
281
+ if (editor) {
282
+ cursors.set(preparedQuestion, cursor);
283
+ editor.focused = false;
284
+ }
285
+ let nextEditor = editors.get(question);
286
+ if (!nextEditor) {
287
+ nextEditor = createEditor();
288
+ editors.set(question, nextEditor);
289
+ }
290
+ editor = nextEditor;
291
+ preparedQuestion = question;
292
+ cursor = cursors.get(question) ?? Math.max(0, question?.options.findIndex((option) => option.selected) ?? 0);
293
+ // Existing editors own the raw draft, caret, and undo history. Loading
294
+ // the trimmed answer again would discard spaces and reset the caret.
295
+ if (!cursors.has(question)) {
296
+ setEditorForQuestion(question);
297
+ } else {
298
+ editorText = editor.getText();
299
+ }
277
300
  syncEditorFocus();
278
301
  }
279
302
 
@@ -302,6 +325,7 @@ export function createQuestionnaireComponent(
302
325
 
303
326
  function advance(): void {
304
327
  cancelArmed = false;
328
+ notice = undefined;
305
329
  if (questions.length === 1) {
306
330
  finish(false);
307
331
  return;
@@ -367,13 +391,14 @@ export function createQuestionnaireComponent(
367
391
  }
368
392
  if (row.kind === "skip") {
369
393
  answers.set(question.id, makeAnswer(question, [], true));
394
+ setText("");
370
395
  advance();
371
396
  return;
372
397
  }
373
398
  if (question.multiple) {
374
399
  if (toggle) {
375
- setEntries(question, toggleOption(question, row.option));
376
400
  notice = undefined;
401
+ setEntries(question, toggleOption(question, row.option));
377
402
  refresh();
378
403
  return;
379
404
  }
@@ -386,7 +411,18 @@ export function createQuestionnaireComponent(
386
411
  advance();
387
412
  return;
388
413
  }
389
- setEntries(question, [optionEntry(row.option)]);
414
+ const entries = getAnswer(question).entries;
415
+ const custom = entries.filter((entry) => entry.custom);
416
+ if (toggle && isOptionSelected(question, row.option)) {
417
+ setEntries(question, custom);
418
+ } else if (toggle || entries.length === 0) {
419
+ setEntries(question, [optionEntry(row.option), ...custom]);
420
+ }
421
+ if (toggle) {
422
+ notice = undefined;
423
+ refresh();
424
+ return;
425
+ }
390
426
  advance();
391
427
  }
392
428
 
@@ -454,19 +490,17 @@ export function createQuestionnaireComponent(
454
490
  }
455
491
 
456
492
  function handleCustomRow(data: string, question: NormalizedQuestion): void {
457
- if (matchesKey(data, Key.up)) {
493
+ // Keep single-line row navigation quick, while allowing multiline
494
+ // answers to use arrow keys within the editor.
495
+ if (matchesKey(data, Key.up) && editor.getCursor().line === 0) {
458
496
  moveCursor(-1, question);
459
497
  return;
460
498
  }
461
- if (matchesKey(data, Key.down)) {
499
+ if (matchesKey(data, Key.down) && editor.getCursor().line === editor.getLines().length - 1) {
462
500
  moveCursor(1, question);
463
501
  return;
464
502
  }
465
- if (matchesKey(data, Key.enter)) {
466
- activateRow(false);
467
- return;
468
- }
469
- // Left/right and everything else edit the inline text.
503
+ // Let the editor handle submission, paste expansion, and newline shortcuts.
470
504
  editor.handleInput(data);
471
505
  refresh();
472
506
  }
@@ -487,6 +521,13 @@ export function createQuestionnaireComponent(
487
521
  return;
488
522
  }
489
523
  if (rows.length > 0) {
524
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.pageDown)) {
525
+ const delta = matchesKey(data, Key.pageUp) ? -MAX_VISIBLE_ROWS : MAX_VISIBLE_ROWS;
526
+ cursor = Math.max(0, Math.min(rows.length - 1, cursor + delta));
527
+ syncEditorFocus();
528
+ refresh();
529
+ return;
530
+ }
490
531
  if (matchesKey(data, Key.up)) {
491
532
  moveCursor(-1, question);
492
533
  return;
@@ -539,7 +580,7 @@ export function createQuestionnaireComponent(
539
580
  if (index >= 0) {
540
581
  cursor = index;
541
582
  syncEditorFocus();
542
- activateRow(question.multiple);
583
+ activateRow(true);
543
584
  }
544
585
  }
545
586
  }
@@ -557,6 +598,7 @@ export function createQuestionnaireComponent(
557
598
  if (!escape && cancelArmed) {
558
599
  cancelArmed = false;
559
600
  notice = undefined;
601
+ refresh();
560
602
  }
561
603
  if (escape) {
562
604
  if (cancelArmed) {
@@ -622,6 +664,8 @@ export function createQuestionnaireComponent(
622
664
  parts.push(theme.fg(ready ? "success" : "dim", submitText));
623
665
  }
624
666
  add(" ", parts.join(" "));
667
+ const readyCount = questions.filter((question) => !answerError(question, answers.get(question.id))).length;
668
+ add(" ", theme.fg("dim", `${readyCount} of ${questions.length} questions ready`));
625
669
  }
626
670
 
627
671
  function renderRows(add: AddLine, lines: string[], renderWidth: number, question: NormalizedQuestion): void {
@@ -680,10 +724,15 @@ export function createQuestionnaireComponent(
680
724
  if (question.header) {
681
725
  add(" ", theme.fg("muted", question.header));
682
726
  }
727
+ if (!question.required) {
728
+ const hint = question.options.length === 0 ? "leave empty to skip" : "choose Skip this question to skip";
729
+ add(" ", theme.fg("dim", `Optional • ${hint}`));
730
+ }
683
731
  if (question.multiple) {
684
- const min = question.min !== undefined ? `at least ${question.min}` : "one or more";
732
+ const min = question.min !== undefined ? `at least ${question.min}` : question.required ? "one or more" : "any number";
685
733
  const max = question.max !== undefined ? `, up to ${question.max}` : "";
686
- add(" ", theme.fg("dim", `Select ${min}${max} • Space toggles`));
734
+ const count = getAnswer(question).entries.length;
735
+ add(" ", theme.fg("dim", `Select ${min}${max} • ${count} ${count === 1 ? "answer" : "answers"} selected • Space toggles`));
687
736
  }
688
737
  add(" ", "");
689
738
  if (question.options.length === 0) {
@@ -702,7 +751,7 @@ export function createQuestionnaireComponent(
702
751
  add(" ", theme.bold(theme.fg("text", "Review your answers")));
703
752
  add(" ", "");
704
753
  const errors = collectAnswerErrors(questions, answers);
705
- const invalid = new Set(errors.map((error) => error.id));
754
+ const invalid = new Map(errors.map((error) => [error.id, error.message]));
706
755
  for (let index = 0; index < questions.length; index += 1) {
707
756
  const question = questions[index]!;
708
757
  const answer = getAnswer(question);
@@ -711,10 +760,8 @@ export function createQuestionnaireComponent(
711
760
  let summary: string;
712
761
  if (answer.skipped) {
713
762
  summary = theme.fg("dim", "(skipped)");
714
- } else if (invalid.has(question.id)) {
715
- summary = theme.fg("warning", "needs an answer");
716
763
  } else if (answer.entries.length === 0) {
717
- summary = theme.fg("dim", "(optional, skipped)");
764
+ summary = theme.fg(question.required ? "warning" : "dim", question.required ? "(unanswered)" : "(optional, skipped)");
718
765
  } else {
719
766
  summary = answer.entries
720
767
  .map((entry) =>
@@ -723,6 +770,10 @@ export function createQuestionnaireComponent(
723
770
  .join(theme.fg("dim", ", "));
724
771
  }
725
772
  add(marker, `${theme.fg("muted", `${question.label}: `)}${summary}`);
773
+ const error = invalid.get(question.id);
774
+ if (error) {
775
+ add(" ", theme.fg("warning", error));
776
+ }
726
777
  }
727
778
  add(" ", "");
728
779
  const ready = errors.length === 0;
@@ -737,7 +788,7 @@ export function createQuestionnaireComponent(
737
788
  " ",
738
789
  theme.fg(
739
790
  "warning",
740
- `${count} ${count === 1 ? "question still needs" : "questions still need"} an answer`,
791
+ `${count} ${count === 1 ? "question needs" : "questions need"} attention`,
741
792
  ),
742
793
  );
743
794
  }
@@ -752,19 +803,19 @@ export function createQuestionnaireComponent(
752
803
  return "Tab switch • Esc cancel";
753
804
  }
754
805
  if (question.options.length === 0) {
755
- return "Enter submit • \\+Enter newline • Esc cancel";
806
+ return `Enter ${showTabs ? "confirm" : "submit"} • \\+Enter newline${showTabs ? " • Tab switch" : ""} • Esc cancel`;
756
807
  }
757
808
  if (cursorRow(question)?.kind === "custom") {
758
- return "Type your answer • Enter confirm • ↑↓ move • Tab switch • Esc cancel";
809
+ return "Type your answer • Enter confirm • \\+Enter newline • ↑↓ edit/move • Esc cancel";
759
810
  }
760
811
  if (question.multiple) {
761
812
  return "↑↓ move • Space toggle • A all • C clear • 1-9 jump • Enter confirm • Tab switch";
762
813
  }
763
- return "↑↓ move • Space/Enter select • 1-9 jump • Tab switch • Esc cancel";
814
+ return "↑↓ move • Space select/clear • 1-9 select/clear • Enter confirm • Tab switch • Esc cancel";
764
815
  }
765
816
 
766
817
  function render(width: number): string[] {
767
- if (cachedLines) {
818
+ if (cachedLines && cachedWidth === width) {
768
819
  return cachedLines;
769
820
  }
770
821
  const renderWidth = Math.max(1, width);
@@ -806,6 +857,7 @@ export function createQuestionnaireComponent(
806
857
  lines.push(theme.fg("borderAccent", "─".repeat(renderWidth)));
807
858
 
808
859
  cachedLines = lines;
860
+ cachedWidth = width;
809
861
  return lines;
810
862
  }
811
863
 
@@ -821,46 +873,58 @@ export function createQuestionnaireComponent(
821
873
  noMatch: (text) => theme.fg("warning", text),
822
874
  },
823
875
  };
824
- const editor = new Editor(tui, editorTheme);
825
- editor.onChange = (value) => {
826
- editorText = value;
827
- if (!suppressEditorEvents) {
876
+ function createEditor(): Editor {
877
+ const questionEditor = new Editor(tui, editorTheme);
878
+ questionEditor.onChange = (value) => {
879
+ editorText = value;
880
+ if (!suppressEditorEvents) {
881
+ applyEditorText(questionEditor.getExpandedText());
882
+ const question = currentQuestion();
883
+ if (question && !answerError(question, answers.get(question.id))) {
884
+ notice = undefined;
885
+ }
886
+ }
887
+ cachedLines = undefined;
888
+ };
889
+ questionEditor.onSubmit = (value) => {
890
+ const question = currentQuestion();
891
+ if (!question) {
892
+ return;
893
+ }
894
+ // Submission clears the editor first. Restore the submitted text so a
895
+ // validation error leaves the answer available for further editing.
896
+ setText(value);
828
897
  applyEditorText(value);
829
- }
830
- cachedLines = undefined;
831
- };
832
- editor.onSubmit = (value) => {
833
- const question = currentQuestion();
834
- if (!question) {
835
- return;
836
- }
837
- // submitValue() clears the editor and fires onChange("") before calling
838
- // onSubmit, so re-apply the submitted text before validating.
839
- applyEditorText(value);
840
- const error = answerError(question, answers.get(question.id));
841
- if (error) {
842
- notice = { text: error, kind: "warning" };
843
- refresh();
844
- return;
845
- }
846
- advance();
847
- };
898
+ const error = answerError(question, answers.get(question.id));
899
+ if (error) {
900
+ notice = { text: error, kind: "warning" };
901
+ refresh();
902
+ return;
903
+ }
904
+ advance();
905
+ };
906
+ return questionEditor;
907
+ }
848
908
 
849
909
  prepareQuestion(questions[0]);
850
910
 
851
- const onAbort = (): void => {
852
- finished = true;
853
- done(buildResult(questions, answers, true));
854
- };
911
+ const onAbort = (): void => finish(true);
855
912
  options.signal?.addEventListener("abort", onAbort, { once: true });
913
+ if (options.signal?.aborted) {
914
+ onAbort();
915
+ }
856
916
 
857
917
  return {
858
918
  get focused(): boolean {
859
919
  return componentFocused;
860
920
  },
861
921
  set focused(value: boolean) {
922
+ if (componentFocused === value) {
923
+ return;
924
+ }
862
925
  componentFocused = value;
863
926
  syncEditorFocus();
927
+ refresh();
864
928
  },
865
929
  render,
866
930
  handleInput,
@@ -868,6 +932,8 @@ export function createQuestionnaireComponent(
868
932
  cachedLines = undefined;
869
933
  },
870
934
  dispose: () => {
935
+ finished = true;
936
+ editor.focused = false;
871
937
  options.signal?.removeEventListener("abort", onAbort);
872
938
  },
873
939
  };