@ryan_nookpi/pi-extension-ask-user-question 0.1.1 → 0.2.1
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 +78 -14
- package/constants.ts +10 -0
- package/controller.test.ts +281 -0
- package/controller.ts +280 -0
- package/form-ui.test.ts +57 -0
- package/form-ui.ts +38 -0
- package/index.test.ts +113 -0
- package/index.ts +62 -510
- package/output.test.ts +102 -0
- package/output.ts +89 -0
- package/package.json +2 -2
- package/schema.ts +36 -0
- package/state.test.ts +136 -0
- package/state.ts +131 -0
- package/types.ts +85 -0
- package/view.test.ts +337 -0
- package/view.ts +218 -0
package/index.ts
CHANGED
|
@@ -1,533 +1,85 @@
|
|
|
1
|
-
import type { ExtensionAPI
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
) {
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { runAskUserQuestionForm } from "./form-ui.ts";
|
|
4
|
+
import {
|
|
5
|
+
buildRenderCallText,
|
|
6
|
+
buildRenderResultText,
|
|
7
|
+
errorResult,
|
|
8
|
+
formatResultContent,
|
|
9
|
+
renderCall,
|
|
10
|
+
renderResult,
|
|
11
|
+
} from "./output.ts";
|
|
12
|
+
import { AskUserQuestionParams } from "./schema.ts";
|
|
13
|
+
import { normalizeQuestions } from "./state.ts";
|
|
14
|
+
import type { AskUserQuestionParamsInput, FormResult, Question } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
const TOOL_DESCRIPTION = `인터랙티브 폼으로 사용자에게 하나 이상의 질문을 묻습니다. 지원하는 질문 유형은 다음 세 가지입니다:
|
|
17
|
+
- **radio**: 미리 정의된 보기 중 하나를 고르는 단일 선택
|
|
18
|
+
- **checkbox**: 여러 보기를 동시에 고르는 복수 선택
|
|
19
|
+
- **text**: 자유롭게 입력하는 텍스트 답변
|
|
20
|
+
|
|
21
|
+
radio/checkbox 질문에는 사용자가 직접 값을 입력할 수 있는 "기타..." 옵션을 포함할 수 있습니다.
|
|
22
|
+
|
|
23
|
+
요구사항을 확인하거나, 선호도를 물어보거나, 구현 방향에 대한 결정을 받아야 할 때 사용하세요. 일반 텍스트로 질문을 던지는 대신 이 도구를 우선 사용합니다.`;
|
|
24
|
+
|
|
25
|
+
const PROMPT_GUIDELINES = [
|
|
26
|
+
"구조화된 사용자 입력이 필요하면 일반 텍스트 질문 대신 ask_user_question을 사용하세요.",
|
|
27
|
+
"단일 선택은 radio, 복수 선택은 checkbox, 서술형 답변은 text를 우선 사용하세요.",
|
|
28
|
+
"선택지가 완전히 닫혀 있지 않다면 allowOther: true로 '기타' 입력 경로를 열어두세요.",
|
|
29
|
+
"관련 질문은 여러 번 나누지 말고 한 번의 호출에 묶어 전달하세요.",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function buildCancelledResponse(result: FormResult) {
|
|
120
33
|
return {
|
|
121
|
-
content: [{ type: "text" as const, text: "
|
|
122
|
-
details:
|
|
123
|
-
question,
|
|
124
|
-
context,
|
|
125
|
-
options,
|
|
126
|
-
allowCustomAnswer,
|
|
127
|
-
allowMultiple,
|
|
128
|
-
cancelled: true,
|
|
129
|
-
}),
|
|
34
|
+
content: [{ type: "text" as const, text: "사용자가 입력 폼을 취소했습니다" }],
|
|
35
|
+
details: result,
|
|
130
36
|
};
|
|
131
37
|
}
|
|
132
38
|
|
|
133
|
-
function
|
|
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
|
-
}
|
|
39
|
+
function buildSuccessResponse(result: FormResult) {
|
|
290
40
|
return {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
selectedIndices: Array.from(selectedOptionIndices)
|
|
294
|
-
.sort((a, b) => a - b)
|
|
295
|
-
.map((index) => index + 1),
|
|
296
|
-
customInput: customAnswers.length > 0 ? customAnswers.join(", ") : undefined,
|
|
41
|
+
content: [{ type: "text" as const, text: formatResultContent(result) }],
|
|
42
|
+
details: result,
|
|
297
43
|
};
|
|
298
44
|
}
|
|
299
45
|
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
}
|
|
46
|
+
export type { AskUserQuestionParamsInput, FormResult, Question };
|
|
47
|
+
export { AskUserQuestionParams, buildRenderCallText, buildRenderResultText, errorResult, normalizeQuestions };
|
|
318
48
|
|
|
319
|
-
|
|
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) {
|
|
49
|
+
export default function askUserQuestion(pi: ExtensionAPI) {
|
|
374
50
|
pi.registerTool({
|
|
375
|
-
name: "
|
|
376
|
-
label: "
|
|
377
|
-
description:
|
|
378
|
-
|
|
379
|
-
|
|
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',
|
|
51
|
+
name: "ask_user_question",
|
|
52
|
+
label: "사용자 질문",
|
|
53
|
+
description: TOOL_DESCRIPTION,
|
|
54
|
+
promptSnippet: "radio, checkbox, text 입력을 사용하는 인터랙티브 질문 폼 열기",
|
|
55
|
+
promptGuidelines: PROMPT_GUIDELINES,
|
|
388
56
|
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
|
-
|
|
57
|
+
async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
|
|
398
58
|
if (!ctx.hasUI) {
|
|
399
|
-
return
|
|
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
|
-
};
|
|
59
|
+
return errorResult("오류: UI를 사용할 수 없습니다. 현재 비대화형 모드에서 실행 중입니다.");
|
|
436
60
|
}
|
|
437
61
|
|
|
438
|
-
const
|
|
439
|
-
if (!
|
|
440
|
-
return
|
|
62
|
+
const params = rawParams as AskUserQuestionParamsInput;
|
|
63
|
+
if (!params.questions.length) {
|
|
64
|
+
return errorResult("오류: 질문이 제공되지 않았습니다.");
|
|
441
65
|
}
|
|
442
66
|
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
};
|
|
67
|
+
const questions = normalizeQuestions(params.questions as Question[]);
|
|
68
|
+
const result = await runAskUserQuestionForm(
|
|
69
|
+
ctx,
|
|
70
|
+
{ title: params.title, description: params.description },
|
|
71
|
+
questions,
|
|
72
|
+
);
|
|
73
|
+
return result.cancelled ? buildCancelledResponse(result) : buildSuccessResponse(result);
|
|
466
74
|
},
|
|
467
|
-
|
|
468
75
|
renderCall(args, theme) {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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);
|
|
76
|
+
return renderCall(
|
|
77
|
+
{ questions: args.questions as Question[] | undefined, title: args.title as string | undefined },
|
|
78
|
+
theme,
|
|
79
|
+
);
|
|
489
80
|
},
|
|
490
|
-
|
|
491
81
|
renderResult(result, _options, theme) {
|
|
492
|
-
|
|
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
|
-
);
|
|
82
|
+
return renderResult(result as { content?: Array<{ type: string; text?: string }>; details?: FormResult }, theme);
|
|
531
83
|
},
|
|
532
84
|
});
|
|
533
85
|
}
|