pi-ask-questions 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ 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
+ ## [0.2.1] - 2026-10-15
9
+
10
+ ### Fixed
11
+
12
+ - 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.
13
+
14
+ ### Documentation
15
+
16
+ - 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.
17
+
18
+ ## [0.2.0] - 2026-10-15
19
+
20
+ ### Changed
21
+
22
+ - Removed decorative icons from tabs, option rows, the review tab, and tool output. Only the selection markers (`[x]` / `[ ]` / `(•)`) and the cursor remain.
23
+ - Option questions now hold at most one typed answer. The inline editor appears directly under the “Type something…” row with the options still visible, and moving onto that row starts editing immediately instead of opening a separate view.
24
+ - Free-form questions always resolve to a single typed answer; `multiple`, `min`, and `max` no longer apply when a question has no options.
25
+
26
+ ### Added
27
+
28
+ - `noWait` parameter: open the questionnaire in the background and return immediately, delivering the answers later as a follow-up message. Only one background questionnaire can be open at a time; it is cancelled safely on session shutdown.
29
+
8
30
  ## [0.1.0] - 2026-10-15
9
31
 
10
32
  ### Added
package/README.md CHANGED
@@ -8,7 +8,7 @@ Interactive questionnaires for [Pi](https://pi.dev): the model runs an `ask_user
8
8
  ```text
9
9
  ────────────────────────────────────────────────────────────
10
10
  ask_user demo
11
- ✓ Scope ◐ Release · Notes Submit
11
+ Scope Release Notes Submit
12
12
  ────────────────────────────────────────────────────────────
13
13
  Which parts of the project should this change touch?
14
14
  Select one or more • Space toggles
@@ -19,7 +19,10 @@ Interactive questionnaires for [Pi](https://pi.dev): the model runs an `ask_user
19
19
  The interactive questionnaire renderer
20
20
  [ ] 3. Documentation
21
21
  [x] 4. Tests
22
- ✎ Type something…
22
+ Type something…
23
+ ──────────────────────────────────────────────
24
+ custom answer
25
+ ──────────────────────────────────────────────
23
26
 
24
27
  ↑↓ move • Space toggle • A all • C clear • 1-9 jump • Enter confirm
25
28
  ────────────────────────────────────────────────────────────
@@ -41,16 +44,18 @@ Once installed, run `/ask-demo` to preview the UI, or just let the model call `a
41
44
 
42
45
  ## Features
43
46
 
44
- - **Tabs** — every question gets a tab with live status (`○` unanswered, `◐` partially answered, `✓` answered, `·` skipped). Move with `Tab` / `Shift+Tab` or `←` / `→`.
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`.
45
48
  - **Single-select** radio options with optional descriptions.
46
49
  - **Multi-select** checkboxes (`multiple: true`), including `min` / `max` validation, `A` to select all, and `C` to clear.
47
- - **Free-form typing** — a question without options is a plain text prompt. Typed answers are supported on option questions too via the built-in “Type something…” entry, and multiple-choice questions can collect several typed answers.
48
- - **Multiple answers** — return any number of selections and typed entries per question.
50
+ - **Free-form typing** — a question without options is a plain text prompt. Option questions can also hold one typed answer unless `allowOther: false`.
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
+ - **One typed answer per question** — typed answers are never duplicated; editing replaces the previous text.
49
53
  - **Optional questions** (`required: false`) that can be skipped, plus preset answers (`selected: true`) and defaults.
50
54
  - **Review tab** — see everything before submitting; jump back to any question to edit it.
51
55
  - **Validation** — required answers and `min`/`max` selection counts are enforced before submit.
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.
52
57
  - **Safe cancel** — `Esc` arms cancellation, a second `Esc` or `Ctrl+C` cancels immediately.
53
- - **Scrollable option lists** — long lists show `↑ n more` / `↓ n more` indicators.
58
+ - **Scrollable option lists** — long lists show “n more above / below” indicators.
54
59
  - **Structured results** — the tool returns text for the model and a machine-readable `structuredContent` payload for codemode scripts.
55
60
 
56
61
  ## How the model uses it
@@ -125,26 +130,34 @@ Notes: (skipped)
125
130
  | `questions[].min` / `max` | integer | Selection bounds for multi-select. |
126
131
  | `questions[].placeholder` | string | Placeholder for the free-form editor. |
127
132
  | `questions[].otherLabel` | string | Custom label for the typed-answer entry. |
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
+
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.
128
140
 
129
141
  ### Keyboard reference
130
142
 
131
143
  | Keys | Action |
132
144
  | --- | --- |
133
- | `Tab` / `Shift+Tab`, `←` / `→` | Switch tabs |
145
+ | `Tab` / `Shift+Tab` | Switch tabs |
134
146
  | `↑` / `↓`, `Home` / `End` | Move the option cursor |
135
147
  | `Space` | Toggle a checkbox / select a radio option |
136
148
  | `1`–`9` | Jump to an option (toggles it in multi-select) |
137
149
  | `A` / `C` | Select all / clear (multi-select) |
138
- | `Enter` | Confirm the question, open the editor, or submit |
139
- | `Esc` | Leave the editor, arm cancel; `Esc` twice cancels |
150
+ | `Enter` | Confirm the question, or submit on the review tab |
151
+ | `Esc` | Arm cancel; `Esc` twice cancels |
140
152
  | `Ctrl+C` | Cancel immediately |
141
- | `\` + `Enter` | Newline in the free-form editor |
153
+ | `\` + `Enter` | Newline in a free-form answer |
142
154
 
143
155
  ## Modes
144
156
 
145
157
  | Mode | Behavior |
146
158
  | --- | --- |
147
159
  | Interactive TUI | Full tabbed questionnaire. |
160
+ | Interactive TUI + `noWait` | Tool returns immediately; answers are delivered as a follow-up message. |
148
161
  | RPC | Sequential fallback built from the dialog API (`select` / `input`). |
149
162
  | Print / JSON | The tool reports that no UI is available and lists the questions so the model can ask them in plain text. |
150
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ask-questions",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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
@@ -14,6 +14,7 @@ import {
14
14
  type NormalizedQuestion,
15
15
  type QuestionAnswer,
16
16
  DEFAULT_SUBMIT_LABEL,
17
+ buildResult,
17
18
  formatAnswersText,
18
19
  normalizeQuestions,
19
20
  toStructuredOutput,
@@ -27,6 +28,8 @@ import { runQuestionnaire, type QuestionnaireContext } from "./ui/ask-questionna
27
28
  /** Structured details stored with the tool result for rendering. */
28
29
  export interface AskToolDetails {
29
30
  cancelled: boolean;
31
+ /** True while a noWait questionnaire is still open in the background. */
32
+ pending?: boolean;
30
33
  answers: QuestionAnswer[];
31
34
  questions: NormalizedQuestion[];
32
35
  }
@@ -36,15 +39,17 @@ const TOOL_DESCRIPTION = `Ask the user one or more questions and wait for their
36
39
  The interactive UI supports:
37
40
  - Tabs: every question gets its own tab (Tab / Shift+Tab and arrow keys switch).
38
41
  - Single-select and multi-select questions (set multiple: true).
39
- - Free-form typing: the user can always type a custom answer unless allowOther is false; a question with no options is purely free-form.
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.
40
43
  - Optional questions (required: false) that can be skipped, preselected options (selected: true), and min/max selection counts.
41
44
  - Number keys 1-9 jump to options; A selects all; C clears a multi-select.
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.
42
46
 
43
47
  Guidelines:
44
48
  - Prefer this tool over asking questions in prose when you actually need the answer to proceed.
45
49
  - Group related questions into one call (2-6 questions) instead of asking repeatedly.
46
50
  - Provide concrete, labeled options whenever you can; use a free-form question only when the answer space is open.
47
- - Put recommended options first.`;
51
+ - Put recommended options first.
52
+ - Use noWait only when you can keep working without the answer; otherwise omit it so the answers arrive in the same turn.`;
48
53
 
49
54
  const HELP_TEXT = `Interactive UI is not available in this mode. Ask the user the following questions directly in your reply instead:
50
55
  {{questions}}`;
@@ -87,6 +92,21 @@ function resultContent(result: AskResult): string {
87
92
 
88
93
  /** Start the questionnaire and wait for the user. */
89
94
  export default function askQuestionsExtension(pi: ExtensionAPI): void {
95
+ // A noWait questionnaire stays open after its tool call returns. It owns a
96
+ // session-scoped abort controller and reports its answers with a follow-up
97
+ // message instead of a tool result.
98
+ let backgroundController: AbortController | undefined;
99
+ let shuttingDown = false;
100
+
101
+ pi.on("session_start", () => {
102
+ shuttingDown = false;
103
+ });
104
+ pi.on("session_shutdown", () => {
105
+ shuttingDown = true;
106
+ backgroundController?.abort();
107
+ backgroundController = undefined;
108
+ });
109
+
90
110
  pi.registerTool<typeof AskUserParamsSchema, AskToolDetails>({
91
111
  name: "ask_user",
92
112
  label: "Ask User",
@@ -96,6 +116,7 @@ export default function askQuestionsExtension(pi: ExtensionAPI): void {
96
116
  promptGuidelines: [
97
117
  "Use ask_user when you need a decision or missing detail from the user; batch related questions into one call instead of asking repeatedly.",
98
118
  "Give ask_user questions labeled options where possible, keep them self-contained, and put recommended options first.",
119
+ "Set noWait only when you can keep working without the answers, for example to ask while investigating something else.",
99
120
  ],
100
121
  parameters: AskUserParamsSchema,
101
122
  outputSchema: AskUserOutputSchema,
@@ -118,6 +139,73 @@ export default function askQuestionsExtension(pi: ExtensionAPI): void {
118
139
  }
119
140
 
120
141
  const submitLabel = params.submitLabel?.trim() || DEFAULT_SUBMIT_LABEL;
142
+
143
+ if (params.noWait === true && ctx.mode === "tui") {
144
+ if (backgroundController) {
145
+ return {
146
+ content: [
147
+ {
148
+ type: "text",
149
+ text: "Another background questionnaire is already open. Wait for the user to answer it before asking again.",
150
+ },
151
+ ],
152
+ details: { cancelled: true, pending: true, answers: [], questions },
153
+ isError: true,
154
+ };
155
+ }
156
+
157
+ const controller = new AbortController();
158
+ backgroundController = controller;
159
+ void runQuestionnaire(ctx as QuestionnaireContext, questions, {
160
+ title: params.title?.trim() || undefined,
161
+ submitLabel,
162
+ signal: controller.signal,
163
+ })
164
+ .then((result) => {
165
+ if (shuttingDown || backgroundController !== controller) {
166
+ return;
167
+ }
168
+ pi.sendMessage(
169
+ {
170
+ customType: "ask_user:answers",
171
+ content: resultContent(result),
172
+ display: true,
173
+ details: { cancelled: result.cancelled, answers: result.answers, questions },
174
+ },
175
+ { triggerTurn: true, deliverAs: "followUp" },
176
+ );
177
+ })
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
+ }
191
+ })
192
+ .finally(() => {
193
+ if (backgroundController === controller) {
194
+ backgroundController = undefined;
195
+ }
196
+ });
197
+
198
+ return {
199
+ content: [
200
+ {
201
+ type: "text",
202
+ text: `Questionnaire shown to the user in the background. Continue with other work; the answers will arrive as a follow-up message.`,
203
+ },
204
+ ],
205
+ details: { cancelled: false, pending: true, answers: [], questions },
206
+ structuredContent: toStructuredOutput(buildResult(questions, new Map(), false, true)),
207
+ };
208
+ }
121
209
  let result: AskResult;
122
210
  try {
123
211
  result = await askQuestions(ctx, questions, {
@@ -173,16 +261,23 @@ export default function askQuestionsExtension(pi: ExtensionAPI): void {
173
261
  const content = result.content[0];
174
262
  return new Text(content?.type === "text" ? content.text : "", 0, 0);
175
263
  }
264
+ if (details.pending) {
265
+ if (result.isError) {
266
+ const content = result.content[0];
267
+ return new Text(content?.type === "text" ? content.text : "Background questionnaire busy", 0, 0);
268
+ }
269
+ return new Text(theme.fg("muted", "Waiting for the user in the background"), 0, 0);
270
+ }
176
271
  if (details.cancelled) {
177
272
  return new Text(theme.fg("warning", "Cancelled"), 0, 0);
178
273
  }
179
274
  const lines = details.answers.map((answer) => {
180
275
  const label = theme.fg("accent", answer.label);
181
276
  if (answer.skipped) {
182
- return `${theme.fg("dim", "⏭ ")}${label}${theme.fg("dim", ": skipped")}`;
277
+ return `${label}${theme.fg("dim", ": skipped")}`;
183
278
  }
184
279
  if (answer.entries.length === 0) {
185
- return `${theme.fg("dim", "· ")}${label}${theme.fg("dim", ": no answer")}`;
280
+ return `${label}${theme.fg("dim", ": no answer")}`;
186
281
  }
187
282
  const values = answer.entries
188
283
  .map((entry) =>
@@ -191,7 +286,7 @@ export default function askQuestionsExtension(pi: ExtensionAPI): void {
191
286
  : theme.fg("text", entry.label),
192
287
  )
193
288
  .join(theme.fg("dim", ", "));
194
- return `${theme.fg("success", "✓ ")}${label}${theme.fg("muted", ": ")}${values}`;
289
+ return `${label}${theme.fg("muted", ": ")}${values}`;
195
290
  });
196
291
  return new Text(lines.join("\n"), 0, 0);
197
292
  },
package/src/fallback.ts CHANGED
@@ -134,7 +134,7 @@ async function askMultiple(
134
134
  selected.add(index);
135
135
  }
136
136
  });
137
- const custom: AnswerEntry[] = [];
137
+ let custom: AnswerEntry | undefined;
138
138
  const doneLabel = "Done";
139
139
  const customLabel = `${question.otherLabel} (type an answer)`;
140
140
  const skipLabel = "Skip this question";
@@ -168,14 +168,8 @@ async function askMultiple(
168
168
  ui.notify("Type an answer first.", "warning");
169
169
  continue;
170
170
  }
171
- if (
172
- custom.some((entry) => entry.value === value) ||
173
- question.options.some((option) => option.value === value)
174
- ) {
175
- ui.notify("That answer was already added.", "info");
176
- continue;
177
- }
178
- custom.push({ value, label: value, custom: true });
171
+ // A question holds at most one typed answer; editing replaces it.
172
+ custom = { value, label: value, custom: true };
179
173
  continue;
180
174
  }
181
175
  if (choice === doneLabel) {
@@ -183,7 +177,7 @@ async function askMultiple(
183
177
  ...question.options
184
178
  .filter((_, index) => selected.has(index))
185
179
  .map((option) => ({ value: option.value, label: option.label, custom: false })),
186
- ...custom,
180
+ ...(custom ? [custom] : []),
187
181
  ];
188
182
  const error = answerError(question, makeAnswer(question, entries));
189
183
  if (error) {
package/src/questions.ts CHANGED
@@ -50,6 +50,8 @@ export interface QuestionAnswer {
50
50
 
51
51
  export interface AskResult {
52
52
  cancelled: boolean;
53
+ /** True when the questionnaire is still open in the background (noWait). */
54
+ pending?: boolean;
53
55
  answers: QuestionAnswer[];
54
56
  }
55
57
 
@@ -80,7 +82,9 @@ export function normalizeQuestions(params: AskUserParams): NormalizedQuestion[]
80
82
  selected: option.selected ?? false,
81
83
  }));
82
84
  const freeForm = options.length === 0;
83
- const multiple = raw.multiple ?? false;
85
+ // Free-form questions always hold exactly one typed answer, so
86
+ // multiple/min/max do not apply to them.
87
+ const multiple = freeForm ? false : (raw.multiple ?? false);
84
88
 
85
89
  return {
86
90
  id,
@@ -177,9 +181,11 @@ export function buildResult(
177
181
  questions: NormalizedQuestion[],
178
182
  answers: ReadonlyMap<string, QuestionAnswer>,
179
183
  cancelled: boolean,
184
+ pending = false,
180
185
  ): AskResult {
181
186
  return {
182
187
  cancelled,
188
+ pending,
183
189
  answers: questions.map((question) => {
184
190
  const answer = answers.get(question.id) ?? makeAnswer(question, []);
185
191
  const skipped = answer.skipped || (!question.required && answer.entries.length === 0);
@@ -192,6 +198,7 @@ export function buildResult(
192
198
  export function toStructuredOutput(result: AskResult): AskUserOutput {
193
199
  return {
194
200
  cancelled: result.cancelled,
201
+ pending: result.pending ?? false,
195
202
  answers: result.answers.map((answer) => ({
196
203
  id: answer.id,
197
204
  question: answer.question,
package/src/schema.ts CHANGED
@@ -111,12 +111,24 @@ export const AskUserParamsSchema = Type.Object({
111
111
  description:
112
112
  "Questions to ask, in order. Each question gets its own tab; the user can move between tabs freely.",
113
113
  }),
114
+ noWait: Type.Optional(
115
+ Type.Boolean({
116
+ description:
117
+ "Show the questionnaire in the background and return immediately instead of waiting for the user (interactive TUI only). The answers arrive later as a follow-up message. Useful when the answers are not needed right now. Only one background questionnaire can be open at a time.",
118
+ }),
119
+ ),
114
120
  });
115
121
 
116
122
  export const AskUserOutputSchema = Type.Object({
117
123
  cancelled: Type.Boolean({
118
124
  description: "True when the user cancelled the questionnaire before submitting.",
119
125
  }),
126
+ pending: Type.Optional(
127
+ Type.Boolean({
128
+ description:
129
+ "True when the questionnaire is still open in the background (noWait); answers arrive in a follow-up message.",
130
+ }),
131
+ ),
120
132
  answers: Type.Array(
121
133
  Type.Object({
122
134
  id: Type.String({ description: "Question id from the request." }),
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * ─────────────────────────────────────────────
7
7
  * Title
8
- * ✓ Scope ◐ Style · Notes [ Submit ]
8
+ * Scope Release Notes [ Submit ]
9
9
  *
10
10
  * The question text
11
11
  * Optional header / hints
@@ -13,21 +13,22 @@
13
13
  * ❯ [x] 1. Option label
14
14
  * description
15
15
  * [ ] 2. Another option
16
- * ✎ Type something…
17
- * ⏭ Skip this question
16
+ * Type something…
17
+ * (inline editor for the typed answer, no separate menu)
18
+ * Skip this question
18
19
  *
19
20
  * ↑↓ move • Space toggle • Enter confirm • Esc cancel
20
21
  * ─────────────────────────────────────────────
21
22
  *
22
23
  * Keyboard contract:
23
- * - Tab / Shift+Tab (and ←/→) switch tabs.
24
+ * - Tab / Shift+Tab switch tabs.
24
25
  * - ↑/↓ move the cursor; Home/End jump to the first/last row.
25
26
  * - Space toggles a multiple-choice option; Enter confirms the question.
26
27
  * - 1-9 jumps to an option; on multiple-choice it toggles it.
27
28
  * - A selects all options, C clears the selection (multiple-choice only).
28
- * - Enter on "Type something…" opens the inline editor.
29
- * - Esc leaves the editor or arms cancellation; Esc twice cancels.
30
- * - Ctrl+C cancels immediately.
29
+ * - Moving the cursor onto "Type something…" starts editing immediately;
30
+ * the inline editor is always there, no separate menu.
31
+ * - Esc arms cancellation; Esc twice cancels. Ctrl+C cancels immediately.
31
32
  */
32
33
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
33
34
  import {
@@ -110,12 +111,13 @@ export function createQuestionnaireComponent(
110
111
  let tab = 0;
111
112
  let cursor = 0;
112
113
  let submitCursor = questions.length;
113
- let mode: "options" | "typing" = "options";
114
114
  let cancelArmed = false;
115
115
  let notice: { text: string; kind: NoticeKind } | undefined;
116
116
  let editorText = "";
117
117
  let cachedLines: string[] | undefined;
118
118
  let finished = false;
119
+ let componentFocused = false;
120
+ let suppressEditorEvents = false;
119
121
 
120
122
  const answers = new Map<string, QuestionAnswer>();
121
123
 
@@ -145,6 +147,10 @@ export function createQuestionnaireComponent(
145
147
  }
146
148
  }
147
149
 
150
+ function currentQuestion(): NormalizedQuestion | undefined {
151
+ return questions[tab];
152
+ }
153
+
148
154
  function rowsFor(question: NormalizedQuestion): QuestionRow[] {
149
155
  const rows: QuestionRow[] = question.options.map((option, index) => ({
150
156
  kind: "option",
@@ -160,6 +166,22 @@ export function createQuestionnaireComponent(
160
166
  return rows;
161
167
  }
162
168
 
169
+ function cursorRow(question: NormalizedQuestion): QuestionRow | undefined {
170
+ return rowsFor(question)[cursor];
171
+ }
172
+
173
+ /** True when keystrokes belong in the editor (free-form or custom row). */
174
+ function isEditorActive(): boolean {
175
+ const question = currentQuestion();
176
+ if (!question) {
177
+ return false;
178
+ }
179
+ if (question.options.length === 0) {
180
+ return true;
181
+ }
182
+ return cursorRow(question)?.kind === "custom";
183
+ }
184
+
163
185
  function isOptionSelected(question: NormalizedQuestion, option: NormalizedOption): boolean {
164
186
  return getAnswer(question).entries.some(
165
187
  (entry) => !entry.custom && entry.value === option.value,
@@ -192,22 +214,67 @@ export function createQuestionnaireComponent(
192
214
  }
193
215
 
194
216
  function setText(text: string): void {
217
+ suppressEditorEvents = true;
195
218
  editor.setText(text);
196
219
  editorText = editor.getText();
220
+ suppressEditorEvents = false;
221
+ }
222
+
223
+ function syncEditorFocus(): void {
224
+ editor.focused = componentFocused && isEditorActive();
225
+ }
226
+
227
+ /**
228
+ * Keep answers in sync with the editor while the user types. Free-form
229
+ * questions store a single typed answer; option questions with a custom
230
+ * row store a single custom entry alongside the selected options.
231
+ */
232
+ function applyEditorText(value: string): void {
233
+ const question = currentQuestion();
234
+ if (!question) {
235
+ return;
236
+ }
237
+ const trimmed = value.trim();
238
+ if (question.options.length === 0) {
239
+ if (trimmed) {
240
+ setEntries(question, [{ value: trimmed, label: trimmed, custom: true }]);
241
+ } else if (question.required) {
242
+ setEntries(question, []);
243
+ } else {
244
+ answers.set(question.id, makeAnswer(question, [], true));
245
+ }
246
+ return;
247
+ }
248
+ if (cursorRow(question)?.kind !== "custom") {
249
+ return;
250
+ }
251
+ const others = getAnswer(question).entries.filter((entry) => !entry.custom);
252
+ if (trimmed) {
253
+ setEntries(question, [...others, { value: trimmed, label: trimmed, custom: true }]);
254
+ } else {
255
+ setEntries(question, others);
256
+ }
197
257
  }
198
258
 
199
259
  // ── Navigation ───────────────────────────────────────────────────────
200
260
 
201
- function prepareQuestion(question: NormalizedQuestion | undefined, prefill: boolean): void {
202
- cursor = 0;
203
- if (!question || question.options.length > 0) {
204
- mode = "options";
261
+ function setEditorForQuestion(question: NormalizedQuestion | undefined): void {
262
+ if (!question) {
205
263
  setText("");
206
264
  return;
207
265
  }
208
- mode = "typing";
209
- const existing = prefill && !question.multiple ? getAnswer(question).entries[0]?.value ?? "" : "";
210
- setText(existing);
266
+ const custom = getAnswer(question).entries.find((entry) => entry.custom);
267
+ if (question.options.length === 0) {
268
+ setText(getAnswer(question).entries[0]?.value ?? "");
269
+ } else {
270
+ setText(custom?.value ?? "");
271
+ }
272
+ }
273
+
274
+ function prepareQuestion(question: NormalizedQuestion | undefined): void {
275
+ cursor = 0;
276
+ setEditorForQuestion(question);
277
+ syncEditorFocus();
211
278
  }
212
279
 
213
280
  function goTab(delta: number): void {
@@ -221,7 +288,7 @@ export function createQuestionnaireComponent(
221
288
  if (tab === submitTabIndex) {
222
289
  submitCursor = submitTabIndex;
223
290
  }
224
- prepareQuestion(questions[tab], true);
291
+ prepareQuestion(questions[tab]);
225
292
  refresh();
226
293
  }
227
294
 
@@ -229,7 +296,7 @@ export function createQuestionnaireComponent(
229
296
  tab = index;
230
297
  cancelArmed = false;
231
298
  notice = undefined;
232
- prepareQuestion(questions[tab], true);
299
+ prepareQuestion(questions[tab]);
233
300
  refresh();
234
301
  }
235
302
 
@@ -240,7 +307,7 @@ export function createQuestionnaireComponent(
240
307
  return;
241
308
  }
242
309
  tab = tab < questions.length - 1 ? tab + 1 : submitTabIndex;
243
- prepareQuestion(questions[tab], true);
310
+ prepareQuestion(questions[tab]);
244
311
  refresh();
245
312
  }
246
313
 
@@ -257,7 +324,7 @@ export function createQuestionnaireComponent(
257
324
  const index = questions.findIndex((question) => question.id === first.id);
258
325
  tab = index >= 0 ? index : 0;
259
326
  cancelArmed = false;
260
- prepareQuestion(questions[tab], false);
327
+ prepareQuestion(questions[tab]);
261
328
  notice = { text: `${first.label}: ${first.message}`, kind: "error" };
262
329
  refresh();
263
330
  return;
@@ -278,29 +345,24 @@ export function createQuestionnaireComponent(
278
345
  refresh();
279
346
  }
280
347
 
281
- function startTyping(): void {
282
- mode = "typing";
283
- const question = questions[tab];
284
- const existing = question
285
- ? getAnswer(question).entries.find((entry) => entry.custom)?.value ?? ""
286
- : "";
287
- setText(existing);
288
- notice = undefined;
289
- refresh();
290
- }
291
-
292
348
  function activateRow(toggle: boolean): void {
293
- const question = questions[tab];
349
+ const question = currentQuestion();
294
350
  if (!question) {
295
351
  return;
296
352
  }
297
- const rows = rowsFor(question);
298
- const row = rows[cursor];
353
+ const row = cursorRow(question);
299
354
  if (!row) {
300
355
  return;
301
356
  }
302
357
  if (row.kind === "custom") {
303
- startTyping();
358
+ // The custom row is edited in place; Enter confirms the question.
359
+ const error = answerError(question, answers.get(question.id));
360
+ if (error) {
361
+ notice = { text: error, kind: "warning" };
362
+ refresh();
363
+ return;
364
+ }
365
+ advance();
304
366
  return;
305
367
  }
306
368
  if (row.kind === "skip") {
@@ -340,68 +402,6 @@ export function createQuestionnaireComponent(
340
402
  refresh();
341
403
  }
342
404
 
343
- function submitTyped(rawValue: string): void {
344
- const question = questions[tab];
345
- if (!question) {
346
- return;
347
- }
348
- const value = rawValue.trim();
349
- const answer = getAnswer(question);
350
-
351
- if (!value) {
352
- if (!question.required) {
353
- answers.set(question.id, makeAnswer(question, [], true));
354
- advance();
355
- return;
356
- }
357
- if (question.multiple && question.options.length === 0) {
358
- if (answer.entries.length > 0) {
359
- advance();
360
- return;
361
- }
362
- notice = { text: "Type an answer first.", kind: "warning" };
363
- refresh();
364
- return;
365
- }
366
- if (question.multiple) {
367
- mode = "options";
368
- notice = undefined;
369
- refresh();
370
- return;
371
- }
372
- notice = { text: "Type an answer first.", kind: "warning" };
373
- refresh();
374
- return;
375
- }
376
-
377
- if (question.multiple) {
378
- if (answer.entries.some((entry) => entry.custom && entry.value === value)) {
379
- notice = { text: "That answer was already added.", kind: "info" };
380
- refresh();
381
- return;
382
- }
383
- if (question.max !== undefined && answer.entries.length >= question.max) {
384
- notice = {
385
- text: `Select at most ${question.max} ${question.max === 1 ? "answer" : "answers"}.`,
386
- kind: "warning",
387
- };
388
- refresh();
389
- return;
390
- }
391
- setEntries(question, [...answer.entries, { value, label: value, custom: true }]);
392
- notice = { text: `Added "${value}".`, kind: "success" };
393
- setText("");
394
- if (question.options.length > 0) {
395
- mode = "options";
396
- }
397
- refresh();
398
- return;
399
- }
400
-
401
- setEntries(question, [{ value, label: value, custom: true }]);
402
- advance();
403
- }
404
-
405
405
  // ── Input handling ───────────────────────────────────────────────────
406
406
 
407
407
  function handleSubmitTab(data: string): void {
@@ -443,39 +443,67 @@ export function createQuestionnaireComponent(
443
443
  }
444
444
  }
445
445
 
446
+ function moveCursor(delta: number, question: NormalizedQuestion): void {
447
+ const rows = rowsFor(question);
448
+ if (rows.length === 0) {
449
+ return;
450
+ }
451
+ cursor = (cursor + delta + rows.length) % rows.length;
452
+ syncEditorFocus();
453
+ refresh();
454
+ }
455
+
456
+ function handleCustomRow(data: string, question: NormalizedQuestion): void {
457
+ if (matchesKey(data, Key.up)) {
458
+ moveCursor(-1, question);
459
+ return;
460
+ }
461
+ if (matchesKey(data, Key.down)) {
462
+ moveCursor(1, question);
463
+ return;
464
+ }
465
+ if (matchesKey(data, Key.enter)) {
466
+ activateRow(false);
467
+ return;
468
+ }
469
+ // Left/right and everything else edit the inline text.
470
+ editor.handleInput(data);
471
+ refresh();
472
+ }
473
+
446
474
  function handleQuestionTab(data: string): void {
447
- const question = questions[tab];
475
+ const question = currentQuestion();
448
476
  if (!question) {
449
477
  return;
450
478
  }
451
479
  const rows = rowsFor(question);
452
480
 
453
- if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
481
+ if (matchesKey(data, Key.right)) {
454
482
  goTab(1);
455
483
  return;
456
484
  }
457
- if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
485
+ if (matchesKey(data, Key.left)) {
458
486
  goTab(-1);
459
487
  return;
460
488
  }
461
489
  if (rows.length > 0) {
462
490
  if (matchesKey(data, Key.up)) {
463
- cursor = (cursor - 1 + rows.length) % rows.length;
464
- refresh();
491
+ moveCursor(-1, question);
465
492
  return;
466
493
  }
467
494
  if (matchesKey(data, Key.down)) {
468
- cursor = (cursor + 1) % rows.length;
469
- refresh();
495
+ moveCursor(1, question);
470
496
  return;
471
497
  }
472
498
  if (matchesKey(data, Key.home)) {
473
499
  cursor = 0;
500
+ syncEditorFocus();
474
501
  refresh();
475
502
  return;
476
503
  }
477
504
  if (matchesKey(data, Key.end)) {
478
505
  cursor = rows.length - 1;
506
+ syncEditorFocus();
479
507
  refresh();
480
508
  return;
481
509
  }
@@ -500,6 +528,7 @@ export function createQuestionnaireComponent(
500
528
  }
501
529
  if (question.multiple && lower === "c") {
502
530
  setEntries(question, []);
531
+ setText("");
503
532
  notice = undefined;
504
533
  refresh();
505
534
  return;
@@ -509,6 +538,7 @@ export function createQuestionnaireComponent(
509
538
  const index = rows.findIndex((row) => row.kind === "option" && row.index === digit - 1);
510
539
  if (index >= 0) {
511
540
  cursor = index;
541
+ syncEditorFocus();
512
542
  activateRow(question.multiple);
513
543
  }
514
544
  }
@@ -524,36 +554,6 @@ export function createQuestionnaireComponent(
524
554
  }
525
555
 
526
556
  const escape = matchesKey(data, Key.escape);
527
-
528
- if (mode === "typing") {
529
- if (showTabs && matchesKey(data, Key.tab)) {
530
- goTab(1);
531
- return;
532
- }
533
- if (showTabs && matchesKey(data, Key.shift("tab"))) {
534
- goTab(-1);
535
- return;
536
- }
537
- if (escape) {
538
- const question = questions[tab];
539
- if (question && question.options.length > 0) {
540
- mode = "options";
541
- notice = undefined;
542
- refresh();
543
- } else {
544
- armCancel();
545
- }
546
- return;
547
- }
548
- if (cancelArmed) {
549
- cancelArmed = false;
550
- notice = undefined;
551
- }
552
- editor.handleInput(data);
553
- refresh();
554
- return;
555
- }
556
-
557
557
  if (!escape && cancelArmed) {
558
558
  cancelArmed = false;
559
559
  notice = undefined;
@@ -566,10 +566,33 @@ export function createQuestionnaireComponent(
566
566
  }
567
567
  return;
568
568
  }
569
+
570
+ if (showTabs && matchesKey(data, Key.tab)) {
571
+ goTab(1);
572
+ return;
573
+ }
574
+ if (showTabs && matchesKey(data, Key.shift("tab"))) {
575
+ goTab(-1);
576
+ return;
577
+ }
578
+
579
+ const question = currentQuestion();
569
580
  if (showTabs && tab === submitTabIndex) {
570
581
  handleSubmitTab(data);
571
582
  return;
572
583
  }
584
+ if (!question) {
585
+ return;
586
+ }
587
+ if (question.options.length === 0) {
588
+ editor.handleInput(data);
589
+ refresh();
590
+ return;
591
+ }
592
+ if (cursorRow(question)?.kind === "custom") {
593
+ handleCustomRow(data, question);
594
+ return;
595
+ }
573
596
  handleQuestionTab(data);
574
597
  }
575
598
 
@@ -580,15 +603,7 @@ export function createQuestionnaireComponent(
580
603
  for (let index = 0; index < questions.length; index += 1) {
581
604
  const question = questions[index]!;
582
605
  const status = answerStatus(question, answers.get(question.id));
583
- const icon =
584
- status === "complete"
585
- ? "✓"
586
- : status === "partial"
587
- ? "◐"
588
- : status === "skipped"
589
- ? "·"
590
- : "○";
591
- const text = ` ${icon} ${question.label} `;
606
+ const text = ` ${question.label} `;
592
607
  if (index === tab) {
593
608
  parts.push(theme.bg("selectedBg", theme.fg("text", text)));
594
609
  } else if (status === "complete") {
@@ -609,7 +624,7 @@ export function createQuestionnaireComponent(
609
624
  add(" ", parts.join(" "));
610
625
  }
611
626
 
612
- function renderRows(add: AddLine, question: NormalizedQuestion): void {
627
+ function renderRows(add: AddLine, lines: string[], renderWidth: number, question: NormalizedQuestion): void {
613
628
  const rows = rowsFor(question);
614
629
  const { start, end, hiddenAbove, hiddenBelow } = windowRange(
615
630
  rows.length,
@@ -617,7 +632,7 @@ export function createQuestionnaireComponent(
617
632
  MAX_VISIBLE_ROWS,
618
633
  );
619
634
  if (hiddenAbove > 0) {
620
- add(" ", theme.fg("dim", `↑ ${hiddenAbove} more`));
635
+ add(" ", theme.fg("dim", `${hiddenAbove} more above`));
621
636
  }
622
637
  for (let index = start; index < end; index += 1) {
623
638
  const row = rows[index]!;
@@ -635,23 +650,29 @@ export function createQuestionnaireComponent(
635
650
  continue;
636
651
  }
637
652
  if (row.kind === "custom") {
638
- const customEntries = getAnswer(question).entries.filter((entry) => entry.custom);
639
- const suffix = customEntries.length > 0 ? ` (${customEntries.length} added)` : "";
640
- add(marker, theme.fg(active ? "accent" : "text", `✎ ${question.otherLabel}${suffix}`));
641
- for (const entry of customEntries) {
642
- add(" ", theme.fg("success", `✎ "${entry.value}"`));
653
+ add(marker, theme.fg(active ? "accent" : "text", question.otherLabel));
654
+ const customValue = getAnswer(question).entries.find((entry) => entry.custom)?.value;
655
+ if (active) {
656
+ if (!editorText && question.placeholder) {
657
+ add(" ", theme.fg("dim", question.placeholder));
658
+ }
659
+ for (const line of editor.render(Math.max(1, renderWidth - 2))) {
660
+ lines.push(` ${line}`);
661
+ }
662
+ } else if (customValue) {
663
+ add(" ", theme.fg("text", customValue));
643
664
  }
644
665
  continue;
645
666
  }
646
- add(marker, theme.fg(active ? "accent" : "warning", "⏭ Skip this question"));
667
+ add(marker, theme.fg(active ? "accent" : "warning", "Skip this question"));
647
668
  }
648
669
  if (hiddenBelow > 0) {
649
- add(" ", theme.fg("dim", `↓ ${hiddenBelow} more`));
670
+ add(" ", theme.fg("dim", `${hiddenBelow} more below`));
650
671
  }
651
672
  }
652
673
 
653
674
  function renderQuestion(add: AddLine, lines: string[], renderWidth: number): void {
654
- const question = questions[tab];
675
+ const question = currentQuestion();
655
676
  if (!question) {
656
677
  return;
657
678
  }
@@ -665,11 +686,16 @@ export function createQuestionnaireComponent(
665
686
  add(" ", theme.fg("dim", `Select ${min}${max} • Space toggles`));
666
687
  }
667
688
  add(" ", "");
668
- if (question.options.length === 0 || mode === "typing") {
669
- renderTyping(add, lines, renderWidth, question);
670
- } else {
671
- renderRows(add, question);
689
+ if (question.options.length === 0) {
690
+ if (question.placeholder && editorText.length === 0) {
691
+ add(" ", theme.fg("dim", question.placeholder));
692
+ }
693
+ for (const line of editor.render(Math.max(1, renderWidth - 2))) {
694
+ lines.push(` ${line}`);
695
+ }
696
+ return;
672
697
  }
698
+ renderRows(add, lines, renderWidth, question);
673
699
  }
674
700
 
675
701
  function renderSubmit(add: AddLine): void {
@@ -682,13 +708,6 @@ export function createQuestionnaireComponent(
682
708
  const answer = getAnswer(question);
683
709
  const active = submitCursor === index;
684
710
  const marker = active ? theme.fg("accent", "❯ ") : " ";
685
- const status = answerStatus(question, answer);
686
- const icon =
687
- status === "complete"
688
- ? theme.fg("success", "✓ ")
689
- : status === "partial"
690
- ? theme.fg("accent", "◐ ")
691
- : "";
692
711
  let summary: string;
693
712
  if (answer.skipped) {
694
713
  summary = theme.fg("dim", "(skipped)");
@@ -703,7 +722,7 @@ export function createQuestionnaireComponent(
703
722
  )
704
723
  .join(theme.fg("dim", ", "));
705
724
  }
706
- add(marker, `${icon}${theme.fg("muted", `${question.label}: `)}${summary}`);
725
+ add(marker, `${theme.fg("muted", `${question.label}: `)}${summary}`);
707
726
  }
708
727
  add(" ", "");
709
728
  const ready = errors.length === 0;
@@ -728,20 +747,16 @@ export function createQuestionnaireComponent(
728
747
  if (showTabs && tab === submitTabIndex) {
729
748
  return `↑↓ choose • Enter open or ${options.submitLabel} • Tab switch • Esc cancel`;
730
749
  }
731
- const question = questions[tab];
750
+ const question = currentQuestion();
732
751
  if (!question) {
733
752
  return "Tab switch • Esc cancel";
734
753
  }
735
- const typing = mode === "typing" || question.options.length === 0;
736
- if (typing) {
737
- if (question.options.length === 0 && question.multiple) {
738
- return "Enter add • empty Enter finish • Esc cancel";
739
- }
740
- if (question.options.length > 0) {
741
- return "Enter submit • Esc back to options";
742
- }
754
+ if (question.options.length === 0) {
743
755
  return "Enter submit • \\+Enter newline • Esc cancel";
744
756
  }
757
+ if (cursorRow(question)?.kind === "custom") {
758
+ return "Type your answer • Enter confirm • ↑↓ move • Tab switch • Esc cancel";
759
+ }
745
760
  if (question.multiple) {
746
761
  return "↑↓ move • Space toggle • A all • C clear • 1-9 jump • Enter confirm • Tab switch";
747
762
  }
@@ -794,29 +809,6 @@ export function createQuestionnaireComponent(
794
809
  return lines;
795
810
  }
796
811
 
797
- // ── Typing sub-render (declared separately to keep render readable) ──
798
-
799
- function renderTyping(
800
- add: AddLine,
801
- lines: string[],
802
- renderWidth: number,
803
- question: NormalizedQuestion,
804
- ): void {
805
- const answer = getAnswer(question);
806
- if (question.options.length === 0 && answer.entries.length > 0) {
807
- answer.entries.forEach((entry, index) => {
808
- add(" ", theme.fg("success", `${index + 1}. ${entry.value}`));
809
- });
810
- add(" ", "");
811
- }
812
- if (question.placeholder && editorText.length === 0) {
813
- add(" ", theme.fg("dim", question.placeholder));
814
- }
815
- for (const line of editor.render(Math.max(1, renderWidth - 2))) {
816
- lines.push(` ${line}`);
817
- }
818
- }
819
-
820
812
  // ── Editor wiring ────────────────────────────────────────────────────
821
813
 
822
814
  const editorTheme: EditorTheme = {
@@ -832,13 +824,29 @@ export function createQuestionnaireComponent(
832
824
  const editor = new Editor(tui, editorTheme);
833
825
  editor.onChange = (value) => {
834
826
  editorText = value;
827
+ if (!suppressEditorEvents) {
828
+ applyEditorText(value);
829
+ }
835
830
  cachedLines = undefined;
836
831
  };
837
832
  editor.onSubmit = (value) => {
838
- submitTyped(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();
839
847
  };
840
848
 
841
- prepareQuestion(questions[0], false);
849
+ prepareQuestion(questions[0]);
842
850
 
843
851
  const onAbort = (): void => {
844
852
  finished = true;
@@ -848,10 +856,11 @@ export function createQuestionnaireComponent(
848
856
 
849
857
  return {
850
858
  get focused(): boolean {
851
- return editor.focused;
859
+ return componentFocused;
852
860
  },
853
861
  set focused(value: boolean) {
854
- editor.focused = value;
862
+ componentFocused = value;
863
+ syncEditorFocus();
855
864
  },
856
865
  render,
857
866
  handleInput,