pi-ask-questions 0.2.1 → 0.4.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 +27 -0
- package/README.md +32 -16
- package/package.json +1 -1
- package/src/extension.ts +15 -12
- package/src/fallback.ts +20 -66
- package/src/questions.ts +7 -3
- package/src/schema.ts +10 -4
- package/src/ui/ask-questionnaire.ts +118 -52
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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
|
+
## [0.4.0] - 2026-10-25
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Added explicit `mode: "urgent"` (blocking) and `mode: "background"` (default) choices. `noWait` remains a compatibility alias; an explicit mode takes precedence.
|
|
15
|
+
- Background answers now use steering delivery at the next model/tool boundary instead of waiting in the follow-up queue.
|
|
16
|
+
|
|
17
|
+
- Tabs preserve unfinished drafts, caret positions, and option cursors when revisiting questions.
|
|
18
|
+
- Multiline extra answers support arrow-key editing between lines; PageUp and PageDown navigate long option lists.
|
|
19
|
+
- Questionnaires show progress, live selection counts, and optional-question hints. The review tab preserves answer summaries while displaying specific validation errors.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Invalid selections above the maximum are shown as partially answered instead of complete.
|
|
24
|
+
- Validation feedback clears as answers become valid and does not carry over to the next question.
|
|
25
|
+
- Cancellation handles signals aborted before component creation, and disposed components ignore late input.
|
|
26
|
+
- 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.
|
|
27
|
+
- Selecting an already selected single-choice option clears it while preserving the typed answer, so presets can be replaced with text alone.
|
|
28
|
+
- Large pasted answers retain their full text instead of returning an editor paste marker, including when submitted through the review tab.
|
|
29
|
+
- Extra typed answers support backslash + Enter for newlines and remain editable after a validation error.
|
|
30
|
+
- Editor undo history is isolated per question so Undo cannot copy an answer from another question.
|
|
31
|
+
- RPC option dialogs support selecting an option and adding text before choosing Done.
|
|
32
|
+
- Selection-limit warnings remain visible when an extra option cannot be selected.
|
|
33
|
+
- Questionnaire rendering recalculates when the terminal width changes.
|
|
34
|
+
|
|
8
35
|
## [0.2.1] - 2026-10-15
|
|
9
36
|
|
|
10
37
|
### Fixed
|
package/README.md
CHANGED
|
@@ -44,23 +44,24 @@ 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
|
|
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
|
+
- **Urgent mode** — set `mode: "urgent"` to block further work until the questionnaire is answered or cancelled.
|
|
57
|
+
- **Background mode** — use `mode: "background"` (the default) and the tool returns immediately while the questionnaire stays open; answers steer the active run at the next model/tool boundary.
|
|
57
58
|
- **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.
|
|
59
|
+
- **Scrollable option lists** — long lists show “n more above / below” indicators. Use `PageUp` / `PageDown` to move eight rows at a time.
|
|
59
60
|
- **Structured results** — the tool returns text for the model and a machine-readable `structuredContent` payload for codemode scripts.
|
|
60
61
|
|
|
61
62
|
## How the model uses it
|
|
62
63
|
|
|
63
|
-
`ask_user` accepts one or more questions. A
|
|
64
|
+
`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
65
|
|
|
65
66
|
```json
|
|
66
67
|
{
|
|
@@ -124,19 +125,31 @@ Notes: (skipped)
|
|
|
124
125
|
| `questions[].options[].label` | string | Display label. |
|
|
125
126
|
| `questions[].options[].description` | string | Explanation shown below the label. |
|
|
126
127
|
| `questions[].options[].selected` | boolean | Preselect the option. |
|
|
127
|
-
| `questions[].multiple` | boolean | Allow more than one
|
|
128
|
+
| `questions[].multiple` | boolean | Allow more than one listed option. A typed answer can accompany either mode. |
|
|
128
129
|
| `questions[].allowOther` | boolean | Show the typed-answer entry. Default `true`. |
|
|
129
130
|
| `questions[].required` | boolean | Require an answer before submit. Default `true`. |
|
|
130
131
|
| `questions[].min` / `max` | integer | Selection bounds for multi-select. |
|
|
131
132
|
| `questions[].placeholder` | string | Placeholder for the free-form editor. |
|
|
132
133
|
| `questions[].otherLabel` | string | Custom label for the typed-answer entry. |
|
|
133
|
-
| `
|
|
134
|
+
| `mode` | `"urgent"` / `"background"` | `"urgent"` waits for answers. Default `"background"` returns immediately and delivers answers as steering (interactive TUI only; other interactive modes wait). Only one background questionnaire can be open at a time. |
|
|
135
|
+
| `noWait` | boolean | Compatibility alias: `true` means background, `false` means urgent. An explicit `mode` takes precedence. |
|
|
136
|
+
|
|
137
|
+
Choose `mode: "urgent"` when the model needs input before proceeding anywhere. Choose `mode: "background"` when it can continue independent work:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"mode": "background",
|
|
142
|
+
"questions": [{ "question": "Which deployment environment should I use?" }]
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Background answers use Pi's steering queue, so they are considered at the next model/tool boundary instead of waiting for the agent to finish. Steering does not interrupt a response mid-token or abort a running tool. If the agent has already stopped, submitting answers starts a new turn. Cancellation is also reported. In RPC mode, both modes wait for the dialog answers.
|
|
134
147
|
|
|
135
148
|
### Structured results
|
|
136
149
|
|
|
137
150
|
`structuredContent` mirrors the answers for codemode scripts: `cancelled`, an optional `pending`, and `answers[]` with `{ id, question, skipped, answers: [{ value, label, custom }] }`.
|
|
138
151
|
|
|
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
|
|
152
|
+
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 steering message.
|
|
140
153
|
|
|
141
154
|
### Keyboard reference
|
|
142
155
|
|
|
@@ -144,20 +157,23 @@ While a background questionnaire is open, `pending` is `true` and each `answers[
|
|
|
144
157
|
| --- | --- |
|
|
145
158
|
| `Tab` / `Shift+Tab` | Switch tabs |
|
|
146
159
|
| `↑` / `↓`, `Home` / `End` | Move the option cursor |
|
|
147
|
-
| `
|
|
148
|
-
| `
|
|
160
|
+
| `PageUp` / `PageDown` | Move a page through the option list |
|
|
161
|
+
| `Space` | Select or clear the highlighted option |
|
|
162
|
+
| `1`–`9` | Select or clear a numbered option, without submitting |
|
|
149
163
|
| `A` / `C` | Select all / clear (multi-select) |
|
|
150
164
|
| `Enter` | Confirm the question, or submit on the review tab |
|
|
151
165
|
| `Esc` | Arm cancel; `Esc` twice cancels |
|
|
152
166
|
| `Ctrl+C` | Cancel immediately |
|
|
153
|
-
| `\` + `Enter` | Newline in a free-form answer |
|
|
167
|
+
| `\` + `Enter` | Newline in a free-form or extra typed answer |
|
|
168
|
+
|
|
169
|
+
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.
|
|
154
170
|
|
|
155
171
|
## Modes
|
|
156
172
|
|
|
157
173
|
| Mode | Behavior |
|
|
158
174
|
| --- | --- |
|
|
159
|
-
| Interactive TUI | Full tabbed questionnaire. |
|
|
160
|
-
| Interactive TUI + `
|
|
175
|
+
| Interactive TUI + `mode: "urgent"` | Full tabbed questionnaire; tool waits for answers before further work. |
|
|
176
|
+
| Interactive TUI + `mode: "background"` (default) | Tool returns immediately; answers steer the active run at the next model/tool boundary. |
|
|
161
177
|
| RPC | Sequential fallback built from the dialog API (`select` / `input`). |
|
|
162
178
|
| Print / JSON | The tool reports that no UI is available and lists the questions so the model can ask them in plain text. |
|
|
163
179
|
|
package/package.json
CHANGED
package/src/extension.ts
CHANGED
|
@@ -28,28 +28,30 @@ import { runQuestionnaire, type QuestionnaireContext } from "./ui/ask-questionna
|
|
|
28
28
|
/** Structured details stored with the tool result for rendering. */
|
|
29
29
|
export interface AskToolDetails {
|
|
30
30
|
cancelled: boolean;
|
|
31
|
-
/** True while a
|
|
31
|
+
/** True while a background questionnaire is still open. */
|
|
32
32
|
pending?: boolean;
|
|
33
33
|
answers: QuestionAnswer[];
|
|
34
34
|
questions: NormalizedQuestion[];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
const TOOL_DESCRIPTION = `Ask the user one or more questions
|
|
37
|
+
const TOOL_DESCRIPTION = `Ask the user one or more questions, either blocking for answers or continuing in the background. Use it when you need a decision, preference, or piece of information that only the user can provide.
|
|
38
38
|
|
|
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
|
-
-
|
|
45
|
-
- Set
|
|
44
|
+
- Space or number keys 1-9 select or clear options without submitting; Enter confirms. A selects all; C clears a multi-select.
|
|
45
|
+
- Set mode: "urgent" to wait for the user before proceeding with any further work.
|
|
46
|
+
- Use mode: "background" (default) to continue independent work while the questionnaire stays open. Answers are injected as steering at the next model/tool boundary, before further work; they do not wait until the agent finishes. This does not abort an in-flight response or running tool.
|
|
47
|
+
- noWait is a compatibility alias: true means background, false means urgent. An explicit mode takes precedence.
|
|
46
48
|
|
|
47
49
|
Guidelines:
|
|
48
50
|
- Prefer this tool over asking questions in prose when you actually need the answer to proceed.
|
|
49
51
|
- Group related questions into one call (2-6 questions) instead of asking repeatedly.
|
|
50
52
|
- Provide concrete, labeled options whenever you can; use a free-form question only when the answer space is open.
|
|
51
53
|
- Put recommended options first.
|
|
52
|
-
-
|
|
54
|
+
- Choose urgent when an answer is required before any further work; choose background only when you can continue independent work. In non-TUI interactive modes, background falls back to waiting for answers.`;
|
|
53
55
|
|
|
54
56
|
const HELP_TEXT = `Interactive UI is not available in this mode. Ask the user the following questions directly in your reply instead:
|
|
55
57
|
{{questions}}`;
|
|
@@ -92,8 +94,8 @@ function resultContent(result: AskResult): string {
|
|
|
92
94
|
|
|
93
95
|
/** Start the questionnaire and wait for the user. */
|
|
94
96
|
export default function askQuestionsExtension(pi: ExtensionAPI): void {
|
|
95
|
-
// A
|
|
96
|
-
// session-scoped abort controller and reports its answers with a
|
|
97
|
+
// A background questionnaire stays open after its tool call returns. It owns a
|
|
98
|
+
// session-scoped abort controller and reports its answers with a steering
|
|
97
99
|
// message instead of a tool result.
|
|
98
100
|
let backgroundController: AbortController | undefined;
|
|
99
101
|
let shuttingDown = false;
|
|
@@ -116,7 +118,7 @@ export default function askQuestionsExtension(pi: ExtensionAPI): void {
|
|
|
116
118
|
promptGuidelines: [
|
|
117
119
|
"Use ask_user when you need a decision or missing detail from the user; batch related questions into one call instead of asking repeatedly.",
|
|
118
120
|
"Give ask_user questions labeled options where possible, keep them self-contained, and put recommended options first.",
|
|
119
|
-
|
|
121
|
+
'Use mode: "urgent" when input is required before proceeding anywhere. Use mode: "background" (default) for independent work; answers steer the active run at the next model/tool boundary.',
|
|
120
122
|
],
|
|
121
123
|
parameters: AskUserParamsSchema,
|
|
122
124
|
outputSchema: AskUserOutputSchema,
|
|
@@ -140,7 +142,8 @@ export default function askQuestionsExtension(pi: ExtensionAPI): void {
|
|
|
140
142
|
|
|
141
143
|
const submitLabel = params.submitLabel?.trim() || DEFAULT_SUBMIT_LABEL;
|
|
142
144
|
|
|
143
|
-
|
|
145
|
+
const mode = params.mode ?? (params.noWait === false ? "urgent" : "background");
|
|
146
|
+
if (mode === "background" && ctx.mode === "tui") {
|
|
144
147
|
if (backgroundController) {
|
|
145
148
|
return {
|
|
146
149
|
content: [
|
|
@@ -172,7 +175,7 @@ export default function askQuestionsExtension(pi: ExtensionAPI): void {
|
|
|
172
175
|
display: true,
|
|
173
176
|
details: { cancelled: result.cancelled, answers: result.answers, questions },
|
|
174
177
|
},
|
|
175
|
-
{ triggerTurn: true, deliverAs: "
|
|
178
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
176
179
|
);
|
|
177
180
|
})
|
|
178
181
|
.catch((error: unknown) => {
|
|
@@ -199,7 +202,7 @@ export default function askQuestionsExtension(pi: ExtensionAPI): void {
|
|
|
199
202
|
content: [
|
|
200
203
|
{
|
|
201
204
|
type: "text",
|
|
202
|
-
text: `Questionnaire shown to the user in the background. Continue with other work; the answers will
|
|
205
|
+
text: `Questionnaire shown to the user in the background. Continue with other work; the answers will be injected as steering at the next model/tool boundary. Incorporate them before continuing further work.`,
|
|
203
206
|
},
|
|
204
207
|
],
|
|
205
208
|
details: { cancelled: false, pending: true, answers: [], questions },
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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 (
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
@@ -111,10 +111,16 @@ 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
|
+
mode: Type.Optional(
|
|
115
|
+
Type.Union([Type.Literal("urgent"), Type.Literal("background")], {
|
|
116
|
+
description:
|
|
117
|
+
'"urgent" blocks further work until the questionnaire is answered or cancelled. "background" (default) returns immediately and injects answers as steering at the next model/tool boundary (interactive TUI only; other interactive modes wait). Takes precedence over noWait. Only one background questionnaire can be open at a time.',
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
114
120
|
noWait: Type.Optional(
|
|
115
121
|
Type.Boolean({
|
|
116
122
|
description:
|
|
117
|
-
"
|
|
123
|
+
"Compatibility alias: true selects background mode, false selects urgent mode. Prefer mode; an explicit mode takes precedence.",
|
|
118
124
|
}),
|
|
119
125
|
),
|
|
120
126
|
});
|
|
@@ -126,7 +132,7 @@ export const AskUserOutputSchema = Type.Object({
|
|
|
126
132
|
pending: Type.Optional(
|
|
127
133
|
Type.Boolean({
|
|
128
134
|
description:
|
|
129
|
-
"True when the questionnaire is still open in
|
|
135
|
+
"True when the questionnaire is still open in background mode; answers arrive as a steering message.",
|
|
130
136
|
}),
|
|
131
137
|
),
|
|
132
138
|
answers: Type.Array(
|
|
@@ -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
|
-
* -
|
|
27
|
-
* -
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 •
|
|
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
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
|
|
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
|
};
|