@pi-unipi/ask-user 0.1.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 +99 -0
- package/ask-ui.ts +477 -0
- package/commands.ts +50 -0
- package/config.ts +133 -0
- package/index.ts +44 -0
- package/package.json +52 -0
- package/settings-tui.ts +164 -0
- package/skills/ask-user/SKILL.md +100 -0
- package/tools.ts +291 -0
- package/types.ts +48 -0
package/tools.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/ask-user — Tool registration
|
|
3
|
+
*
|
|
4
|
+
* Registers ask_user tool for structured user input.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { ASK_USER_TOOLS } from "@pi-unipi/core";
|
|
10
|
+
import type { NormalizedOption, AskUserResponse } from "./types.js";
|
|
11
|
+
import { renderAskUI, createRenderCall, createRenderResult } from "./ask-ui.js";
|
|
12
|
+
import { getAskUserSettings } from "./config.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register ask-user tools.
|
|
16
|
+
*/
|
|
17
|
+
export function registerAskUserTools(pi: ExtensionAPI): void {
|
|
18
|
+
pi.registerTool({
|
|
19
|
+
name: ASK_USER_TOOLS.ASK,
|
|
20
|
+
label: "Ask User",
|
|
21
|
+
description:
|
|
22
|
+
"Ask the user a question with structured options. Supports single-select, " +
|
|
23
|
+
"multi-select, and freeform text input. Use for decisions, preferences, " +
|
|
24
|
+
"and clarifications that require explicit user input.",
|
|
25
|
+
promptSnippet: "Ask the user a structured question with options.",
|
|
26
|
+
promptGuidelines: [
|
|
27
|
+
"Use ask_user when you need explicit user input before proceeding.",
|
|
28
|
+
"Good for: architectural trade-offs, ambiguous requirements, user preferences, confirming destructive operations.",
|
|
29
|
+
"Provide clear options with labels and optional descriptions.",
|
|
30
|
+
"Use allowMultiple for multi-select scenarios (e.g., choosing features to enable).",
|
|
31
|
+
"Use allowFreeform: false to restrict to predefined options only.",
|
|
32
|
+
],
|
|
33
|
+
parameters: Type.Object({
|
|
34
|
+
question: Type.String({
|
|
35
|
+
description: "The question to ask the user",
|
|
36
|
+
}),
|
|
37
|
+
context: Type.Optional(
|
|
38
|
+
Type.String({
|
|
39
|
+
description: "Additional context shown before the question",
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
options: Type.Optional(
|
|
43
|
+
Type.Array(
|
|
44
|
+
Type.Object({
|
|
45
|
+
label: Type.String({ description: "Display label" }),
|
|
46
|
+
description: Type.Optional(
|
|
47
|
+
Type.String({ description: "Optional description shown below label" }),
|
|
48
|
+
),
|
|
49
|
+
value: Type.Optional(
|
|
50
|
+
Type.String({
|
|
51
|
+
description: "Value returned when selected (defaults to label)",
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
}),
|
|
55
|
+
{
|
|
56
|
+
description:
|
|
57
|
+
"Multiple-choice options. Omit for freeform-only input.",
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
allowMultiple: Type.Optional(
|
|
62
|
+
Type.Boolean({
|
|
63
|
+
description: "Enable multi-select mode (default: false)",
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
allowFreeform: Type.Optional(
|
|
67
|
+
Type.Boolean({
|
|
68
|
+
description: "Allow freeform text input (default: true)",
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
timeout: Type.Optional(
|
|
72
|
+
Type.Number({
|
|
73
|
+
description: "Auto-dismiss after N milliseconds",
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
}),
|
|
77
|
+
|
|
78
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
|
|
79
|
+
const {
|
|
80
|
+
question,
|
|
81
|
+
context,
|
|
82
|
+
options: rawOptions,
|
|
83
|
+
allowMultiple = false,
|
|
84
|
+
allowFreeform = true,
|
|
85
|
+
timeout,
|
|
86
|
+
} = params as {
|
|
87
|
+
question: string;
|
|
88
|
+
context?: string;
|
|
89
|
+
options?: { label: string; description?: string; value?: string }[];
|
|
90
|
+
allowMultiple?: boolean;
|
|
91
|
+
allowFreeform?: boolean;
|
|
92
|
+
timeout?: number;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Check settings
|
|
96
|
+
const settings = getAskUserSettings();
|
|
97
|
+
if (!settings.enabled) {
|
|
98
|
+
return {
|
|
99
|
+
content: [
|
|
100
|
+
{
|
|
101
|
+
type: "text",
|
|
102
|
+
text: "Error: ask_user tool is disabled in settings.",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
details: {
|
|
106
|
+
question,
|
|
107
|
+
response: {
|
|
108
|
+
kind: "cancelled",
|
|
109
|
+
comment: "Tool disabled",
|
|
110
|
+
} as AskUserResponse,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Validate requested format against allowed formats
|
|
116
|
+
if (allowMultiple && !settings.allowedFormats.multiSelect) {
|
|
117
|
+
return {
|
|
118
|
+
content: [
|
|
119
|
+
{
|
|
120
|
+
type: "text",
|
|
121
|
+
text: "Error: Multi-select questions are disabled in settings.",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
details: {
|
|
125
|
+
question,
|
|
126
|
+
response: {
|
|
127
|
+
kind: "cancelled",
|
|
128
|
+
comment: "Multi-select disabled",
|
|
129
|
+
} as AskUserResponse,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!allowMultiple && !settings.allowedFormats.singleSelect) {
|
|
135
|
+
return {
|
|
136
|
+
content: [
|
|
137
|
+
{
|
|
138
|
+
type: "text",
|
|
139
|
+
text: "Error: Single-select questions are disabled in settings.",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
details: {
|
|
143
|
+
question,
|
|
144
|
+
response: {
|
|
145
|
+
kind: "cancelled",
|
|
146
|
+
comment: "Single-select disabled",
|
|
147
|
+
} as AskUserResponse,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (allowFreeform && !settings.allowedFormats.freeform) {
|
|
153
|
+
return {
|
|
154
|
+
content: [
|
|
155
|
+
{
|
|
156
|
+
type: "text",
|
|
157
|
+
text: "Error: Freeform questions are disabled in settings.",
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
details: {
|
|
161
|
+
question,
|
|
162
|
+
response: {
|
|
163
|
+
kind: "cancelled",
|
|
164
|
+
comment: "Freeform disabled",
|
|
165
|
+
} as AskUserResponse,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Validate: need UI
|
|
171
|
+
if (!ctx.hasUI) {
|
|
172
|
+
return {
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: "text",
|
|
176
|
+
text: "Error: UI not available (running in non-interactive mode)",
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
details: {
|
|
180
|
+
question,
|
|
181
|
+
response: {
|
|
182
|
+
kind: "cancelled",
|
|
183
|
+
comment: "No UI available",
|
|
184
|
+
} as AskUserResponse,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Validate: need options or freeform
|
|
190
|
+
const options = rawOptions || [];
|
|
191
|
+
if (options.length === 0 && !allowFreeform) {
|
|
192
|
+
return {
|
|
193
|
+
content: [
|
|
194
|
+
{
|
|
195
|
+
type: "text",
|
|
196
|
+
text: "Error: No options provided and allowFreeform is false. Provide options or enable freeform.",
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
details: {
|
|
200
|
+
question,
|
|
201
|
+
response: {
|
|
202
|
+
kind: "cancelled",
|
|
203
|
+
comment: "No options and no freeform",
|
|
204
|
+
} as AskUserResponse,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Normalize options — resolve value (defaults to label)
|
|
210
|
+
const normalizedOptions: NormalizedOption[] = options.map((opt) => ({
|
|
211
|
+
label: opt.label,
|
|
212
|
+
description: opt.description,
|
|
213
|
+
value: opt.value ?? opt.label,
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
// Render interactive UI
|
|
217
|
+
const result = await ctx.ui.custom<{ response: AskUserResponse } | null>(
|
|
218
|
+
renderAskUI({
|
|
219
|
+
question,
|
|
220
|
+
context,
|
|
221
|
+
options: normalizedOptions,
|
|
222
|
+
allowMultiple,
|
|
223
|
+
allowFreeform,
|
|
224
|
+
timeout,
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Handle cancel
|
|
229
|
+
if (!result) {
|
|
230
|
+
return {
|
|
231
|
+
content: [
|
|
232
|
+
{
|
|
233
|
+
type: "text",
|
|
234
|
+
text: "User cancelled the selection",
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
details: {
|
|
238
|
+
question,
|
|
239
|
+
options: normalizedOptions.map((o) => o.label),
|
|
240
|
+
response: {
|
|
241
|
+
kind: "cancelled",
|
|
242
|
+
} as AskUserResponse,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Build response content
|
|
248
|
+
const response = result.response;
|
|
249
|
+
let contentText: string;
|
|
250
|
+
|
|
251
|
+
switch (response.kind) {
|
|
252
|
+
case "selection": {
|
|
253
|
+
const selections = response.selections || [];
|
|
254
|
+
contentText =
|
|
255
|
+
selections.length === 1
|
|
256
|
+
? `User selected: ${selections[0]}`
|
|
257
|
+
: `User selected: ${selections.join(", ")}`;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case "freeform":
|
|
261
|
+
contentText = `User wrote: ${response.text}`;
|
|
262
|
+
break;
|
|
263
|
+
case "combined": {
|
|
264
|
+
const selections = response.selections || [];
|
|
265
|
+
const selText = selections.length === 1
|
|
266
|
+
? selections[0]
|
|
267
|
+
: selections.join(", ");
|
|
268
|
+
contentText = `User selected: ${selText} and wrote: ${response.text}`;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case "timed_out":
|
|
272
|
+
contentText = "User did not respond (timed out)";
|
|
273
|
+
break;
|
|
274
|
+
default:
|
|
275
|
+
contentText = "No response";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
content: [{ type: "text", text: contentText }],
|
|
280
|
+
details: {
|
|
281
|
+
question,
|
|
282
|
+
options: normalizedOptions.map((o) => o.label),
|
|
283
|
+
response,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
renderCall: createRenderCall(),
|
|
289
|
+
renderResult: createRenderResult(),
|
|
290
|
+
});
|
|
291
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/ask-user — TypeScript types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Option for ask_user tool */
|
|
6
|
+
export interface AskUserOption {
|
|
7
|
+
/** Display label */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Optional description shown below label */
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Value returned when selected (defaults to label) */
|
|
12
|
+
value?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Parameters for ask_user tool */
|
|
16
|
+
export interface AskUserParams {
|
|
17
|
+
/** The question to ask the user */
|
|
18
|
+
question: string;
|
|
19
|
+
/** Additional context shown before the question */
|
|
20
|
+
context?: string;
|
|
21
|
+
/** Multiple-choice options (omit for freeform-only) */
|
|
22
|
+
options?: AskUserOption[];
|
|
23
|
+
/** Enable multi-select mode (default: false) */
|
|
24
|
+
allowMultiple?: boolean;
|
|
25
|
+
/** Allow freeform text input (default: true) */
|
|
26
|
+
allowFreeform?: boolean;
|
|
27
|
+
/** Auto-dismiss after N milliseconds */
|
|
28
|
+
timeout?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Response from ask_user tool */
|
|
32
|
+
export interface AskUserResponse {
|
|
33
|
+
/** Response kind */
|
|
34
|
+
kind: "selection" | "freeform" | "combined" | "cancelled" | "timed_out";
|
|
35
|
+
/** Selected option values (for selection kind) */
|
|
36
|
+
selections?: string[];
|
|
37
|
+
/** Freeform text (for freeform kind) */
|
|
38
|
+
text?: string;
|
|
39
|
+
/** Optional user comment */
|
|
40
|
+
comment?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Normalized option with resolved value */
|
|
44
|
+
export interface NormalizedOption {
|
|
45
|
+
label: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
value: string;
|
|
48
|
+
}
|