pi-ask-questions 0.0.0-stage → 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 +32 -0
- package/LICENSE +21 -0
- package/README.md +178 -2
- package/package.json +55 -5
- package/src/extension.ts +355 -0
- package/src/fallback.ts +199 -0
- package/src/questions.ts +267 -0
- package/src/schema.ts +156 -0
- package/src/ui/ask-questionnaire.ts +882 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
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
|
+
|
|
20
|
+
## [0.1.0] - 2026-10-15
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `ask_user` tool with tabbed questionnaires, single-select and multi-select options, free-form typing, and multiple typed answers per question.
|
|
25
|
+
- Validated answers: required questions, `min`/`max` selection counts, optional questions that can be skipped, and preselected defaults.
|
|
26
|
+
- Review tab that summarizes all answers and allows jumping back to edit any question.
|
|
27
|
+
- Safe cancellation (Esc twice or Ctrl+C) and abort-signal support.
|
|
28
|
+
- Scrollable, width-aware rendering with option descriptions, placeholders, and status icons.
|
|
29
|
+
- Structured tool output (`structuredContent`) alongside a compact model-facing summary.
|
|
30
|
+
- RPC dialog fallback for modes without custom components, and a plain-text fallback for non-interactive modes.
|
|
31
|
+
- `/ask-demo` command to preview the questionnaire UI.
|
|
32
|
+
- Unit and interaction tests covering normalization, validation, formatting, rendering, and keyboard flows.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thgilciffart
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,179 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-ask-questions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Interactive questionnaires for [Pi](https://pi.dev): the model runs an `ask_user` tool and you answer in a proper terminal UI — tabs, multi-select, free-form typing, validation, and a review step.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/pi-ask-questions)
|
|
6
|
+
[](https://github.com/thgilciffart/pi-ask-questions/actions/workflows/ci.yml)
|
|
7
|
+
|
|
8
|
+
```text
|
|
9
|
+
────────────────────────────────────────────────────────────
|
|
10
|
+
ask_user demo
|
|
11
|
+
Scope Release Notes Submit
|
|
12
|
+
────────────────────────────────────────────────────────────
|
|
13
|
+
Which parts of the project should this change touch?
|
|
14
|
+
Select one or more • Space toggles
|
|
15
|
+
|
|
16
|
+
❯ [x] 1. Extension host
|
|
17
|
+
Tool registration and lifecycle code
|
|
18
|
+
[ ] 2. TUI component
|
|
19
|
+
The interactive questionnaire renderer
|
|
20
|
+
[ ] 3. Documentation
|
|
21
|
+
[x] 4. Tests
|
|
22
|
+
Type something…
|
|
23
|
+
──────────────────────────────────────────────
|
|
24
|
+
custom answer
|
|
25
|
+
──────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
↑↓ move • Space toggle • A all • C clear • 1-9 jump • Enter confirm
|
|
28
|
+
────────────────────────────────────────────────────────────
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi install npm:pi-ask-questions
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Try it for a single invocation without changing your settings:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pi -e npm:pi-ask-questions
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Once installed, run `/ask-demo` to preview the UI, or just let the model call `ask_user` when it needs input.
|
|
44
|
+
|
|
45
|
+
## Features
|
|
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`.
|
|
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.
|
|
53
|
+
- **Optional questions** (`required: false`) that can be skipped, plus preset answers (`selected: true`) and defaults.
|
|
54
|
+
- **Review tab** — see everything before submitting; jump back to any question to edit it.
|
|
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.
|
|
57
|
+
- **Safe cancel** — `Esc` arms cancellation, a second `Esc` or `Ctrl+C` cancels immediately.
|
|
58
|
+
- **Scrollable option lists** — long lists show “n more above / below” indicators.
|
|
59
|
+
- **Structured results** — the tool returns text for the model and a machine-readable `structuredContent` payload for codemode scripts.
|
|
60
|
+
|
|
61
|
+
## How the model uses it
|
|
62
|
+
|
|
63
|
+
`ask_user` accepts one or more questions. A single-select question resolves immediately; everything else flows into tabs and a submit step.
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"title": "Release planning",
|
|
68
|
+
"questions": [
|
|
69
|
+
{
|
|
70
|
+
"id": "scope",
|
|
71
|
+
"label": "Scope",
|
|
72
|
+
"question": "Which packages should the release include?",
|
|
73
|
+
"multiple": true,
|
|
74
|
+
"min": 1,
|
|
75
|
+
"options": [
|
|
76
|
+
{ "value": "core", "label": "Core", "description": "Shared runtime code", "selected": true },
|
|
77
|
+
{ "value": "cli", "label": "CLI" },
|
|
78
|
+
{ "value": "docs", "label": "Docs" }
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"id": "version",
|
|
83
|
+
"label": "Version",
|
|
84
|
+
"question": "How should we version it?",
|
|
85
|
+
"options": [
|
|
86
|
+
{ "value": "patch", "label": "Patch" },
|
|
87
|
+
{ "value": "minor", "label": "Minor" },
|
|
88
|
+
{ "value": "major", "label": "Major" }
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"id": "notes",
|
|
93
|
+
"label": "Notes",
|
|
94
|
+
"question": "Anything else I should know?",
|
|
95
|
+
"required": false,
|
|
96
|
+
"placeholder": "Type a note, or leave empty…"
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The model receives a compact summary such as:
|
|
103
|
+
|
|
104
|
+
```text
|
|
105
|
+
User answered:
|
|
106
|
+
Scope: selected "Core", "CLI", typed "shared types"
|
|
107
|
+
Version: selected "Minor"
|
|
108
|
+
Notes: (skipped)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Parameters
|
|
112
|
+
|
|
113
|
+
| Field | Type | Description |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| `title` | string | Optional title shown above the questionnaire. |
|
|
116
|
+
| `submitLabel` | string | Label of the submit entry. Default `Submit`. |
|
|
117
|
+
| `questions` | array | 1–12 questions, each rendered as a tab. |
|
|
118
|
+
| `questions[].id` | string | Stable id used in the result. Defaults to `q1`, `q2`, … |
|
|
119
|
+
| `questions[].label` | string | Short tab label. Defaults to `Q1`, `Q2`, … |
|
|
120
|
+
| `questions[].question` | string | The question text. |
|
|
121
|
+
| `questions[].header` | string | Optional context shown under the question. |
|
|
122
|
+
| `questions[].options` | array | Choices. Omit or leave empty for free-form text. |
|
|
123
|
+
| `questions[].options[].value` | string | Value returned when picked. Defaults to the label. |
|
|
124
|
+
| `questions[].options[].label` | string | Display label. |
|
|
125
|
+
| `questions[].options[].description` | string | Explanation shown below the label. |
|
|
126
|
+
| `questions[].options[].selected` | boolean | Preselect the option. |
|
|
127
|
+
| `questions[].multiple` | boolean | Allow more than one selection. |
|
|
128
|
+
| `questions[].allowOther` | boolean | Show the typed-answer entry. Default `true`. |
|
|
129
|
+
| `questions[].required` | boolean | Require an answer before submit. Default `true`. |
|
|
130
|
+
| `questions[].min` / `max` | integer | Selection bounds for multi-select. |
|
|
131
|
+
| `questions[].placeholder` | string | Placeholder for the free-form editor. |
|
|
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
|
+
### Keyboard reference
|
|
136
|
+
|
|
137
|
+
| Keys | Action |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| `Tab` / `Shift+Tab` | Switch tabs |
|
|
140
|
+
| `↑` / `↓`, `Home` / `End` | Move the option cursor |
|
|
141
|
+
| `Space` | Toggle a checkbox / select a radio option |
|
|
142
|
+
| `1`–`9` | Jump to an option (toggles it in multi-select) |
|
|
143
|
+
| `A` / `C` | Select all / clear (multi-select) |
|
|
144
|
+
| `Enter` | Confirm the question, or submit on the review tab |
|
|
145
|
+
| `Esc` | Arm cancel; `Esc` twice cancels |
|
|
146
|
+
| `Ctrl+C` | Cancel immediately |
|
|
147
|
+
| `\` + `Enter` | Newline in a free-form answer |
|
|
148
|
+
|
|
149
|
+
## Modes
|
|
150
|
+
|
|
151
|
+
| Mode | Behavior |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| Interactive TUI | Full tabbed questionnaire. |
|
|
154
|
+
| Interactive TUI + `noWait` | Tool returns immediately; answers are delivered as a follow-up message. |
|
|
155
|
+
| RPC | Sequential fallback built from the dialog API (`select` / `input`). |
|
|
156
|
+
| Print / JSON | The tool reports that no UI is available and lists the questions so the model can ask them in plain text. |
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
npm install
|
|
162
|
+
npm run check # typecheck + unit tests
|
|
163
|
+
pi -e ./src/extension.ts
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The pure questionnaire model (`src/questions.ts`), the TUI component (`src/ui/ask-questionnaire.ts`), and the extension wiring are covered by unit and interaction tests that drive the component with raw key sequences.
|
|
167
|
+
|
|
168
|
+
## Publishing
|
|
169
|
+
|
|
170
|
+
The package is published to npm as `pi-ask-questions` and carries the `pi-package` keyword, which makes it eligible for the [Pi package gallery](https://pi.dev/packages). `prepublishOnly` runs the full check suite.
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
npm login
|
|
174
|
+
npm publish
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "pi-ask-questions",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Interactive questionnaires for Pi: tabs, multi-select, free-form typing, validation, and structured answers.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-coding-agent",
|
|
9
|
+
"extension",
|
|
10
|
+
"questions",
|
|
11
|
+
"questionnaire",
|
|
12
|
+
"ask",
|
|
13
|
+
"multi-select",
|
|
14
|
+
"tui"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "thgilciffart <73938431+thgilciffart@users.noreply.github.com>",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/thgilciffart/pi-ask-questions.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/thgilciffart/pi-ask-questions#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/thgilciffart/pi-ask-questions/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"pi": {
|
|
34
|
+
"extensions": [
|
|
35
|
+
"./src/extension.ts"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node --test \"test/**/*.test.ts\"",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"check": "npm run typecheck && npm test",
|
|
42
|
+
"prepublishOnly": "npm run check"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
46
|
+
"@earendil-works/pi-tui": "*",
|
|
47
|
+
"typebox": "*"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@earendil-works/pi-coding-agent": "^1.2.0",
|
|
51
|
+
"@earendil-works/pi-tui": "^1.2.0",
|
|
52
|
+
"@types/node": "^26.6.5",
|
|
53
|
+
"typebox": "^1.3.37",
|
|
54
|
+
"typescript": "^7.0.2"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-ask-questions
|
|
3
|
+
*
|
|
4
|
+
* Registers the `ask_user` tool: an interactive questionnaire with tabs,
|
|
5
|
+
* single- and multi-select options, free-form typing, validation, and
|
|
6
|
+
* structured answers. Also adds `/ask-demo` so the UI can be previewed
|
|
7
|
+
* without involving the model.
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
11
|
+
import { runFallbackSurvey } from "./fallback.ts";
|
|
12
|
+
import {
|
|
13
|
+
type AskResult,
|
|
14
|
+
type NormalizedQuestion,
|
|
15
|
+
type QuestionAnswer,
|
|
16
|
+
DEFAULT_SUBMIT_LABEL,
|
|
17
|
+
buildResult,
|
|
18
|
+
formatAnswersText,
|
|
19
|
+
normalizeQuestions,
|
|
20
|
+
toStructuredOutput,
|
|
21
|
+
} from "./questions.ts";
|
|
22
|
+
import {
|
|
23
|
+
AskUserOutputSchema,
|
|
24
|
+
AskUserParamsSchema,
|
|
25
|
+
type AskUserParams,
|
|
26
|
+
} from "./schema.ts";
|
|
27
|
+
import { runQuestionnaire, type QuestionnaireContext } from "./ui/ask-questionnaire.ts";
|
|
28
|
+
/** Structured details stored with the tool result for rendering. */
|
|
29
|
+
export interface AskToolDetails {
|
|
30
|
+
cancelled: boolean;
|
|
31
|
+
/** True while a noWait questionnaire is still open in the background. */
|
|
32
|
+
pending?: boolean;
|
|
33
|
+
answers: QuestionAnswer[];
|
|
34
|
+
questions: NormalizedQuestion[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TOOL_DESCRIPTION = `Ask the user one or more questions and wait for their answers. Use it when you need a decision, preference, or piece of information that only the user can provide.
|
|
38
|
+
|
|
39
|
+
The interactive UI supports:
|
|
40
|
+
- Tabs: every question gets its own tab (Tab / Shift+Tab and arrow keys switch).
|
|
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.
|
|
43
|
+
- Optional questions (required: false) that can be skipped, preselected options (selected: true), and min/max selection counts.
|
|
44
|
+
- Number keys 1-9 jump to options; A selects all; C clears a multi-select.
|
|
45
|
+
- Set noWait: true to open the questionnaire in the background: the tool returns immediately and the answers arrive later as a follow-up message.
|
|
46
|
+
|
|
47
|
+
Guidelines:
|
|
48
|
+
- Prefer this tool over asking questions in prose when you actually need the answer to proceed.
|
|
49
|
+
- Group related questions into one call (2-6 questions) instead of asking repeatedly.
|
|
50
|
+
- Provide concrete, labeled options whenever you can; use a free-form question only when the answer space is open.
|
|
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.`;
|
|
53
|
+
|
|
54
|
+
const HELP_TEXT = `Interactive UI is not available in this mode. Ask the user the following questions directly in your reply instead:
|
|
55
|
+
{{questions}}`;
|
|
56
|
+
|
|
57
|
+
/** Dispatch to the right UI for the current mode. */
|
|
58
|
+
async function askQuestions(
|
|
59
|
+
ctx: Pick<ExtensionContext, "ui" | "mode" | "hasUI">,
|
|
60
|
+
questions: NormalizedQuestion[],
|
|
61
|
+
options: { title?: string; submitLabel: string; signal?: AbortSignal },
|
|
62
|
+
): Promise<AskResult> {
|
|
63
|
+
if (ctx.mode === "tui") {
|
|
64
|
+
return runQuestionnaire(ctx as QuestionnaireContext, questions, options);
|
|
65
|
+
}
|
|
66
|
+
if (ctx.hasUI) {
|
|
67
|
+
return runFallbackSurvey(ctx.ui, questions, options.signal);
|
|
68
|
+
}
|
|
69
|
+
throw new Error("no-ui");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Human-readable list used when no UI exists (print/JSON mode). */
|
|
73
|
+
function questionsAsText(questions: NormalizedQuestion[]): string {
|
|
74
|
+
return questions
|
|
75
|
+
.map((question, index) => {
|
|
76
|
+
const options =
|
|
77
|
+
question.options.length > 0
|
|
78
|
+
? `\n Options: ${question.options.map((option) => option.label).join(", ")}`
|
|
79
|
+
: "";
|
|
80
|
+
const optional = question.required ? "" : " (optional)";
|
|
81
|
+
return `${index + 1}. ${question.question}${optional}${options}`;
|
|
82
|
+
})
|
|
83
|
+
.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resultContent(result: AskResult): string {
|
|
87
|
+
if (result.cancelled) {
|
|
88
|
+
return "The user cancelled the questionnaire before submitting.";
|
|
89
|
+
}
|
|
90
|
+
return `User answered:\n${formatAnswersText(result)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Start the questionnaire and wait for the user. */
|
|
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
|
+
|
|
110
|
+
pi.registerTool<typeof AskUserParamsSchema, AskToolDetails>({
|
|
111
|
+
name: "ask_user",
|
|
112
|
+
label: "Ask User",
|
|
113
|
+
description: TOOL_DESCRIPTION,
|
|
114
|
+
promptSnippet:
|
|
115
|
+
"ask_user: ask the user structured questions in an interactive questionnaire (tabs, multi-select, free-form).",
|
|
116
|
+
promptGuidelines: [
|
|
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.",
|
|
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.",
|
|
120
|
+
],
|
|
121
|
+
parameters: AskUserParamsSchema,
|
|
122
|
+
outputSchema: AskUserOutputSchema,
|
|
123
|
+
executionMode: "sequential",
|
|
124
|
+
annotations: {
|
|
125
|
+
readOnlyHint: true,
|
|
126
|
+
destructiveHint: false,
|
|
127
|
+
idempotentHint: true,
|
|
128
|
+
openWorldHint: false,
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
132
|
+
const questions = normalizeQuestions(params);
|
|
133
|
+
if (questions.length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
content: [{ type: "text", text: "No questions were provided." }],
|
|
136
|
+
details: { cancelled: true, answers: [], questions },
|
|
137
|
+
isError: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
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
|
+
}
|
|
198
|
+
let result: AskResult;
|
|
199
|
+
try {
|
|
200
|
+
result = await askQuestions(ctx, questions, {
|
|
201
|
+
title: params.title?.trim() || undefined,
|
|
202
|
+
submitLabel,
|
|
203
|
+
signal: signal ?? undefined,
|
|
204
|
+
});
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error instanceof Error && error.message === "no-ui") {
|
|
207
|
+
const text = HELP_TEXT.replace("{{questions}}", questionsAsText(questions));
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: "text", text }],
|
|
210
|
+
details: { cancelled: true, answers: [], questions },
|
|
211
|
+
isError: true,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
content: [{ type: "text", text: resultContent(result) }],
|
|
219
|
+
details: {
|
|
220
|
+
cancelled: result.cancelled,
|
|
221
|
+
answers: result.answers,
|
|
222
|
+
questions,
|
|
223
|
+
},
|
|
224
|
+
structuredContent: toStructuredOutput(result),
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
renderCall(args, theme, _context) {
|
|
229
|
+
const questions = Array.isArray(args.questions) ? args.questions : [];
|
|
230
|
+
const count = questions.length;
|
|
231
|
+
let text =
|
|
232
|
+
theme.fg("toolTitle", theme.bold("ask_user ")) +
|
|
233
|
+
theme.fg("muted", `${count} question${count === 1 ? "" : "s"}`);
|
|
234
|
+
const labels = questions
|
|
235
|
+
.map((question, index) => question.label?.trim() || question.id?.trim() || `Q${index + 1}`)
|
|
236
|
+
.join(", ");
|
|
237
|
+
if (labels.length > 0) {
|
|
238
|
+
text += theme.fg("dim", ` (${labels})`);
|
|
239
|
+
}
|
|
240
|
+
const first = questions[0]?.question;
|
|
241
|
+
if (typeof first === "string" && first.length > 0) {
|
|
242
|
+
text += `\n${theme.fg("dim", ` ${first}`)}`;
|
|
243
|
+
}
|
|
244
|
+
return new Text(text, 0, 0);
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
renderResult(result, _options, theme, _context) {
|
|
248
|
+
const details = result.details;
|
|
249
|
+
if (!details) {
|
|
250
|
+
const content = result.content[0];
|
|
251
|
+
return new Text(content?.type === "text" ? content.text : "", 0, 0);
|
|
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
|
+
}
|
|
260
|
+
if (details.cancelled) {
|
|
261
|
+
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
262
|
+
}
|
|
263
|
+
const lines = details.answers.map((answer) => {
|
|
264
|
+
const label = theme.fg("accent", answer.label);
|
|
265
|
+
if (answer.skipped) {
|
|
266
|
+
return `${label}${theme.fg("dim", ": skipped")}`;
|
|
267
|
+
}
|
|
268
|
+
if (answer.entries.length === 0) {
|
|
269
|
+
return `${label}${theme.fg("dim", ": no answer")}`;
|
|
270
|
+
}
|
|
271
|
+
const values = answer.entries
|
|
272
|
+
.map((entry) =>
|
|
273
|
+
entry.custom
|
|
274
|
+
? `${theme.fg("muted", "(typed) ")}${theme.fg("text", entry.value)}`
|
|
275
|
+
: theme.fg("text", entry.label),
|
|
276
|
+
)
|
|
277
|
+
.join(theme.fg("dim", ", "));
|
|
278
|
+
return `${label}${theme.fg("muted", ": ")}${values}`;
|
|
279
|
+
});
|
|
280
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
pi.registerCommand("ask-demo", {
|
|
285
|
+
description: "Preview the ask_user questionnaire UI with a sample survey",
|
|
286
|
+
handler: async (_args, ctx) => {
|
|
287
|
+
if (!ctx.hasUI) {
|
|
288
|
+
ctx.ui.notify("The demo needs an interactive UI (TUI or RPC mode).", "error");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const questions = normalizeQuestions(demoParams());
|
|
292
|
+
try {
|
|
293
|
+
const result = await askQuestions(ctx, questions, {
|
|
294
|
+
title: "ask_user demo",
|
|
295
|
+
submitLabel: DEFAULT_SUBMIT_LABEL,
|
|
296
|
+
signal: ctx.signal,
|
|
297
|
+
});
|
|
298
|
+
ctx.ui.notify(
|
|
299
|
+
result.cancelled
|
|
300
|
+
? "Demo cancelled"
|
|
301
|
+
: `Demo finished — ${formatAnswersText(result).split("\n").length} answers`,
|
|
302
|
+
"info",
|
|
303
|
+
);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error instanceof Error && error.message === "no-ui") {
|
|
306
|
+
ctx.ui.notify("No UI available for the demo.", "error");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Sample questionnaire used by `/ask-demo` and the README. */
|
|
316
|
+
function demoParams(): AskUserParams {
|
|
317
|
+
return {
|
|
318
|
+
title: "ask_user demo",
|
|
319
|
+
questions: [
|
|
320
|
+
{
|
|
321
|
+
id: "scope",
|
|
322
|
+
label: "Scope",
|
|
323
|
+
question: "Which parts of the project should this change touch?",
|
|
324
|
+
multiple: true,
|
|
325
|
+
options: [
|
|
326
|
+
{
|
|
327
|
+
label: "Extension host",
|
|
328
|
+
description: "Tool registration and lifecycle code",
|
|
329
|
+
selected: true,
|
|
330
|
+
},
|
|
331
|
+
{ label: "TUI component", description: "The interactive questionnaire renderer" },
|
|
332
|
+
{ label: "Documentation" },
|
|
333
|
+
{ label: "Tests", selected: true },
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
id: "release",
|
|
338
|
+
label: "Release",
|
|
339
|
+
question: "How should the release be versioned?",
|
|
340
|
+
options: [
|
|
341
|
+
{ label: "Patch", description: "Bug fixes only (0.0.x)" },
|
|
342
|
+
{ label: "Minor", description: "Backwards-compatible features (0.x.0)" },
|
|
343
|
+
{ label: "Major", description: "Breaking changes (x.0.0)" },
|
|
344
|
+
],
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
id: "notes",
|
|
348
|
+
label: "Notes",
|
|
349
|
+
question: "Anything else I should know?",
|
|
350
|
+
required: false,
|
|
351
|
+
placeholder: "Type a note, or leave empty…",
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
};
|
|
355
|
+
}
|