@ryan_nookpi/pi-extension-ask-user-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/README.md +15 -0
- package/index.ts +533 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @ryan_nookpi/pi-extension-ask-user-question
|
|
2
|
+
|
|
3
|
+
Independent pi package for the `AskUserQuestion` extension.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install /Users/creatrip/Documents/pi-extension/packages/ask-user-question
|
|
9
|
+
pi install npm:@ryan_nookpi/pi-extension-ask-user-question
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What it provides
|
|
13
|
+
|
|
14
|
+
- `AskUserQuestion` tool
|
|
15
|
+
- `./index.ts` entry
|
package/index.ts
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
|
|
5
|
+
interface AskUserQuestionDetails {
|
|
6
|
+
question: string;
|
|
7
|
+
context?: string;
|
|
8
|
+
options: string[];
|
|
9
|
+
allowCustomAnswer: boolean;
|
|
10
|
+
allowMultiple: boolean;
|
|
11
|
+
answer: string | null;
|
|
12
|
+
answers: string[];
|
|
13
|
+
selectedOption?: string;
|
|
14
|
+
selectedOptions?: string[];
|
|
15
|
+
selectedIndex?: number;
|
|
16
|
+
selectedIndices?: number[];
|
|
17
|
+
customInput?: string;
|
|
18
|
+
cancelled: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const AskUserQuestionParams = Type.Object({
|
|
22
|
+
question: Type.String({ description: "Question to ask the user" }),
|
|
23
|
+
context: Type.Optional(Type.String({ description: "Optional extra context shown to the user" })),
|
|
24
|
+
options: Type.Optional(Type.Array(Type.String(), { description: "Optional predefined options" })),
|
|
25
|
+
allowCustomAnswer: Type.Optional(
|
|
26
|
+
Type.Boolean({ description: "If true, allow typing a custom answer when options are provided", default: true }),
|
|
27
|
+
),
|
|
28
|
+
allowMultiple: Type.Optional(
|
|
29
|
+
Type.Boolean({ description: "If true, allow selecting multiple options", default: false }),
|
|
30
|
+
),
|
|
31
|
+
placeholder: Type.Optional(Type.String({ description: "Optional input placeholder for typed answers" })),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const OTHER_OPTION_LABEL = "Other (type your own)";
|
|
35
|
+
const DONE_OPTION_LABEL = "Done selecting";
|
|
36
|
+
|
|
37
|
+
function stripMarkdownForDisplay(text: string): string {
|
|
38
|
+
return text
|
|
39
|
+
.replace(/\r\n/g, "\n")
|
|
40
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, "")
|
|
41
|
+
.replace(/^\s{0,3}>\s?/gm, "")
|
|
42
|
+
.replace(/^\s*[-*+]\s+/gm, "• ")
|
|
43
|
+
.replace(/^\s*\d+\.\s+/gm, "• ")
|
|
44
|
+
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1")
|
|
45
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)")
|
|
46
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
47
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
48
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
49
|
+
.replace(/~~([^~]+)~~/g, "$1")
|
|
50
|
+
.replace(/(^|[^*])\*([^*\n]+)\*(?=[^*]|$)/g, "$1$2")
|
|
51
|
+
.replace(/(^|[^_])_([^_\n]+)_(?=[^_]|$)/g, "$1$2")
|
|
52
|
+
.replace(/^\s*([-*_]\s*){3,}$/gm, "")
|
|
53
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
54
|
+
.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toDisplayText(text: string): string {
|
|
58
|
+
const stripped = stripMarkdownForDisplay(text).trim();
|
|
59
|
+
return stripped || text.trim() || "(empty)";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildDisplayOptions(options: string[]): string[] {
|
|
63
|
+
const base = options.map((option) => toDisplayText(option));
|
|
64
|
+
const counts = new Map<string, number>();
|
|
65
|
+
for (const label of base) counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
66
|
+
return base.map((label, index) => ((counts.get(label) ?? 0) > 1 ? `${index + 1}. ${label}` : label));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeOptions(raw: unknown): string[] {
|
|
70
|
+
if (!Array.isArray(raw)) return [];
|
|
71
|
+
const dedup = new Set<string>();
|
|
72
|
+
for (const item of raw) {
|
|
73
|
+
if (typeof item !== "string") continue;
|
|
74
|
+
const normalized = item.trim();
|
|
75
|
+
if (!normalized) continue;
|
|
76
|
+
dedup.add(normalized);
|
|
77
|
+
}
|
|
78
|
+
return Array.from(dedup);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildPrompt(question: string, context?: string): string {
|
|
82
|
+
const normalizedQuestion = toDisplayText(question);
|
|
83
|
+
const ctx = typeof context === "string" ? toDisplayText(context) : "";
|
|
84
|
+
if (!ctx) return normalizedQuestion;
|
|
85
|
+
return `${normalizedQuestion}\n\n${ctx}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clampLines(text: string, maxLines: number): string {
|
|
89
|
+
const lines = text.split("\n");
|
|
90
|
+
if (lines.length <= maxLines) return text;
|
|
91
|
+
const hidden = lines.length - maxLines;
|
|
92
|
+
const visible = lines.slice(0, maxLines);
|
|
93
|
+
const lastIndex = visible.length - 1;
|
|
94
|
+
visible[lastIndex] = `${visible[lastIndex]} … (+${hidden} lines)`;
|
|
95
|
+
return visible.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildDetails(
|
|
99
|
+
base: Omit<AskUserQuestionDetails, "answer" | "answers" | "cancelled"> & {
|
|
100
|
+
answer?: string | null;
|
|
101
|
+
answers?: string[];
|
|
102
|
+
cancelled?: boolean;
|
|
103
|
+
},
|
|
104
|
+
): AskUserQuestionDetails {
|
|
105
|
+
return {
|
|
106
|
+
...base,
|
|
107
|
+
answer: base.answer ?? null,
|
|
108
|
+
answers: base.answers ?? [],
|
|
109
|
+
cancelled: base.cancelled ?? false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildCancelledResult(
|
|
114
|
+
question: string,
|
|
115
|
+
context: string | undefined,
|
|
116
|
+
options: string[],
|
|
117
|
+
allowCustomAnswer: boolean,
|
|
118
|
+
allowMultiple: boolean,
|
|
119
|
+
) {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text" as const, text: "User cancelled AskUserQuestion." }],
|
|
122
|
+
details: buildDetails({
|
|
123
|
+
question,
|
|
124
|
+
context,
|
|
125
|
+
options,
|
|
126
|
+
allowCustomAnswer,
|
|
127
|
+
allowMultiple,
|
|
128
|
+
cancelled: true,
|
|
129
|
+
}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildExecutionParams(params: Record<string, unknown>): AskUserQuestionExecutionParams {
|
|
134
|
+
return {
|
|
135
|
+
question: typeof params.question === "string" ? params.question.trim() : "",
|
|
136
|
+
context: typeof params.context === "string" ? params.context.trim() : undefined,
|
|
137
|
+
options: normalizeOptions(params.options),
|
|
138
|
+
allowCustomAnswer: typeof params.allowCustomAnswer === "boolean" ? params.allowCustomAnswer : true,
|
|
139
|
+
allowMultiple: typeof params.allowMultiple === "boolean" ? params.allowMultiple : false,
|
|
140
|
+
placeholder: typeof params.placeholder === "string" ? params.placeholder : "",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildAskUserQuestionErrorResult(message: string, params: AskUserQuestionExecutionParams) {
|
|
145
|
+
return {
|
|
146
|
+
content: [{ type: "text" as const, text: message }],
|
|
147
|
+
details: buildDetails({
|
|
148
|
+
question: params.question,
|
|
149
|
+
context: params.context,
|
|
150
|
+
options: params.options,
|
|
151
|
+
allowCustomAnswer: params.allowCustomAnswer,
|
|
152
|
+
allowMultiple: params.allowMultiple,
|
|
153
|
+
cancelled: true,
|
|
154
|
+
}),
|
|
155
|
+
isError: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function askSingleChoice(
|
|
160
|
+
ctx: ExtensionContext,
|
|
161
|
+
params: AskUserQuestionExecutionParams,
|
|
162
|
+
): Promise<AskSingleChoiceResult | undefined> {
|
|
163
|
+
if (params.options.length > 0) {
|
|
164
|
+
const displayOptions = buildDisplayOptions(params.options);
|
|
165
|
+
const selectable = params.allowCustomAnswer ? [...displayOptions, OTHER_OPTION_LABEL] : [...displayOptions];
|
|
166
|
+
const selected = await ctx.ui.select(buildPrompt(params.question, params.context), selectable);
|
|
167
|
+
if (selected === undefined) return undefined;
|
|
168
|
+
if (selected === OTHER_OPTION_LABEL) {
|
|
169
|
+
const customInput = await promptForCustomInput(ctx, params.placeholder);
|
|
170
|
+
if (customInput === undefined) return undefined;
|
|
171
|
+
return {
|
|
172
|
+
answer: customInput,
|
|
173
|
+
selectedOption: "custom",
|
|
174
|
+
customInput,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const optionIndex = displayOptions.indexOf(selected);
|
|
179
|
+
return {
|
|
180
|
+
answer: optionIndex >= 0 ? params.options[optionIndex] : selected,
|
|
181
|
+
selectedOption: optionIndex >= 0 ? params.options[optionIndex] : selected,
|
|
182
|
+
selectedIndex: optionIndex >= 0 ? optionIndex + 1 : undefined,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const answer = await ctx.ui.input(buildPrompt(params.question, params.context), params.placeholder);
|
|
187
|
+
if (answer === undefined) return undefined;
|
|
188
|
+
return {
|
|
189
|
+
answer,
|
|
190
|
+
customInput: answer.trim() || undefined,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type MultiSelectResult = {
|
|
195
|
+
cancelled: boolean;
|
|
196
|
+
answers: string[];
|
|
197
|
+
selectedIndices: number[];
|
|
198
|
+
customInput?: string;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
type AskSingleChoiceResult = {
|
|
202
|
+
answer: string;
|
|
203
|
+
selectedOption?: string;
|
|
204
|
+
selectedIndex?: number;
|
|
205
|
+
customInput?: string;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
type AskUserQuestionExecutionParams = {
|
|
209
|
+
question: string;
|
|
210
|
+
context?: string;
|
|
211
|
+
options: string[];
|
|
212
|
+
allowCustomAnswer: boolean;
|
|
213
|
+
allowMultiple: boolean;
|
|
214
|
+
placeholder: string;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
type OptionEntry =
|
|
218
|
+
| { kind: "option"; label: string; optionIndex: number }
|
|
219
|
+
| { kind: "custom"; label: string; customIndex: number }
|
|
220
|
+
| { kind: "other"; label: string }
|
|
221
|
+
| { kind: "done"; label: string };
|
|
222
|
+
|
|
223
|
+
async function promptForCustomInput(ctx: ExtensionContext, placeholder: string): Promise<string | undefined> {
|
|
224
|
+
const answer = await ctx.ui.input("Your answer", placeholder);
|
|
225
|
+
if (answer === undefined) return undefined;
|
|
226
|
+
const normalized = answer.trim();
|
|
227
|
+
if (!normalized) {
|
|
228
|
+
ctx.ui.notify("Empty custom answer ignored.", "warning");
|
|
229
|
+
return "";
|
|
230
|
+
}
|
|
231
|
+
return normalized;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildMultiSelectEntries(
|
|
235
|
+
displayOptions: string[],
|
|
236
|
+
selectedOptionIndices: Set<number>,
|
|
237
|
+
allowCustomAnswer: boolean,
|
|
238
|
+
customAnswers: string[],
|
|
239
|
+
): OptionEntry[] {
|
|
240
|
+
const entries: OptionEntry[] = displayOptions.map((option, index) => ({
|
|
241
|
+
kind: "option",
|
|
242
|
+
optionIndex: index,
|
|
243
|
+
label: `${selectedOptionIndices.has(index) ? "☑" : "☐"} ${index + 1}. ${option}`,
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
if (!allowCustomAnswer) {
|
|
247
|
+
return entries;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
entries.push({ kind: "other", label: OTHER_OPTION_LABEL });
|
|
251
|
+
customAnswers.forEach((answer, index) => {
|
|
252
|
+
entries.push({ kind: "custom", customIndex: index, label: `☑ custom ${index + 1}. ${answer}` });
|
|
253
|
+
});
|
|
254
|
+
return entries;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function buildSelectedSummary(
|
|
258
|
+
displayOptions: string[],
|
|
259
|
+
selectedOptionIndices: Set<number>,
|
|
260
|
+
customAnswers: string[],
|
|
261
|
+
): string[] {
|
|
262
|
+
return [
|
|
263
|
+
...Array.from(selectedOptionIndices)
|
|
264
|
+
.sort((a, b) => a - b)
|
|
265
|
+
.map((index) => displayOptions[index]),
|
|
266
|
+
...customAnswers,
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function findSelectedEntry(entries: OptionEntry[], choice: string): OptionEntry | undefined {
|
|
271
|
+
return entries.find((entry) => entry.label === choice);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function finalizeMultiSelect(
|
|
275
|
+
ctx: ExtensionContext,
|
|
276
|
+
options: string[],
|
|
277
|
+
selectedOptionIndices: Set<number>,
|
|
278
|
+
customAnswers: string[],
|
|
279
|
+
): MultiSelectResult | null {
|
|
280
|
+
const answers = [
|
|
281
|
+
...Array.from(selectedOptionIndices)
|
|
282
|
+
.sort((a, b) => a - b)
|
|
283
|
+
.map((index) => options[index]),
|
|
284
|
+
...customAnswers,
|
|
285
|
+
];
|
|
286
|
+
if (answers.length === 0) {
|
|
287
|
+
ctx.ui.notify("Select at least one option before finishing.", "warning");
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
cancelled: false,
|
|
292
|
+
answers,
|
|
293
|
+
selectedIndices: Array.from(selectedOptionIndices)
|
|
294
|
+
.sort((a, b) => a - b)
|
|
295
|
+
.map((index) => index + 1),
|
|
296
|
+
customInput: customAnswers.length > 0 ? customAnswers.join(", ") : undefined,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function handleMultiSelectEntry(
|
|
301
|
+
ctx: ExtensionContext,
|
|
302
|
+
entry: OptionEntry,
|
|
303
|
+
selectedOptionIndices: Set<number>,
|
|
304
|
+
customAnswers: string[],
|
|
305
|
+
options: string[],
|
|
306
|
+
placeholder: string,
|
|
307
|
+
): Promise<MultiSelectResult | null> {
|
|
308
|
+
if (entry.kind === "option") {
|
|
309
|
+
if (selectedOptionIndices.has(entry.optionIndex)) selectedOptionIndices.delete(entry.optionIndex);
|
|
310
|
+
else selectedOptionIndices.add(entry.optionIndex);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (entry.kind === "custom") {
|
|
315
|
+
customAnswers.splice(entry.customIndex, 1);
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (entry.kind === "other") {
|
|
320
|
+
const customAnswer = await promptForCustomInput(ctx, placeholder);
|
|
321
|
+
if (customAnswer && !customAnswers.includes(customAnswer)) {
|
|
322
|
+
customAnswers.push(customAnswer);
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return finalizeMultiSelect(ctx, options, selectedOptionIndices, customAnswers);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function askMultipleOptions(
|
|
331
|
+
ctx: ExtensionContext,
|
|
332
|
+
question: string,
|
|
333
|
+
context: string | undefined,
|
|
334
|
+
options: string[],
|
|
335
|
+
allowCustomAnswer: boolean,
|
|
336
|
+
placeholder: string,
|
|
337
|
+
): Promise<MultiSelectResult> {
|
|
338
|
+
const selectedOptionIndices = new Set<number>();
|
|
339
|
+
const customAnswers: string[] = [];
|
|
340
|
+
const displayOptions = options.map((option) => toDisplayText(option));
|
|
341
|
+
|
|
342
|
+
while (true) {
|
|
343
|
+
const selectedSummary = buildSelectedSummary(displayOptions, selectedOptionIndices, customAnswers);
|
|
344
|
+
const entries = buildMultiSelectEntries(displayOptions, selectedOptionIndices, allowCustomAnswer, customAnswers);
|
|
345
|
+
entries.push({ kind: "done", label: `${DONE_OPTION_LABEL} (${selectedSummary.length} selected)` });
|
|
346
|
+
|
|
347
|
+
const choice = await ctx.ui.select(
|
|
348
|
+
`${buildPrompt(question, context)}\n\nSelected: ${selectedSummary.length > 0 ? selectedSummary.join(", ") : "(none)"}`,
|
|
349
|
+
entries.map((entry) => entry.label),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
if (choice === undefined) {
|
|
353
|
+
return { cancelled: true, answers: [], selectedIndices: [] };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const selectedEntry = findSelectedEntry(entries, choice);
|
|
357
|
+
if (!selectedEntry) continue;
|
|
358
|
+
|
|
359
|
+
const result = await handleMultiSelectEntry(
|
|
360
|
+
ctx,
|
|
361
|
+
selectedEntry,
|
|
362
|
+
selectedOptionIndices,
|
|
363
|
+
customAnswers,
|
|
364
|
+
options,
|
|
365
|
+
placeholder,
|
|
366
|
+
);
|
|
367
|
+
if (result) {
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export default function askUserQuestionExtension(pi: ExtensionAPI) {
|
|
374
|
+
pi.registerTool({
|
|
375
|
+
name: "AskUserQuestion",
|
|
376
|
+
label: "AskUserQuestion",
|
|
377
|
+
description:
|
|
378
|
+
"Ask the user a question and wait for their response. Use this when you need to:\n" +
|
|
379
|
+
"1. Gather user preferences or requirements\n" +
|
|
380
|
+
"2. Clarify ambiguous instructions\n" +
|
|
381
|
+
"3. Get decisions on implementation choices as you work\n" +
|
|
382
|
+
"4. Offer choices to the user about what direction to take\n\n" +
|
|
383
|
+
"Usage notes:\n" +
|
|
384
|
+
'- Users will always be able to select "Other" to provide custom text input\n' +
|
|
385
|
+
"- Use allowMultiple: true to allow multiple answers to be selected for a question\n" +
|
|
386
|
+
'- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label\n' +
|
|
387
|
+
'- Do NOT use this tool to ask "Should I proceed?" or seek unnecessary confirmation — just proceed with the task',
|
|
388
|
+
parameters: AskUserQuestionParams,
|
|
389
|
+
|
|
390
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
391
|
+
const executionParams = buildExecutionParams(params as Record<string, unknown>);
|
|
392
|
+
const { question, context, options, allowCustomAnswer, allowMultiple } = executionParams;
|
|
393
|
+
|
|
394
|
+
if (!question) {
|
|
395
|
+
return buildAskUserQuestionErrorResult("AskUserQuestion requires a non-empty question.", executionParams);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!ctx.hasUI) {
|
|
399
|
+
return buildAskUserQuestionErrorResult(
|
|
400
|
+
"AskUserQuestion requires interactive mode (UI unavailable).",
|
|
401
|
+
executionParams,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
ctx.ui.notify("Waiting for input", "info");
|
|
406
|
+
|
|
407
|
+
if (allowMultiple && options.length > 0) {
|
|
408
|
+
const multipleResult = await askMultipleOptions(
|
|
409
|
+
ctx,
|
|
410
|
+
question,
|
|
411
|
+
context,
|
|
412
|
+
options,
|
|
413
|
+
allowCustomAnswer,
|
|
414
|
+
executionParams.placeholder,
|
|
415
|
+
);
|
|
416
|
+
if (multipleResult.cancelled) {
|
|
417
|
+
return buildCancelledResult(question, context, options, allowCustomAnswer, allowMultiple);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const answerText = multipleResult.answers.join(", ");
|
|
421
|
+
return {
|
|
422
|
+
content: [{ type: "text" as const, text: answerText }],
|
|
423
|
+
details: buildDetails({
|
|
424
|
+
question,
|
|
425
|
+
context,
|
|
426
|
+
options,
|
|
427
|
+
allowCustomAnswer,
|
|
428
|
+
allowMultiple,
|
|
429
|
+
answer: answerText,
|
|
430
|
+
answers: multipleResult.answers,
|
|
431
|
+
selectedOptions: multipleResult.answers,
|
|
432
|
+
selectedIndices: multipleResult.selectedIndices,
|
|
433
|
+
customInput: multipleResult.customInput,
|
|
434
|
+
}),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const singleChoice = await askSingleChoice(ctx, executionParams);
|
|
439
|
+
if (!singleChoice) {
|
|
440
|
+
return buildCancelledResult(question, context, options, allowCustomAnswer, allowMultiple);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const normalizedAnswer = singleChoice.answer.trim();
|
|
444
|
+
return {
|
|
445
|
+
content: [{ type: "text" as const, text: normalizedAnswer || "(empty answer)" }],
|
|
446
|
+
details: buildDetails({
|
|
447
|
+
question,
|
|
448
|
+
context,
|
|
449
|
+
options,
|
|
450
|
+
allowCustomAnswer,
|
|
451
|
+
allowMultiple,
|
|
452
|
+
answer: normalizedAnswer,
|
|
453
|
+
answers: [normalizedAnswer],
|
|
454
|
+
selectedOption: singleChoice.selectedOption,
|
|
455
|
+
selectedOptions:
|
|
456
|
+
singleChoice.selectedOption && singleChoice.selectedOption !== "custom"
|
|
457
|
+
? [singleChoice.selectedOption]
|
|
458
|
+
: normalizedAnswer
|
|
459
|
+
? [normalizedAnswer]
|
|
460
|
+
: [],
|
|
461
|
+
selectedIndex: singleChoice.selectedIndex,
|
|
462
|
+
selectedIndices: singleChoice.selectedIndex ? [singleChoice.selectedIndex] : undefined,
|
|
463
|
+
customInput: singleChoice.selectedOption === "custom" ? normalizedAnswer : singleChoice.customInput,
|
|
464
|
+
}),
|
|
465
|
+
};
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
renderCall(args, theme) {
|
|
469
|
+
const question = typeof args.question === "string" ? toDisplayText(args.question) : "(no question)";
|
|
470
|
+
const context = typeof args.context === "string" && args.context.trim() ? toDisplayText(args.context) : "";
|
|
471
|
+
const options = buildDisplayOptions(normalizeOptions(args.options));
|
|
472
|
+
const allowCustomAnswer = args.allowCustomAnswer ?? true;
|
|
473
|
+
const allowMultiple = args.allowMultiple ?? false;
|
|
474
|
+
|
|
475
|
+
let text = `${theme.fg("toolTitle", theme.bold("AskUserQuestion"))} ${theme.fg("accent", question)}`;
|
|
476
|
+
if (options.length > 0) {
|
|
477
|
+
const renderedOptions = allowCustomAnswer ? [...options, "Other"] : options;
|
|
478
|
+
text += `\n${theme.fg("dim", `options:${renderedOptions.length}${allowMultiple ? " · multi" : ""}`)}`;
|
|
479
|
+
for (let i = 0; i < Math.min(renderedOptions.length, 4); i++) {
|
|
480
|
+
text += `\n${theme.fg("muted", `- ${renderedOptions[i]}`)}`;
|
|
481
|
+
}
|
|
482
|
+
if (renderedOptions.length > 4) {
|
|
483
|
+
text += `\n${theme.fg("muted", `… ${renderedOptions.length - 4} more`)}`;
|
|
484
|
+
}
|
|
485
|
+
} else if (context) {
|
|
486
|
+
text += `\n${theme.fg("muted", context)}`;
|
|
487
|
+
}
|
|
488
|
+
return new Text(clampLines(text, 6), 0, 0);
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
renderResult(result, _options, theme) {
|
|
492
|
+
const details = result.details as AskUserQuestionDetails | undefined;
|
|
493
|
+
if (!details) {
|
|
494
|
+
const text = result.content[0];
|
|
495
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (details.cancelled) {
|
|
499
|
+
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (details.allowMultiple) {
|
|
503
|
+
const answers = details.answers?.filter((answer) => answer?.trim()) ?? [];
|
|
504
|
+
let text =
|
|
505
|
+
theme.fg("success", "✓ ") +
|
|
506
|
+
theme.fg("muted", `selected ${answers.length}: `) +
|
|
507
|
+
theme.fg("accent", answers.length > 0 ? answers.join(", ") : "(none)");
|
|
508
|
+
if (details.customInput) {
|
|
509
|
+
text += `\n${theme.fg("dim", `custom: ${details.customInput}`)}`;
|
|
510
|
+
}
|
|
511
|
+
return new Text(clampLines(text, 3), 0, 0);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const answerText = details.answer ?? "";
|
|
515
|
+
if (details.selectedOption && details.selectedOption !== "custom") {
|
|
516
|
+
const indexPrefix = details.selectedIndex ? `${details.selectedIndex}. ` : "";
|
|
517
|
+
return new Text(
|
|
518
|
+
theme.fg("success", "✓ ") +
|
|
519
|
+
theme.fg("muted", "selected ") +
|
|
520
|
+
theme.fg("accent", `${indexPrefix}${answerText}`),
|
|
521
|
+
0,
|
|
522
|
+
0,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return new Text(
|
|
527
|
+
theme.fg("success", "✓ ") + theme.fg("muted", "answered ") + theme.fg("accent", answerText || "(empty answer)"),
|
|
528
|
+
0,
|
|
529
|
+
0,
|
|
530
|
+
);
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ryan_nookpi/pi-extension-ask-user-question",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AskUserQuestion tool extension for pi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
"index.ts",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": [
|
|
15
|
+
"./index.ts"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
20
|
+
"@mariozechner/pi-tui": "*",
|
|
21
|
+
"@sinclair/typebox": "*"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
}
|
|
26
|
+
}
|