@pi-archimedes/ask 2.2.0 → 2.3.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/package.json +4 -2
- package/src/index.ts +8 -430
- package/src/ipc-relay.ts +87 -0
- package/src/tool.ts +370 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/ask",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
],
|
|
12
12
|
"main": "./src/index.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@pi-archimedes/core": "2.
|
|
14
|
+
"@pi-archimedes/core": "2.3.0"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
17
17
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"typebox": ">=1.1.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
23
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
22
24
|
"typebox": "^1.1.38",
|
|
23
25
|
"typescript": "^6.0.0"
|
|
24
26
|
},
|
package/src/index.ts
CHANGED
|
@@ -1,217 +1,11 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { connect } from "node:net";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { OTHER_OPTION, type AskQuestion, type AskSelection } from "./selection.js";
|
|
7
|
-
import { askSingleQuestionWithInlineNote } from "./picker.js";
|
|
8
|
-
import { askQuestionsWithTabs } from "./dialog.js";
|
|
9
|
-
|
|
10
|
-
const OptionItemSchema = Type.Object({
|
|
11
|
-
label: Type.String({ description: "Display label" }),
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
const QuestionItemSchema = Type.Object({
|
|
15
|
-
id: Type.String({ description: "Question id (e.g. auth, cache, priority)" }),
|
|
16
|
-
question: Type.String({ description: "Question text" }),
|
|
17
|
-
description: Type.Optional(
|
|
18
|
-
Type.String({
|
|
19
|
-
description:
|
|
20
|
-
"Optional context in Markdown/plain text. Rendered above options with wrapping (supports headings/lists/code blocks).",
|
|
21
|
-
}),
|
|
22
|
-
),
|
|
23
|
-
options: Type.Array(OptionItemSchema, {
|
|
24
|
-
description: "Available options. Do not include 'Other'.",
|
|
25
|
-
minItems: 1,
|
|
26
|
-
}),
|
|
27
|
-
multi: Type.Optional(Type.Boolean({ description: "Allow multi-select" })),
|
|
28
|
-
recommended: Type.Optional(
|
|
29
|
-
Type.Number({ description: "0-indexed recommended option. '(Recommended)' is shown automatically." }),
|
|
30
|
-
),
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
const AskParamsSchema = Type.Object({
|
|
34
|
-
questions: Type.Array(QuestionItemSchema, { description: "Questions to ask", minItems: 1 }),
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
type AskParams = Static<typeof AskParamsSchema>;
|
|
38
|
-
|
|
39
|
-
interface QuestionResult {
|
|
40
|
-
id: string;
|
|
41
|
-
question: string;
|
|
42
|
-
description: string | undefined;
|
|
43
|
-
options: string[];
|
|
44
|
-
multi: boolean;
|
|
45
|
-
selectedOptions: string[];
|
|
46
|
-
customInput: string | undefined;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface AskToolDetails {
|
|
50
|
-
id?: string;
|
|
51
|
-
question?: string;
|
|
52
|
-
description: string | undefined;
|
|
53
|
-
options?: string[];
|
|
54
|
-
multi?: boolean;
|
|
55
|
-
selectedOptions?: string[];
|
|
56
|
-
customInput: string | undefined;
|
|
57
|
-
results?: QuestionResult[];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function sanitizeForSessionText(value: string): string {
|
|
61
|
-
return value
|
|
62
|
-
.replace(/[\r\n\t]/g, " ")
|
|
63
|
-
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
|
64
|
-
.replace(/\s{2,}/g, " ")
|
|
65
|
-
.trim();
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function sanitizeMultilineForSessionText(value: string): string {
|
|
69
|
-
return value
|
|
70
|
-
.replace(/\r\n/g, "\n")
|
|
71
|
-
.replace(/\r/g, "\n")
|
|
72
|
-
.split("\n")
|
|
73
|
-
.map((line) => sanitizeForSessionText(line))
|
|
74
|
-
.join("\n")
|
|
75
|
-
.trim();
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function sanitizeOptionForSessionText(option: string): string {
|
|
79
|
-
const sanitizedOption = sanitizeForSessionText(option);
|
|
80
|
-
return sanitizedOption.length > 0 ? sanitizedOption : "(empty option)";
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
|
|
84
|
-
const selectedOptions = result.selectedOptions
|
|
85
|
-
.map((selectedOption) => sanitizeForSessionText(selectedOption))
|
|
86
|
-
.filter((selectedOption) => selectedOption.length > 0);
|
|
87
|
-
|
|
88
|
-
const rawDescription = result.description;
|
|
89
|
-
const description = rawDescription == null ? undefined : sanitizeMultilineForSessionText(rawDescription);
|
|
90
|
-
const rawCustomInput = result.customInput;
|
|
91
|
-
const customInput = rawCustomInput == null ? undefined : sanitizeForSessionText(rawCustomInput);
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
id: sanitizeForSessionText(result.id) || "(unknown)",
|
|
95
|
-
question: sanitizeForSessionText(result.question) || "(empty question)",
|
|
96
|
-
description: description && description.length > 0 ? description : undefined,
|
|
97
|
-
options: result.options.map(sanitizeOptionForSessionText),
|
|
98
|
-
multi: result.multi,
|
|
99
|
-
selectedOptions,
|
|
100
|
-
customInput: customInput && customInput.length > 0 ? customInput : undefined,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function formatSelectionForSummary(result: QuestionResult): string {
|
|
105
|
-
const hasSelectedOptions = result.selectedOptions.length > 0;
|
|
106
|
-
const hasCustomInput = Boolean(result.customInput);
|
|
107
|
-
|
|
108
|
-
if (!hasSelectedOptions && !hasCustomInput) {
|
|
109
|
-
return "(cancelled)";
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (hasSelectedOptions && hasCustomInput) {
|
|
113
|
-
const selectedPart = result.multi
|
|
114
|
-
? `[${result.selectedOptions.join(", ")}]`
|
|
115
|
-
: result.selectedOptions[0] ?? "";
|
|
116
|
-
return `${selectedPart} + Other: "${result.customInput}"`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (hasCustomInput) {
|
|
120
|
-
return `"${result.customInput}"`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (result.multi) {
|
|
124
|
-
return `[${result.selectedOptions.join(", ")}]`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return result.selectedOptions[0] ?? "";
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function formatQuestionResult(result: QuestionResult): string {
|
|
131
|
-
return `${result.id}: ${formatSelectionForSummary(result)}`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function formatQuestionContext(result: QuestionResult, questionIndex: number): string {
|
|
135
|
-
const lines: string[] = [`Question ${questionIndex + 1} (${result.id})`, `Prompt: ${result.question}`];
|
|
136
|
-
|
|
137
|
-
if (result.description) {
|
|
138
|
-
lines.push("Context:");
|
|
139
|
-
for (const descriptionLine of result.description.split("\n")) {
|
|
140
|
-
lines.push(` ${descriptionLine}`);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
lines.push("Options:");
|
|
145
|
-
lines.push(...result.options.map((option, optionIndex) => ` ${optionIndex + 1}. ${option}`));
|
|
146
|
-
lines.push("Response:");
|
|
147
|
-
|
|
148
|
-
const hasSelectedOptions = result.selectedOptions.length > 0;
|
|
149
|
-
const hasCustomInput = Boolean(result.customInput);
|
|
150
|
-
|
|
151
|
-
if (!hasSelectedOptions && !hasCustomInput) {
|
|
152
|
-
lines.push(" Selected: (cancelled)");
|
|
153
|
-
return lines.join("\n");
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (hasSelectedOptions) {
|
|
157
|
-
const selectedText = result.multi
|
|
158
|
-
? `[${result.selectedOptions.join(", ")}]`
|
|
159
|
-
: result.selectedOptions[0];
|
|
160
|
-
lines.push(` Selected: ${selectedText}`);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (hasCustomInput) {
|
|
164
|
-
if (!hasSelectedOptions) {
|
|
165
|
-
lines.push(` Selected: ${OTHER_OPTION}`);
|
|
166
|
-
}
|
|
167
|
-
lines.push(` Custom input: ${result.customInput}`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return lines.join("\n");
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function buildAskSessionContent(results: QuestionResult[]): string {
|
|
174
|
-
const safeResults = results.map(toSessionSafeQuestionResult);
|
|
175
|
-
const summaryLines = safeResults.map(formatQuestionResult).join("\n");
|
|
176
|
-
const contextBlocks = safeResults.map((result, index) => formatQuestionContext(result, index)).join("\n\n");
|
|
177
|
-
return `User answers:\n${summaryLines}\n\nAnswer context:\n${contextBlocks}`;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const ASK_TOOL_DESCRIPTION = `
|
|
181
|
-
Ask the user for clarification when a choice materially affects the outcome.
|
|
182
|
-
|
|
183
|
-
- Use when multiple valid approaches have different trade-offs.
|
|
184
|
-
- Prefer 2-5 concise options.
|
|
185
|
-
- Use multi=true when multiple answers are valid.
|
|
186
|
-
- Use recommended=<index> (0-indexed) to mark the default option.
|
|
187
|
-
- Use description to provide Markdown/plain context (supports long explanations and structure diagrams).
|
|
188
|
-
- You can ask multiple related questions in one call using questions[].
|
|
189
|
-
- Do NOT include an 'Other' option; UI adds it automatically.
|
|
190
|
-
`.trim();
|
|
2
|
+
import { registerIpcRelay } from "./ipc-relay.js";
|
|
3
|
+
import { registerAskTool } from "./tool.js";
|
|
191
4
|
|
|
192
5
|
export function registerAsk(pi: ExtensionAPI) {
|
|
193
6
|
const unsubscribes: Array<() => void> = [];
|
|
194
7
|
let currentCtx: ExtensionContext | undefined;
|
|
195
8
|
|
|
196
|
-
// Listen for ask requests from subagents via the bus — show UI immediately
|
|
197
|
-
const unsubAskRequest = getBus().on(Events.ASK_REQUEST, async (payload: unknown) => {
|
|
198
|
-
const data = payload as {
|
|
199
|
-
source: string;
|
|
200
|
-
requestId: string;
|
|
201
|
-
questions: AskQuestion[];
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
// Skip main agent — it shows its own UI in the tool handler
|
|
205
|
-
if (data.source === "main") return;
|
|
206
|
-
|
|
207
|
-
if (!data.questions || data.questions.length === 0) return;
|
|
208
|
-
|
|
209
|
-
// Defer to next tick so TUI can process current state
|
|
210
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
211
|
-
await handleAskRequest(data);
|
|
212
|
-
});
|
|
213
|
-
unsubscribes.push(unsubAskRequest);
|
|
214
|
-
|
|
215
9
|
// Keep ctx reference fresh
|
|
216
10
|
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
217
11
|
currentCtx = ctx;
|
|
@@ -220,231 +14,15 @@ export function registerAsk(pi: ExtensionAPI) {
|
|
|
220
14
|
currentCtx = ctx;
|
|
221
15
|
});
|
|
222
16
|
|
|
17
|
+
// Register the IPC relay for subagent ask requests
|
|
18
|
+
registerIpcRelay(pi, () => currentCtx, unsubscribes);
|
|
19
|
+
|
|
20
|
+
// Register the ask tool
|
|
21
|
+
registerAskTool(pi);
|
|
22
|
+
|
|
223
23
|
// Clean up on shutdown
|
|
224
24
|
pi.on("session_shutdown", (_event, _ctx) => {
|
|
225
25
|
unsubscribes.forEach((unsub) => unsub());
|
|
226
26
|
unsubscribes.length = 0;
|
|
227
27
|
});
|
|
228
|
-
|
|
229
|
-
async function handleAskRequest(data: {
|
|
230
|
-
requestId: string;
|
|
231
|
-
questions: AskQuestion[];
|
|
232
|
-
}) {
|
|
233
|
-
if (!currentCtx?.ui) return;
|
|
234
|
-
|
|
235
|
-
const questions = data.questions;
|
|
236
|
-
let cancelled = true;
|
|
237
|
-
let selections: AskSelection[] = [];
|
|
238
|
-
|
|
239
|
-
try {
|
|
240
|
-
if (questions.length === 1) {
|
|
241
|
-
const q = questions[0]!;
|
|
242
|
-
if (q.multi) {
|
|
243
|
-
// Multi-select single question routes to the tabs dialog (picker doesn't support multi)
|
|
244
|
-
const res = await askQuestionsWithTabs(currentCtx.ui, questions);
|
|
245
|
-
cancelled = res.cancelled;
|
|
246
|
-
selections = res.selections;
|
|
247
|
-
} else {
|
|
248
|
-
const input: { question: string; description?: string; options: typeof q.options; recommended?: number } = {
|
|
249
|
-
question: q.question,
|
|
250
|
-
options: q.options,
|
|
251
|
-
};
|
|
252
|
-
if (q.description && q.description.trim().length > 0) input.description = q.description;
|
|
253
|
-
if (q.recommended != null) input.recommended = q.recommended;
|
|
254
|
-
const sel = await askSingleQuestionWithInlineNote(currentCtx.ui, input);
|
|
255
|
-
selections = [sel];
|
|
256
|
-
cancelled = sel.selectedOptions.length === 0 && !sel.customInput;
|
|
257
|
-
}
|
|
258
|
-
} else {
|
|
259
|
-
const res = await askQuestionsWithTabs(currentCtx.ui, questions);
|
|
260
|
-
cancelled = res.cancelled;
|
|
261
|
-
selections = res.selections;
|
|
262
|
-
}
|
|
263
|
-
} catch {
|
|
264
|
-
cancelled = true;
|
|
265
|
-
selections = questions.map(() => ({ selectedOptions: [] }));
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Build results matching the request's questions
|
|
269
|
-
const results = questions.map((q, i) => ({
|
|
270
|
-
id: q.id,
|
|
271
|
-
selectedOptions: selections[i]?.selectedOptions ?? [],
|
|
272
|
-
customInput: selections[i]?.customInput,
|
|
273
|
-
}));
|
|
274
|
-
|
|
275
|
-
// Emit on bus — parent's streamEvents writes to child socket
|
|
276
|
-
getBus().emit(Events.ASK_RESPONSE, {
|
|
277
|
-
requestId: data.requestId,
|
|
278
|
-
cancelled,
|
|
279
|
-
results,
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
pi.registerTool({
|
|
284
|
-
name: "ask",
|
|
285
|
-
label: "Ask",
|
|
286
|
-
description: ASK_TOOL_DESCRIPTION,
|
|
287
|
-
parameters: AskParamsSchema,
|
|
288
|
-
|
|
289
|
-
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
|
|
290
|
-
// Headless/subagent path: the child's ask tool connects to the parent's
|
|
291
|
-
// PI_SUBAGENT_SOCKET bridge (created in subagent/spawn.ts:startAskSocketServer)
|
|
292
|
-
// and exchanges ask_request/ask_response JSON lines.
|
|
293
|
-
if (!ctx.hasUI) {
|
|
294
|
-
const requestId = randomUUID();
|
|
295
|
-
const socketPath = process.env.PI_SUBAGENT_SOCKET;
|
|
296
|
-
|
|
297
|
-
const response = await new Promise<{
|
|
298
|
-
cancelled: boolean;
|
|
299
|
-
results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
|
|
300
|
-
}>((resolve) => {
|
|
301
|
-
if (!socketPath) {
|
|
302
|
-
resolve({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
let resolved = false;
|
|
307
|
-
const finish = (r: { cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> }) => {
|
|
308
|
-
if (resolved) return;
|
|
309
|
-
resolved = true;
|
|
310
|
-
clearTimeout(timer);
|
|
311
|
-
resolve(r);
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
const socket = connect(socketPath, () => {
|
|
315
|
-
// Connected — send the question to the parent
|
|
316
|
-
socket.write(JSON.stringify({
|
|
317
|
-
type: "ask_request",
|
|
318
|
-
requestId,
|
|
319
|
-
questions: params.questions,
|
|
320
|
-
}) + "\n");
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
let buffer = "";
|
|
324
|
-
socket.on("data", (data: Buffer) => {
|
|
325
|
-
buffer += data.toString();
|
|
326
|
-
const lines = buffer.split("\n");
|
|
327
|
-
buffer = lines.pop() ?? "";
|
|
328
|
-
for (const line of lines) {
|
|
329
|
-
try {
|
|
330
|
-
const msg = JSON.parse(line);
|
|
331
|
-
if (msg.type === "ask_response" && msg.requestId === requestId) {
|
|
332
|
-
socket.end();
|
|
333
|
-
finish({ cancelled: msg.cancelled, results: msg.results });
|
|
334
|
-
}
|
|
335
|
-
} catch { /* ignore */ }
|
|
336
|
-
}
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
socket.on("error", () => {
|
|
340
|
-
finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
socket.on("close", () => {
|
|
344
|
-
finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
// Timeout after 5 minutes
|
|
348
|
-
const timer = setTimeout(() => {
|
|
349
|
-
socket.end();
|
|
350
|
-
finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
351
|
-
}, 5 * 60 * 1000);
|
|
352
|
-
timer.unref();
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
// Build results from response
|
|
356
|
-
const results: QuestionResult[] = params.questions.map((q, i) => {
|
|
357
|
-
const r = response.results[i];
|
|
358
|
-
return {
|
|
359
|
-
id: q.id,
|
|
360
|
-
question: q.question,
|
|
361
|
-
description: q.description && q.description.trim().length > 0 ? q.description : undefined,
|
|
362
|
-
options: q.options.map((o) => o.label),
|
|
363
|
-
multi: q.multi ?? false,
|
|
364
|
-
selectedOptions: r?.selectedOptions ?? [],
|
|
365
|
-
customInput: r?.customInput ?? undefined,
|
|
366
|
-
};
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
if (response.cancelled && results.every((r) => r.selectedOptions.length === 0)) {
|
|
370
|
-
return {
|
|
371
|
-
content: [{ type: "text", text: "User cancelled the question." }],
|
|
372
|
-
details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
return {
|
|
377
|
-
content: [{ type: "text", text: buildAskSessionContent(results) }],
|
|
378
|
-
details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (params.questions.length === 0) {
|
|
383
|
-
return {
|
|
384
|
-
content: [{ type: "text", text: "Error: questions must not be empty" }],
|
|
385
|
-
details: {},
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// Emit bus event so notify (if installed) can schedule a desktop notification
|
|
390
|
-
getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: randomUUID(), questions: params.questions });
|
|
391
|
-
|
|
392
|
-
if (params.questions.length === 1) {
|
|
393
|
-
const q = params.questions[0]!;
|
|
394
|
-
const selection = q.multi
|
|
395
|
-
? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
|
|
396
|
-
: await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
|
|
397
|
-
const optionLabels = q.options.map((option) => option.label);
|
|
398
|
-
const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
|
|
399
|
-
|
|
400
|
-
const result: QuestionResult = {
|
|
401
|
-
id: q.id,
|
|
402
|
-
question: q.question,
|
|
403
|
-
description: desc,
|
|
404
|
-
options: optionLabels,
|
|
405
|
-
multi: q.multi ?? false,
|
|
406
|
-
selectedOptions: selection.selectedOptions,
|
|
407
|
-
customInput: selection.customInput,
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
const details: AskToolDetails = {
|
|
411
|
-
id: q.id,
|
|
412
|
-
question: q.question,
|
|
413
|
-
description: desc,
|
|
414
|
-
options: optionLabels,
|
|
415
|
-
multi: q.multi ?? false,
|
|
416
|
-
selectedOptions: selection.selectedOptions,
|
|
417
|
-
customInput: selection.customInput,
|
|
418
|
-
results: [result],
|
|
419
|
-
};
|
|
420
|
-
|
|
421
|
-
return {
|
|
422
|
-
content: [{ type: "text", text: buildAskSessionContent([result]) }],
|
|
423
|
-
details,
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const results: QuestionResult[] = [];
|
|
428
|
-
const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
|
|
429
|
-
for (let i = 0; i < params.questions.length; i++) {
|
|
430
|
-
const q = params.questions[i]!;
|
|
431
|
-
const selection = tabResult.selections[i] ?? { selectedOptions: [] };
|
|
432
|
-
const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
|
|
433
|
-
results.push({
|
|
434
|
-
id: q.id,
|
|
435
|
-
question: q.question,
|
|
436
|
-
description: desc,
|
|
437
|
-
options: q.options.map((option) => option.label),
|
|
438
|
-
multi: q.multi ?? false,
|
|
439
|
-
selectedOptions: selection.selectedOptions,
|
|
440
|
-
customInput: selection.customInput,
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
return {
|
|
445
|
-
content: [{ type: "text", text: buildAskSessionContent(results) }],
|
|
446
|
-
details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
|
|
447
|
-
};
|
|
448
|
-
},
|
|
449
|
-
});
|
|
450
28
|
}
|
package/src/ipc-relay.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
3
|
+
import { type AskQuestion, type AskSelection } from "./selection.js";
|
|
4
|
+
import { askSingleQuestionWithInlineNote } from "./picker.js";
|
|
5
|
+
import { askQuestionsWithTabs } from "./dialog.js";
|
|
6
|
+
|
|
7
|
+
export function registerIpcRelay(
|
|
8
|
+
pi: ExtensionAPI,
|
|
9
|
+
getCtx: () => ExtensionContext | undefined,
|
|
10
|
+
unsubscribes: Array<() => void>,
|
|
11
|
+
): void {
|
|
12
|
+
const unsubAskRequest = getBus().on(Events.ASK_REQUEST, async (payload: unknown) => {
|
|
13
|
+
const data = payload as {
|
|
14
|
+
source: string;
|
|
15
|
+
requestId: string;
|
|
16
|
+
questions: AskQuestion[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Skip main agent — it shows its own UI in the tool handler
|
|
20
|
+
if (data.source === "main") return;
|
|
21
|
+
|
|
22
|
+
if (!data.questions || data.questions.length === 0) return;
|
|
23
|
+
|
|
24
|
+
// Defer to next tick so TUI can process current state
|
|
25
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
26
|
+
await handleAskRequest(data, getCtx);
|
|
27
|
+
});
|
|
28
|
+
unsubscribes.push(unsubAskRequest);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function handleAskRequest(
|
|
32
|
+
data: {
|
|
33
|
+
requestId: string;
|
|
34
|
+
questions: AskQuestion[];
|
|
35
|
+
},
|
|
36
|
+
getCtx: () => ExtensionContext | undefined,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const ctx = getCtx();
|
|
39
|
+
if (!ctx?.ui) return;
|
|
40
|
+
|
|
41
|
+
const questions = data.questions;
|
|
42
|
+
let cancelled = true;
|
|
43
|
+
let selections: AskSelection[] = [];
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
if (questions.length === 1) {
|
|
47
|
+
const q = questions[0]!;
|
|
48
|
+
if (q.multi) {
|
|
49
|
+
// Multi-select single question routes to the tabs dialog (picker doesn't support multi)
|
|
50
|
+
const res = await askQuestionsWithTabs(ctx.ui, questions);
|
|
51
|
+
cancelled = res.cancelled;
|
|
52
|
+
selections = res.selections;
|
|
53
|
+
} else {
|
|
54
|
+
const input: { question: string; description?: string; options: typeof q.options; recommended?: number } = {
|
|
55
|
+
question: q.question,
|
|
56
|
+
options: q.options,
|
|
57
|
+
};
|
|
58
|
+
if (q.description && q.description.trim().length > 0) input.description = q.description;
|
|
59
|
+
if (q.recommended != null) input.recommended = q.recommended;
|
|
60
|
+
const sel = await askSingleQuestionWithInlineNote(ctx.ui, input);
|
|
61
|
+
selections = [sel];
|
|
62
|
+
cancelled = sel.selectedOptions.length === 0 && !sel.customInput;
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
const res = await askQuestionsWithTabs(ctx.ui, questions);
|
|
66
|
+
cancelled = res.cancelled;
|
|
67
|
+
selections = res.selections;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
cancelled = true;
|
|
71
|
+
selections = questions.map(() => ({ selectedOptions: [] }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Build results matching the request's questions
|
|
75
|
+
const results = questions.map((q, i) => ({
|
|
76
|
+
id: q.id,
|
|
77
|
+
selectedOptions: selections[i]?.selectedOptions ?? [],
|
|
78
|
+
customInput: selections[i]?.customInput,
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
// Emit on bus — parent's streamEvents writes to child socket
|
|
82
|
+
getBus().emit(Events.ASK_RESPONSE, {
|
|
83
|
+
requestId: data.requestId,
|
|
84
|
+
cancelled,
|
|
85
|
+
results,
|
|
86
|
+
});
|
|
87
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
4
|
+
import { connect } from "node:net";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { OTHER_OPTION, type AskQuestion } from "./selection.js";
|
|
7
|
+
import { askSingleQuestionWithInlineNote } from "./picker.js";
|
|
8
|
+
import { askQuestionsWithTabs } from "./dialog.js";
|
|
9
|
+
|
|
10
|
+
// ── Schemas ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const OptionItemSchema = Type.Object({
|
|
13
|
+
label: Type.String({ description: "Display label" }),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const QuestionItemSchema = Type.Object({
|
|
17
|
+
id: Type.String({ description: "Question id (e.g. auth, cache, priority)" }),
|
|
18
|
+
question: Type.String({ description: "Question text" }),
|
|
19
|
+
description: Type.Optional(
|
|
20
|
+
Type.String({
|
|
21
|
+
description:
|
|
22
|
+
"Optional context in Markdown/plain text. Rendered above options with wrapping (supports headings/lists/code blocks).",
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
options: Type.Array(OptionItemSchema, {
|
|
26
|
+
description: "Available options. Do not include 'Other'.",
|
|
27
|
+
minItems: 1,
|
|
28
|
+
}),
|
|
29
|
+
multi: Type.Optional(Type.Boolean({ description: "Allow multi-select" })),
|
|
30
|
+
recommended: Type.Optional(
|
|
31
|
+
Type.Number({ description: "0-indexed recommended option. '(Recommended)' is shown automatically." }),
|
|
32
|
+
),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const AskParamsSchema = Type.Object({
|
|
36
|
+
questions: Type.Array(QuestionItemSchema, { description: "Questions to ask", minItems: 1 }),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
type AskParams = Static<typeof AskParamsSchema>;
|
|
40
|
+
|
|
41
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface QuestionResult {
|
|
44
|
+
id: string;
|
|
45
|
+
question: string;
|
|
46
|
+
description: string | undefined;
|
|
47
|
+
options: string[];
|
|
48
|
+
multi: boolean;
|
|
49
|
+
selectedOptions: string[];
|
|
50
|
+
customInput: string | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AskToolDetails {
|
|
54
|
+
id?: string;
|
|
55
|
+
question?: string;
|
|
56
|
+
description: string | undefined;
|
|
57
|
+
options?: string[];
|
|
58
|
+
multi?: boolean;
|
|
59
|
+
selectedOptions?: string[];
|
|
60
|
+
customInput: string | undefined;
|
|
61
|
+
results?: QuestionResult[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Session-text helpers ─────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function sanitizeForSessionText(value: string): string {
|
|
67
|
+
return value
|
|
68
|
+
.replace(/[\r\n\t]/g, " ")
|
|
69
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
|
70
|
+
.replace(/\s{2,}/g, " ")
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sanitizeMultilineForSessionText(value: string): string {
|
|
75
|
+
return value
|
|
76
|
+
.replace(/\r\n/g, "\n")
|
|
77
|
+
.replace(/\r/g, "\n")
|
|
78
|
+
.split("\n")
|
|
79
|
+
.map((line) => sanitizeForSessionText(line))
|
|
80
|
+
.join("\n")
|
|
81
|
+
.trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sanitizeOptionForSessionText(option: string): string {
|
|
85
|
+
const sanitizedOption = sanitizeForSessionText(option);
|
|
86
|
+
return sanitizedOption.length > 0 ? sanitizedOption : "(empty option)";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
|
|
90
|
+
const selectedOptions = result.selectedOptions
|
|
91
|
+
.map((selectedOption) => sanitizeForSessionText(selectedOption))
|
|
92
|
+
.filter((selectedOption) => selectedOption.length > 0);
|
|
93
|
+
|
|
94
|
+
const rawDescription = result.description;
|
|
95
|
+
const description = rawDescription == null ? undefined : sanitizeMultilineForSessionText(rawDescription);
|
|
96
|
+
const rawCustomInput = result.customInput;
|
|
97
|
+
const customInput = rawCustomInput == null ? undefined : sanitizeForSessionText(rawCustomInput);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
id: sanitizeForSessionText(result.id) || "(unknown)",
|
|
101
|
+
question: sanitizeForSessionText(result.question) || "(empty question)",
|
|
102
|
+
description: description && description.length > 0 ? description : undefined,
|
|
103
|
+
options: result.options.map(sanitizeOptionForSessionText),
|
|
104
|
+
multi: result.multi,
|
|
105
|
+
selectedOptions,
|
|
106
|
+
customInput: customInput && customInput.length > 0 ? customInput : undefined,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatSelectionForSummary(result: QuestionResult): string {
|
|
111
|
+
const hasSelectedOptions = result.selectedOptions.length > 0;
|
|
112
|
+
const hasCustomInput = Boolean(result.customInput);
|
|
113
|
+
|
|
114
|
+
if (!hasSelectedOptions && !hasCustomInput) {
|
|
115
|
+
return "(cancelled)";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (hasSelectedOptions && hasCustomInput) {
|
|
119
|
+
const selectedPart = result.multi
|
|
120
|
+
? `[${result.selectedOptions.join(", ")}]`
|
|
121
|
+
: result.selectedOptions[0] ?? "";
|
|
122
|
+
return `${selectedPart} + Other: "${result.customInput}"`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (hasCustomInput) {
|
|
126
|
+
return `"${result.customInput}"`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (result.multi) {
|
|
130
|
+
return `[${result.selectedOptions.join(", ")}]`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return result.selectedOptions[0] ?? "";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function formatQuestionResult(result: QuestionResult): string {
|
|
137
|
+
return `${result.id}: ${formatSelectionForSummary(result)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatQuestionContext(result: QuestionResult, questionIndex: number): string {
|
|
141
|
+
const lines: string[] = [`Question ${questionIndex + 1} (${result.id})`, `Prompt: ${result.question}`];
|
|
142
|
+
|
|
143
|
+
if (result.description) {
|
|
144
|
+
lines.push("Context:");
|
|
145
|
+
for (const descriptionLine of result.description.split("\n")) {
|
|
146
|
+
lines.push(` ${descriptionLine}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lines.push("Options:");
|
|
151
|
+
lines.push(...result.options.map((option, optionIndex) => ` ${optionIndex + 1}. ${option}`));
|
|
152
|
+
lines.push("Response:");
|
|
153
|
+
|
|
154
|
+
const hasSelectedOptions = result.selectedOptions.length > 0;
|
|
155
|
+
const hasCustomInput = Boolean(result.customInput);
|
|
156
|
+
|
|
157
|
+
if (!hasSelectedOptions && !hasCustomInput) {
|
|
158
|
+
lines.push(" Selected: (cancelled)");
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (hasSelectedOptions) {
|
|
163
|
+
const selectedText = result.multi
|
|
164
|
+
? `[${result.selectedOptions.join(", ")}]`
|
|
165
|
+
: result.selectedOptions[0];
|
|
166
|
+
lines.push(` Selected: ${selectedText}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (hasCustomInput) {
|
|
170
|
+
if (!hasSelectedOptions) {
|
|
171
|
+
lines.push(` Selected: ${OTHER_OPTION}`);
|
|
172
|
+
}
|
|
173
|
+
lines.push(` Custom input: ${result.customInput}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function buildAskSessionContent(results: QuestionResult[]): string {
|
|
180
|
+
const safeResults = results.map(toSessionSafeQuestionResult);
|
|
181
|
+
const summaryLines = safeResults.map(formatQuestionResult).join("\n");
|
|
182
|
+
const contextBlocks = safeResults.map((result, index) => formatQuestionContext(result, index)).join("\n\n");
|
|
183
|
+
return `User answers:\n${summaryLines}\n\nAnswer context:\n${contextBlocks}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Tool description ─────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
const ASK_TOOL_DESCRIPTION = `
|
|
189
|
+
Ask the user for clarification when a choice materially affects the outcome.
|
|
190
|
+
|
|
191
|
+
- Use when multiple valid approaches have different trade-offs.
|
|
192
|
+
- Prefer 2-5 concise options.
|
|
193
|
+
- Use multi=true when multiple answers are valid.
|
|
194
|
+
- Use recommended=<index> (0-indexed) to mark the default option.
|
|
195
|
+
- Use description to provide Markdown/plain context (supports long explanations and structure diagrams).
|
|
196
|
+
- You can ask multiple related questions in one call using questions[].
|
|
197
|
+
- Do NOT include an 'Other' option; UI adds it automatically.
|
|
198
|
+
`.trim();
|
|
199
|
+
|
|
200
|
+
// ── Tool registration ────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
export function registerAskTool(pi: ExtensionAPI): void {
|
|
203
|
+
pi.registerTool({
|
|
204
|
+
name: "ask",
|
|
205
|
+
label: "Ask",
|
|
206
|
+
description: ASK_TOOL_DESCRIPTION,
|
|
207
|
+
parameters: AskParamsSchema,
|
|
208
|
+
|
|
209
|
+
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
|
|
210
|
+
// Headless/subagent path: the child's ask tool connects to the parent's
|
|
211
|
+
// PI_SUBAGENT_SOCKET bridge (created in subagent/spawn.ts:startAskSocketServer)
|
|
212
|
+
// and exchanges ask_request/ask_response JSON lines.
|
|
213
|
+
if (!ctx.hasUI) {
|
|
214
|
+
const requestId = randomUUID();
|
|
215
|
+
const socketPath = process.env.PI_SUBAGENT_SOCKET;
|
|
216
|
+
|
|
217
|
+
const response = await new Promise<{
|
|
218
|
+
cancelled: boolean;
|
|
219
|
+
results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
|
|
220
|
+
}>((resolve) => {
|
|
221
|
+
if (!socketPath) {
|
|
222
|
+
resolve({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let resolved = false;
|
|
227
|
+
const finish = (r: { cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> }) => {
|
|
228
|
+
if (resolved) return;
|
|
229
|
+
resolved = true;
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
resolve(r);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const socket = connect(socketPath, () => {
|
|
235
|
+
// Connected — send the question to the parent
|
|
236
|
+
socket.write(JSON.stringify({
|
|
237
|
+
type: "ask_request",
|
|
238
|
+
requestId,
|
|
239
|
+
questions: params.questions,
|
|
240
|
+
}) + "\n");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
let buffer = "";
|
|
244
|
+
socket.on("data", (data: Buffer) => {
|
|
245
|
+
buffer += data.toString();
|
|
246
|
+
const lines = buffer.split("\n");
|
|
247
|
+
buffer = lines.pop() ?? "";
|
|
248
|
+
for (const line of lines) {
|
|
249
|
+
try {
|
|
250
|
+
const msg = JSON.parse(line);
|
|
251
|
+
if (msg.type === "ask_response" && msg.requestId === requestId) {
|
|
252
|
+
socket.end();
|
|
253
|
+
finish({ cancelled: msg.cancelled, results: msg.results });
|
|
254
|
+
}
|
|
255
|
+
} catch { /* ignore */ }
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
socket.on("error", () => {
|
|
260
|
+
finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
socket.on("close", () => {
|
|
264
|
+
finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Timeout after 5 minutes
|
|
268
|
+
const timer = setTimeout(() => {
|
|
269
|
+
socket.end();
|
|
270
|
+
finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
271
|
+
}, 5 * 60 * 1000);
|
|
272
|
+
timer.unref();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Build results from response
|
|
276
|
+
const results: QuestionResult[] = params.questions.map((q, i) => {
|
|
277
|
+
const r = response.results[i];
|
|
278
|
+
return {
|
|
279
|
+
id: q.id,
|
|
280
|
+
question: q.question,
|
|
281
|
+
description: q.description && q.description.trim().length > 0 ? q.description : undefined,
|
|
282
|
+
options: q.options.map((o) => o.label),
|
|
283
|
+
multi: q.multi ?? false,
|
|
284
|
+
selectedOptions: r?.selectedOptions ?? [],
|
|
285
|
+
customInput: r?.customInput ?? undefined,
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (response.cancelled && results.every((r) => r.selectedOptions.length === 0)) {
|
|
290
|
+
return {
|
|
291
|
+
content: [{ type: "text", text: "User cancelled the question." }],
|
|
292
|
+
details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
content: [{ type: "text", text: buildAskSessionContent(results) }],
|
|
298
|
+
details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (params.questions.length === 0) {
|
|
303
|
+
return {
|
|
304
|
+
content: [{ type: "text", text: "Error: questions must not be empty" }],
|
|
305
|
+
details: {},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Emit bus event so notify (if installed) can schedule a desktop notification
|
|
310
|
+
getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: randomUUID(), questions: params.questions });
|
|
311
|
+
|
|
312
|
+
if (params.questions.length === 1) {
|
|
313
|
+
const q = params.questions[0]!;
|
|
314
|
+
const selection = q.multi
|
|
315
|
+
? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
|
|
316
|
+
: await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
|
|
317
|
+
const optionLabels = q.options.map((option) => option.label);
|
|
318
|
+
const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
|
|
319
|
+
|
|
320
|
+
const result: QuestionResult = {
|
|
321
|
+
id: q.id,
|
|
322
|
+
question: q.question,
|
|
323
|
+
description: desc,
|
|
324
|
+
options: optionLabels,
|
|
325
|
+
multi: q.multi ?? false,
|
|
326
|
+
selectedOptions: selection.selectedOptions,
|
|
327
|
+
customInput: selection.customInput,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const details: AskToolDetails = {
|
|
331
|
+
id: q.id,
|
|
332
|
+
question: q.question,
|
|
333
|
+
description: desc,
|
|
334
|
+
options: optionLabels,
|
|
335
|
+
multi: q.multi ?? false,
|
|
336
|
+
selectedOptions: selection.selectedOptions,
|
|
337
|
+
customInput: selection.customInput,
|
|
338
|
+
results: [result],
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
content: [{ type: "text", text: buildAskSessionContent([result]) }],
|
|
343
|
+
details,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const results: QuestionResult[] = [];
|
|
348
|
+
const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
|
|
349
|
+
for (let i = 0; i < params.questions.length; i++) {
|
|
350
|
+
const q = params.questions[i]!;
|
|
351
|
+
const selection = tabResult.selections[i] ?? { selectedOptions: [] };
|
|
352
|
+
const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
|
|
353
|
+
results.push({
|
|
354
|
+
id: q.id,
|
|
355
|
+
question: q.question,
|
|
356
|
+
description: desc,
|
|
357
|
+
options: q.options.map((option) => option.label),
|
|
358
|
+
multi: q.multi ?? false,
|
|
359
|
+
selectedOptions: selection.selectedOptions,
|
|
360
|
+
customInput: selection.customInput,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
content: [{ type: "text", text: buildAskSessionContent(results) }],
|
|
366
|
+
details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|