@henryqw/pi-ask-question 0.1.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/LICENSE +21 -0
- package/README.md +51 -0
- package/extensions/ask-question.ts +249 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henry Wang
|
|
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
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# `@henryqw/pi-ask-question`
|
|
2
|
+
|
|
3
|
+
Pi extension exposing `ask_question`, an interactive tool for asking user one question. Tool shows one to three supplied options, marks first as recommended, then adds `Something else.` for custom answer.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@henryqw/pi-ask-question
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Tool accepts:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"question": "Which database should we use?",
|
|
16
|
+
"options": [
|
|
17
|
+
{ "label": "PostgreSQL", "description": "Shared server database" },
|
|
18
|
+
{ "label": "SQLite", "description": "Local, embedded storage" },
|
|
19
|
+
{ "label": "File", "description": "Plain file storage" }
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Supply one to three meaningful options in preference order; UI adds `(Recommended)` to first label. Number keys select options directly. Empty questions, blank or duplicate labels, empty lists, more than three options, and non-interactive sessions return error result. Aborting tool call closes pending question.
|
|
25
|
+
|
|
26
|
+
Remove with:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi remove npm:@henryqw/pi-ask-question
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm test
|
|
36
|
+
npm run typecheck
|
|
37
|
+
npm run pack:check
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Run manual model/TUI check outside CI:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm run test:manual
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Pass criteria:
|
|
47
|
+
|
|
48
|
+
1. Agent calls `ask_question` instead of asking in plain text.
|
|
49
|
+
2. UI shows one to three useful choices, first marked `(Recommended)`, plus `Something else.`.
|
|
50
|
+
3. Option descriptions explain tradeoffs without repeating labels.
|
|
51
|
+
4. Number key selects matching option; custom choice accepts typed answer; `Esc` cancels.
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Editor,
|
|
4
|
+
type EditorTheme,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
Text,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
|
|
13
|
+
interface QuestionOption {
|
|
14
|
+
label: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type DisplayOption = QuestionOption & { isOther?: boolean };
|
|
19
|
+
|
|
20
|
+
const NUMBER_KEYS = ["1", "2", "3", "4"] as const;
|
|
21
|
+
|
|
22
|
+
interface QuestionDetails {
|
|
23
|
+
question: string;
|
|
24
|
+
options: string[];
|
|
25
|
+
answer: string | null;
|
|
26
|
+
wasCustom?: boolean;
|
|
27
|
+
selectedIndex?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const QuestionOptionSchema = Type.Object({
|
|
31
|
+
label: Type.String({ description: "Display label for the option", minLength: 1 }),
|
|
32
|
+
description: Type.Optional(Type.String({ description: "Optional description shown below label", minLength: 1 })),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const AskQuestionParams = Type.Object({
|
|
36
|
+
question: Type.String({ description: "Question to ask user", minLength: 1 }),
|
|
37
|
+
options: Type.Array(QuestionOptionSchema, {
|
|
38
|
+
description: "One to three meaningful options, ordered with recommended option first",
|
|
39
|
+
minItems: 1,
|
|
40
|
+
maxItems: 3,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export default function askQuestionExtension(pi: ExtensionAPI): void {
|
|
45
|
+
pi.registerTool({
|
|
46
|
+
name: "ask_question",
|
|
47
|
+
label: "Ask Question",
|
|
48
|
+
description: "Ask user one question with up to three options or a custom answer. First option is shown as recommended. Use when user input is needed to proceed.",
|
|
49
|
+
promptSnippet: "Ask user one interactive question with up to three options or a custom answer",
|
|
50
|
+
promptGuidelines: [
|
|
51
|
+
"Use ask_question instead of plain assistant text whenever user input is needed to proceed.",
|
|
52
|
+
"Give ask_question one to three concise, meaningful options without inventing filler, put recommended option first, and omit '(Recommended)' from its label.",
|
|
53
|
+
"Give ask_question option descriptions only when they explain meaningful tradeoffs; never repeat option labels.",
|
|
54
|
+
],
|
|
55
|
+
parameters: AskQuestionParams,
|
|
56
|
+
executionMode: "sequential",
|
|
57
|
+
|
|
58
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
59
|
+
const question = params.question.trim();
|
|
60
|
+
const suppliedOptions = params.options.map((option) => ({
|
|
61
|
+
label: option.label.trim(),
|
|
62
|
+
...(option.description === undefined ? {} : { description: option.description.trim() }),
|
|
63
|
+
}));
|
|
64
|
+
const options = suppliedOptions.map((option) => option.label);
|
|
65
|
+
if (ctx.mode !== "tui") {
|
|
66
|
+
return {
|
|
67
|
+
content: [{ type: "text" as const, text: "Error: UI not available (running in non-interactive mode)" }],
|
|
68
|
+
details: { question, options, answer: null } satisfies QuestionDetails,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
let validationError: string | undefined;
|
|
72
|
+
if (suppliedOptions.length < 1 || suppliedOptions.length > 3) validationError = "1 to 3 options required";
|
|
73
|
+
else if (!question) validationError = "Question must not be blank";
|
|
74
|
+
else if (suppliedOptions.some((option) => !option.label)) validationError = "Option labels must not be blank";
|
|
75
|
+
else if (new Set(options.map((option) => option.toLowerCase())).size !== options.length) validationError = "Option labels must be unique";
|
|
76
|
+
if (validationError) {
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text" as const, text: `Error: ${validationError}` }],
|
|
79
|
+
details: { question, options, answer: null } satisfies QuestionDetails,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const allOptions: DisplayOption[] = [...suppliedOptions, { label: "Something else.", isOther: true }];
|
|
84
|
+
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>(
|
|
85
|
+
(tui, theme, _kb, done) => {
|
|
86
|
+
let optionIndex = 0;
|
|
87
|
+
let editMode = false;
|
|
88
|
+
let cachedLines: string[] | undefined;
|
|
89
|
+
const editorTheme: EditorTheme = {
|
|
90
|
+
borderColor: (text) => theme.fg("accent", text),
|
|
91
|
+
selectList: {
|
|
92
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
93
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
94
|
+
description: (text) => theme.fg("muted", text),
|
|
95
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
96
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const editor = new Editor(tui, editorTheme);
|
|
100
|
+
|
|
101
|
+
function refresh(): void {
|
|
102
|
+
cachedLines = undefined;
|
|
103
|
+
tui.requestRender();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
editor.onSubmit = (value) => {
|
|
107
|
+
const answer = value.trim();
|
|
108
|
+
if (answer) done({ answer, wasCustom: true });
|
|
109
|
+
else {
|
|
110
|
+
editMode = false;
|
|
111
|
+
editor.setText("");
|
|
112
|
+
refresh();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function selectOption(): void {
|
|
117
|
+
const selected = allOptions[optionIndex]!;
|
|
118
|
+
if (selected.isOther) editMode = true;
|
|
119
|
+
else done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function handleInput(data: string): void {
|
|
123
|
+
if (editMode) {
|
|
124
|
+
if (matchesKey(data, Key.escape)) {
|
|
125
|
+
editMode = false;
|
|
126
|
+
editor.setText("");
|
|
127
|
+
refresh();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
editor.handleInput(data);
|
|
131
|
+
refresh();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const numberIndex = NUMBER_KEYS.findIndex((key, index) => index < allOptions.length && matchesKey(data, key));
|
|
136
|
+
if (numberIndex >= 0) {
|
|
137
|
+
optionIndex = numberIndex;
|
|
138
|
+
selectOption();
|
|
139
|
+
} else if (matchesKey(data, Key.up)) optionIndex = Math.max(0, optionIndex - 1);
|
|
140
|
+
else if (matchesKey(data, Key.down)) optionIndex = Math.min(allOptions.length - 1, optionIndex + 1);
|
|
141
|
+
else if (matchesKey(data, Key.enter)) selectOption();
|
|
142
|
+
else if (matchesKey(data, Key.escape)) {
|
|
143
|
+
done(null);
|
|
144
|
+
return;
|
|
145
|
+
} else return;
|
|
146
|
+
refresh();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function render(width: number): string[] {
|
|
150
|
+
if (cachedLines) return cachedLines;
|
|
151
|
+
const lines: string[] = [];
|
|
152
|
+
const renderWidth = Math.max(1, width);
|
|
153
|
+
const addWrapped = (text: string): void => { lines.push(...wrapTextWithAnsi(text, renderWidth)); };
|
|
154
|
+
const addWrappedWithPrefix = (prefix: string, text: string): void => {
|
|
155
|
+
const prefixWidth = visibleWidth(prefix);
|
|
156
|
+
if (prefixWidth >= renderWidth) {
|
|
157
|
+
addWrapped(prefix + text);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
|
|
161
|
+
const continuationPrefix = " ".repeat(prefixWidth);
|
|
162
|
+
for (let index = 0; index < wrapped.length; index++) {
|
|
163
|
+
lines.push(`${index === 0 ? prefix : continuationPrefix}${wrapped[index]}`);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
|
|
168
|
+
addWrappedWithPrefix(" ", theme.fg("text", question));
|
|
169
|
+
lines.push("");
|
|
170
|
+
for (let index = 0; index < allOptions.length; index++) {
|
|
171
|
+
const option = allOptions[index]!;
|
|
172
|
+
const selected = index === optionIndex;
|
|
173
|
+
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
|
174
|
+
const label = `${index + 1}. ${option.label}${index === 0 ? " (Recommended)" : ""}${option.isOther && editMode ? " ✎" : ""}`;
|
|
175
|
+
addWrappedWithPrefix(prefix, theme.fg(selected || (option.isOther && editMode) ? "accent" : "text", label));
|
|
176
|
+
if (option.description) addWrappedWithPrefix(" ", theme.fg("muted", option.description));
|
|
177
|
+
}
|
|
178
|
+
if (editMode) {
|
|
179
|
+
lines.push("");
|
|
180
|
+
addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
|
|
181
|
+
for (const line of editor.render(Math.max(1, renderWidth - 2))) lines.push(` ${line}`);
|
|
182
|
+
}
|
|
183
|
+
lines.push("");
|
|
184
|
+
addWrappedWithPrefix(" ", theme.fg("dim", editMode ? "Enter to submit • Esc to go back" : `↑↓ navigate • 1–${allOptions.length} or Enter to select • Esc to cancel`));
|
|
185
|
+
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
|
|
186
|
+
cachedLines = lines;
|
|
187
|
+
return lines;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const abort = (): void => done(null);
|
|
191
|
+
if (signal?.aborted) abort();
|
|
192
|
+
else signal?.addEventListener("abort", abort, { once: true });
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
render,
|
|
196
|
+
invalidate: () => { cachedLines = undefined; },
|
|
197
|
+
handleInput,
|
|
198
|
+
dispose: () => signal?.removeEventListener("abort", abort),
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
if (!result) {
|
|
204
|
+
return {
|
|
205
|
+
content: [{ type: "text" as const, text: "User cancelled question" }],
|
|
206
|
+
details: { question, options, answer: null } satisfies QuestionDetails,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
content: [{
|
|
211
|
+
type: "text" as const,
|
|
212
|
+
text: result.wasCustom ? `User wrote: ${result.answer}` : `User selected: ${result.index}. ${result.answer}`,
|
|
213
|
+
}],
|
|
214
|
+
details: {
|
|
215
|
+
question,
|
|
216
|
+
options,
|
|
217
|
+
answer: result.answer,
|
|
218
|
+
wasCustom: result.wasCustom,
|
|
219
|
+
selectedIndex: result.index,
|
|
220
|
+
} satisfies QuestionDetails,
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
renderCall(args, theme) {
|
|
225
|
+
let text = theme.fg("toolTitle", theme.bold("ask_question ")) + theme.fg("muted", args.question);
|
|
226
|
+
const options = Array.isArray(args.options) ? args.options : [];
|
|
227
|
+
if (options.length) {
|
|
228
|
+
const labels = options.map((option: QuestionOption) => option.label);
|
|
229
|
+
const numbered = [...labels, "Something else."].map((option, index) => `${index + 1}. ${option}${index === 0 ? " (Recommended)" : ""}`);
|
|
230
|
+
text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
|
|
231
|
+
}
|
|
232
|
+
return new Text(text, 0, 0);
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
renderResult(result, _options, theme) {
|
|
236
|
+
const details = result.details as QuestionDetails | undefined;
|
|
237
|
+
if (!details) {
|
|
238
|
+
const content = result.content[0];
|
|
239
|
+
return new Text(content?.type === "text" ? content.text : "", 0, 0);
|
|
240
|
+
}
|
|
241
|
+
if (details.answer === null) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
242
|
+
if (details.wasCustom) {
|
|
243
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer), 0, 0);
|
|
244
|
+
}
|
|
245
|
+
const display = details.selectedIndex ? `${details.selectedIndex}. ${details.answer}` : details.answer;
|
|
246
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0);
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@henryqw/pi-ask-question",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ask Pi users one interactive question with choices or a custom answer.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"question",
|
|
9
|
+
"interactive"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.19.0"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.ts",
|
|
23
|
+
"test:manual": "pi --no-extensions -e ./extensions/ask-question.ts --tools ask_question --no-session \"We need storage for a small team app. Before making changes, ask me to choose storage.\"",
|
|
24
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/ask-question.ts test/*.test.ts",
|
|
25
|
+
"pack:check": "npm pack --dry-run"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/pi-coding-agent": ">=0.84.1",
|
|
29
|
+
"@earendil-works/pi-tui": ">=0.84.1",
|
|
30
|
+
"typebox": "*"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/HenryQW/pi-packages.git",
|
|
35
|
+
"directory": "packages/pi-ask-question"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/HenryQW/pi-packages/issues"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./extensions/ask-question.ts"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|