@henryqw/pi-ask-question 0.1.9 → 0.1.11
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/README.md +1 -20
- package/extensions/ask-question.ts +16 -165
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -30,23 +30,4 @@ pi install npm:@henryqw/pi-ask-question
|
|
|
30
30
|
}
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Supply one to three options in preference order. UI marks the first `(Recommended)` and adds `Something else
|
|
34
|
-
|
|
35
|
-
## Remove
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
pi remove npm:@henryqw/pi-ask-question
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
## Development
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
npm run typecheck --workspace @henryqw/pi-ask-question
|
|
45
|
-
npm run pack:check --workspace @henryqw/pi-ask-question
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Manual TUI check outside CI:
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
npm run test:manual --workspace @henryqw/pi-ask-question
|
|
52
|
-
```
|
|
33
|
+
Supply one to three options in preference order. UI marks the first `(Recommended)` and adds `Something else.`, which opens a text input for a custom answer. Empty questions, blank or duplicate labels, empty lists, more than three options, and non-interactive sessions return an error. Aborting the tool closes the pending question.
|
|
@@ -1,23 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
Editor,
|
|
4
|
-
type EditorTheme,
|
|
5
|
-
Key,
|
|
6
|
-
type KeyId,
|
|
7
|
-
matchesKey,
|
|
8
|
-
Text,
|
|
9
|
-
visibleWidth,
|
|
10
|
-
wrapTextWithAnsi,
|
|
11
|
-
} from "@earendil-works/pi-tui";
|
|
12
2
|
import { Type } from "typebox";
|
|
13
3
|
|
|
14
|
-
interface QuestionOption {
|
|
15
|
-
label: string;
|
|
16
|
-
description?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
type DisplayOption = QuestionOption & { isOther?: boolean };
|
|
20
|
-
|
|
21
4
|
const CUSTOM_OPTION_LABEL = "Something else.";
|
|
22
5
|
// Models are told to omit "(Recommended)" from labels but don't always comply; normalize instead of duplicating.
|
|
23
6
|
const RECOMMENDED_SUFFIX = /\s*\(recommended\)\s*$/i;
|
|
@@ -86,126 +69,20 @@ export default function askQuestionExtension(pi: ExtensionAPI): void {
|
|
|
86
69
|
};
|
|
87
70
|
}
|
|
88
71
|
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
let cachedLines: string[] | undefined;
|
|
95
|
-
const editorTheme: EditorTheme = {
|
|
96
|
-
borderColor: (text) => theme.fg("accent", text),
|
|
97
|
-
selectList: {
|
|
98
|
-
selectedPrefix: (text) => theme.fg("accent", text),
|
|
99
|
-
selectedText: (text) => theme.fg("accent", text),
|
|
100
|
-
description: (text) => theme.fg("muted", text),
|
|
101
|
-
scrollInfo: (text) => theme.fg("dim", text),
|
|
102
|
-
noMatch: (text) => theme.fg("warning", text),
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
const editor = new Editor(tui, editorTheme);
|
|
106
|
-
|
|
107
|
-
function refresh(): void {
|
|
108
|
-
cachedLines = undefined;
|
|
109
|
-
tui.requestRender();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
editor.onSubmit = (value) => {
|
|
113
|
-
const answer = value.trim();
|
|
114
|
-
if (answer) done({ answer, wasCustom: true });
|
|
115
|
-
else {
|
|
116
|
-
editMode = false;
|
|
117
|
-
editor.setText("");
|
|
118
|
-
refresh();
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
function selectOption(): void {
|
|
123
|
-
const selected = allOptions[optionIndex]!;
|
|
124
|
-
if (selected.isOther) editMode = true;
|
|
125
|
-
else done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 });
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function handleInput(data: string): void {
|
|
129
|
-
if (editMode) {
|
|
130
|
-
if (matchesKey(data, Key.escape)) {
|
|
131
|
-
editMode = false;
|
|
132
|
-
editor.setText("");
|
|
133
|
-
refresh();
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
editor.handleInput(data);
|
|
137
|
-
refresh();
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const numberIndex = allOptions.findIndex((_, index) => matchesKey(data, `${index + 1}` as KeyId));
|
|
142
|
-
if (numberIndex >= 0) {
|
|
143
|
-
optionIndex = numberIndex;
|
|
144
|
-
selectOption();
|
|
145
|
-
} else if (matchesKey(data, Key.up)) optionIndex = Math.max(0, optionIndex - 1);
|
|
146
|
-
else if (matchesKey(data, Key.down)) optionIndex = Math.min(allOptions.length - 1, optionIndex + 1);
|
|
147
|
-
else if (matchesKey(data, Key.enter)) selectOption();
|
|
148
|
-
else if (matchesKey(data, Key.escape)) {
|
|
149
|
-
done(null);
|
|
150
|
-
return;
|
|
151
|
-
} else return;
|
|
152
|
-
refresh();
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function render(width: number): string[] {
|
|
156
|
-
if (cachedLines) return cachedLines;
|
|
157
|
-
const lines: string[] = [];
|
|
158
|
-
const renderWidth = Math.max(1, width);
|
|
159
|
-
const addWrappedWithPrefix = (prefix: string, text: string): void => {
|
|
160
|
-
const prefixWidth = visibleWidth(prefix);
|
|
161
|
-
if (prefixWidth >= renderWidth) {
|
|
162
|
-
lines.push(...wrapTextWithAnsi(prefix + text, renderWidth));
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
|
|
166
|
-
const continuationPrefix = " ".repeat(prefixWidth);
|
|
167
|
-
for (let index = 0; index < wrapped.length; index++) {
|
|
168
|
-
lines.push(`${index === 0 ? prefix : continuationPrefix}${wrapped[index]}`);
|
|
169
|
-
}
|
|
170
|
-
};
|
|
72
|
+
const choices = suppliedOptions.map((option, index) => {
|
|
73
|
+
const label = index === 0 ? withRecommended(option.label) : option.label;
|
|
74
|
+
return `${index + 1}. ${label}${option.description ? ` — ${option.description}` : ""}`;
|
|
75
|
+
});
|
|
76
|
+
choices.push(`${choices.length + 1}. ${CUSTOM_OPTION_LABEL}`);
|
|
171
77
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
|
179
|
-
const label = `${index + 1}. ${index === 0 ? withRecommended(option.label) : option.label}${option.isOther && editMode ? " ✎" : ""}`;
|
|
180
|
-
addWrappedWithPrefix(prefix, theme.fg(selected || (option.isOther && editMode) ? "accent" : "text", label));
|
|
181
|
-
if (option.description) addWrappedWithPrefix(" ", theme.fg("muted", option.description));
|
|
182
|
-
}
|
|
183
|
-
if (editMode) {
|
|
184
|
-
lines.push("");
|
|
185
|
-
addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
|
|
186
|
-
for (const line of editor.render(Math.max(1, renderWidth - 2))) lines.push(` ${line}`);
|
|
187
|
-
}
|
|
188
|
-
lines.push("");
|
|
189
|
-
addWrappedWithPrefix(" ", theme.fg("dim", editMode ? "Enter to submit • Esc to go back" : `↑↓ navigate • 1–${allOptions.length} or Enter to select • Esc to cancel`));
|
|
190
|
-
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
|
|
191
|
-
cachedLines = lines;
|
|
192
|
-
return lines;
|
|
193
|
-
}
|
|
78
|
+
const selected = await ctx.ui.select(question, choices, { signal });
|
|
79
|
+
const selectedIndex = selected === undefined ? -1 : choices.indexOf(selected);
|
|
80
|
+
const wasCustom = selectedIndex === suppliedOptions.length;
|
|
81
|
+
const answer = wasCustom
|
|
82
|
+
? (await ctx.ui.input(CUSTOM_OPTION_LABEL, "Type your answer", { signal }))?.trim()
|
|
83
|
+
: suppliedOptions[selectedIndex]?.label;
|
|
194
84
|
|
|
195
|
-
|
|
196
|
-
if (signal?.aborted) abort();
|
|
197
|
-
else signal?.addEventListener("abort", abort, { once: true });
|
|
198
|
-
|
|
199
|
-
return {
|
|
200
|
-
render,
|
|
201
|
-
invalidate: () => { cachedLines = undefined; },
|
|
202
|
-
handleInput,
|
|
203
|
-
dispose: () => signal?.removeEventListener("abort", abort),
|
|
204
|
-
};
|
|
205
|
-
},
|
|
206
|
-
);
|
|
207
|
-
|
|
208
|
-
if (!result) {
|
|
85
|
+
if (!answer) {
|
|
209
86
|
return {
|
|
210
87
|
content: [{ type: "text" as const, text: "User cancelled question" }],
|
|
211
88
|
details: { question, options, answer: null } satisfies QuestionDetails,
|
|
@@ -214,42 +91,16 @@ export default function askQuestionExtension(pi: ExtensionAPI): void {
|
|
|
214
91
|
return {
|
|
215
92
|
content: [{
|
|
216
93
|
type: "text" as const,
|
|
217
|
-
text:
|
|
94
|
+
text: wasCustom ? `User wrote: ${answer}` : `User selected: ${selectedIndex + 1}. ${answer}`,
|
|
218
95
|
}],
|
|
219
96
|
details: {
|
|
220
97
|
question,
|
|
221
98
|
options,
|
|
222
|
-
answer
|
|
223
|
-
wasCustom
|
|
224
|
-
selectedIndex:
|
|
99
|
+
answer,
|
|
100
|
+
wasCustom,
|
|
101
|
+
selectedIndex: wasCustom ? undefined : selectedIndex + 1,
|
|
225
102
|
} satisfies QuestionDetails,
|
|
226
103
|
};
|
|
227
104
|
},
|
|
228
|
-
|
|
229
|
-
renderCall(args, theme) {
|
|
230
|
-
let text = theme.fg("toolTitle", theme.bold("ask_question ")) + theme.fg("muted", args.question);
|
|
231
|
-
const options = Array.isArray(args.options) ? args.options : [];
|
|
232
|
-
if (options.length) {
|
|
233
|
-
const labels = options.map((option: QuestionOption) => option.label);
|
|
234
|
-
const numbered = [...labels, CUSTOM_OPTION_LABEL].map((option, index) => `${index + 1}. ${index === 0 ? withRecommended(option) : option}`);
|
|
235
|
-
text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
|
|
236
|
-
}
|
|
237
|
-
return new Text(text, 0, 0);
|
|
238
|
-
},
|
|
239
|
-
|
|
240
|
-
renderResult(result, _options, theme) {
|
|
241
|
-
const details = result.details as QuestionDetails | undefined;
|
|
242
|
-
if (!details) {
|
|
243
|
-
const content = result.content[0];
|
|
244
|
-
return new Text(content?.type === "text" ? content.text : "", 0, 0);
|
|
245
|
-
}
|
|
246
|
-
if (details.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
247
|
-
if (details.answer === null) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
248
|
-
if (details.wasCustom) {
|
|
249
|
-
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer), 0, 0);
|
|
250
|
-
}
|
|
251
|
-
const display = details.selectedIndex ? `${details.selectedIndex}. ${details.answer}` : details.answer;
|
|
252
|
-
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0);
|
|
253
|
-
},
|
|
254
105
|
});
|
|
255
106
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-ask-question",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Ask Pi users one interactive question with choices or a custom answer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.ts",
|
|
22
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.\"",
|
|
23
|
-
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/ask-question.ts",
|
|
24
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/ask-question.ts test/*.test.ts",
|
|
24
25
|
"pack:check": "npm pack --dry-run"
|
|
25
26
|
},
|
|
26
27
|
"peerDependencies": {
|
|
27
28
|
"@earendil-works/pi-coding-agent": ">=0.84.1",
|
|
28
|
-
"@earendil-works/pi-tui": ">=0.84.1",
|
|
29
29
|
"typebox": "^1.3.15"
|
|
30
30
|
},
|
|
31
31
|
"repository": {
|