pi-ask-questions 0.1.0 → 0.2.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 +12 -0
- package/README.md +17 -10
- package/package.json +1 -1
- package/src/extension.ts +89 -5
- package/src/fallback.ts +4 -10
- package/src/questions.ts +8 -1
- package/src/schema.ts +12 -0
- package/src/ui/ask-questionnaire.ts +209 -200
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ 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.0] - 2026-10-15
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Removed decorative icons from tabs, option rows, the review tab, and tool output. Only the selection markers (`[x]` / `[ ]` / `(•)`) and the cursor remain.
|
|
13
|
+
- 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.
|
|
14
|
+
- Free-form questions always resolve to a single typed answer; `multiple`, `min`, and `max` no longer apply when a question has no options.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `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.
|
|
19
|
+
|
|
8
20
|
## [0.1.0] - 2026-10-15
|
|
9
21
|
|
|
10
22
|
### 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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
48
|
-
- **
|
|
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
|
|
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,28 @@ 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. |
|
|
128
134
|
|
|
129
135
|
### Keyboard reference
|
|
130
136
|
|
|
131
137
|
| Keys | Action |
|
|
132
138
|
| --- | --- |
|
|
133
|
-
| `Tab` / `Shift+Tab
|
|
139
|
+
| `Tab` / `Shift+Tab` | Switch tabs |
|
|
134
140
|
| `↑` / `↓`, `Home` / `End` | Move the option cursor |
|
|
135
141
|
| `Space` | Toggle a checkbox / select a radio option |
|
|
136
142
|
| `1`–`9` | Jump to an option (toggles it in multi-select) |
|
|
137
143
|
| `A` / `C` | Select all / clear (multi-select) |
|
|
138
|
-
| `Enter` | Confirm the question,
|
|
139
|
-
| `Esc` |
|
|
144
|
+
| `Enter` | Confirm the question, or submit on the review tab |
|
|
145
|
+
| `Esc` | Arm cancel; `Esc` twice cancels |
|
|
140
146
|
| `Ctrl+C` | Cancel immediately |
|
|
141
|
-
| `\` + `Enter` | Newline in
|
|
147
|
+
| `\` + `Enter` | Newline in a free-form answer |
|
|
142
148
|
|
|
143
149
|
## Modes
|
|
144
150
|
|
|
145
151
|
| Mode | Behavior |
|
|
146
152
|
| --- | --- |
|
|
147
153
|
| Interactive TUI | Full tabbed questionnaire. |
|
|
154
|
+
| Interactive TUI + `noWait` | Tool returns immediately; answers are delivered as a follow-up message. |
|
|
148
155
|
| RPC | Sequential fallback built from the dialog API (`select` / `input`). |
|
|
149
156
|
| Print / JSON | The tool reports that no UI is available and lists the questions so the model can ask them in plain text. |
|
|
150
157
|
|
package/package.json
CHANGED
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:
|
|
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,62 @@ 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(() => {
|
|
179
|
+
// The questionnaire could not be shown; there is nothing to deliver.
|
|
180
|
+
})
|
|
181
|
+
.finally(() => {
|
|
182
|
+
if (backgroundController === controller) {
|
|
183
|
+
backgroundController = undefined;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
content: [
|
|
189
|
+
{
|
|
190
|
+
type: "text",
|
|
191
|
+
text: `Questionnaire shown to the user in the background. Continue with other work; the answers will arrive as a follow-up message.`,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
details: { cancelled: false, pending: true, answers: [], questions },
|
|
195
|
+
structuredContent: toStructuredOutput(buildResult(questions, new Map(), false, true)),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
121
198
|
let result: AskResult;
|
|
122
199
|
try {
|
|
123
200
|
result = await askQuestions(ctx, questions, {
|
|
@@ -173,16 +250,23 @@ export default function askQuestionsExtension(pi: ExtensionAPI): void {
|
|
|
173
250
|
const content = result.content[0];
|
|
174
251
|
return new Text(content?.type === "text" ? content.text : "", 0, 0);
|
|
175
252
|
}
|
|
253
|
+
if (details.pending) {
|
|
254
|
+
if (result.isError) {
|
|
255
|
+
const content = result.content[0];
|
|
256
|
+
return new Text(content?.type === "text" ? content.text : "Background questionnaire busy", 0, 0);
|
|
257
|
+
}
|
|
258
|
+
return new Text(theme.fg("muted", "Waiting for the user in the background"), 0, 0);
|
|
259
|
+
}
|
|
176
260
|
if (details.cancelled) {
|
|
177
261
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
178
262
|
}
|
|
179
263
|
const lines = details.answers.map((answer) => {
|
|
180
264
|
const label = theme.fg("accent", answer.label);
|
|
181
265
|
if (answer.skipped) {
|
|
182
|
-
return `${
|
|
266
|
+
return `${label}${theme.fg("dim", ": skipped")}`;
|
|
183
267
|
}
|
|
184
268
|
if (answer.entries.length === 0) {
|
|
185
|
-
return `${
|
|
269
|
+
return `${label}${theme.fg("dim", ": no answer")}`;
|
|
186
270
|
}
|
|
187
271
|
const values = answer.entries
|
|
188
272
|
.map((entry) =>
|
|
@@ -191,7 +275,7 @@ export default function askQuestionsExtension(pi: ExtensionAPI): void {
|
|
|
191
275
|
: theme.fg("text", entry.label),
|
|
192
276
|
)
|
|
193
277
|
.join(theme.fg("dim", ", "));
|
|
194
|
-
return `${
|
|
278
|
+
return `${label}${theme.fg("muted", ": ")}${values}`;
|
|
195
279
|
});
|
|
196
280
|
return new Text(lines.join("\n"), 0, 0);
|
|
197
281
|
},
|
package/src/fallback.ts
CHANGED
|
@@ -134,7 +134,7 @@ async function askMultiple(
|
|
|
134
134
|
selected.add(index);
|
|
135
135
|
}
|
|
136
136
|
});
|
|
137
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
-
* -
|
|
29
|
-
*
|
|
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
|
|
202
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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 =
|
|
349
|
+
const question = currentQuestion();
|
|
294
350
|
if (!question) {
|
|
295
351
|
return;
|
|
296
352
|
}
|
|
297
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
481
|
+
if (matchesKey(data, Key.right)) {
|
|
454
482
|
goTab(1);
|
|
455
483
|
return;
|
|
456
484
|
}
|
|
457
|
-
if (matchesKey(data, Key.
|
|
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
|
-
|
|
464
|
-
refresh();
|
|
491
|
+
moveCursor(-1, question);
|
|
465
492
|
return;
|
|
466
493
|
}
|
|
467
494
|
if (matchesKey(data, Key.down)) {
|
|
468
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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", "
|
|
667
|
+
add(marker, theme.fg(active ? "accent" : "warning", "Skip this question"));
|
|
647
668
|
}
|
|
648
669
|
if (hiddenBelow > 0) {
|
|
649
|
-
add(" ", theme.fg("dim",
|
|
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 =
|
|
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
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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, `${
|
|
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 =
|
|
750
|
+
const question = currentQuestion();
|
|
732
751
|
if (!question) {
|
|
733
752
|
return "Tab switch • Esc cancel";
|
|
734
753
|
}
|
|
735
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
859
|
+
return componentFocused;
|
|
852
860
|
},
|
|
853
861
|
set focused(value: boolean) {
|
|
854
|
-
|
|
862
|
+
componentFocused = value;
|
|
863
|
+
syncEditorFocus();
|
|
855
864
|
},
|
|
856
865
|
render,
|
|
857
866
|
handleInput,
|