pi-ask-user 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/LICENSE +21 -0
- package/README.md +52 -0
- package/index.ts +640 -0
- package/package.json +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Enzo Lucchesi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# pi-ask-user
|
|
2
|
+
|
|
3
|
+
A Pi package that provides an interactive `ask_user` tool for gathering user input during agent execution.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Single-select option lists
|
|
8
|
+
- Multi-select option lists
|
|
9
|
+
- Optional freeform response mode
|
|
10
|
+
- Context text support
|
|
11
|
+
- Graceful fallback when UI is unavailable
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:pi-ask-user
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Use
|
|
20
|
+
|
|
21
|
+
Once installed, the tool is available to the agent as `ask_user`.
|
|
22
|
+
|
|
23
|
+
Example call shape:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"question": "Which option should we use?",
|
|
28
|
+
"context": "We are choosing a deploy target.",
|
|
29
|
+
"options": [
|
|
30
|
+
"staging",
|
|
31
|
+
{ "title": "production", "description": "Customer-facing" }
|
|
32
|
+
],
|
|
33
|
+
"allowMultiple": false,
|
|
34
|
+
"allowFreeform": true
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Local development
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pi -e ./index.ts
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Publish
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm publish
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask Tool Extension - Interactive question UI for pi-coding-agent
|
|
3
|
+
*
|
|
4
|
+
* Refactored to use built-in TUI primitives (Container/Text/Spacer/SelectList/Editor)
|
|
5
|
+
* and DynamicBorder instead of manual ANSI box drawing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import { Type } from "@sinclair/typebox";
|
|
11
|
+
import {
|
|
12
|
+
Container,
|
|
13
|
+
type Component,
|
|
14
|
+
Editor,
|
|
15
|
+
type EditorTheme,
|
|
16
|
+
Key,
|
|
17
|
+
matchesKey,
|
|
18
|
+
SelectList,
|
|
19
|
+
type SelectItem,
|
|
20
|
+
Spacer,
|
|
21
|
+
Text,
|
|
22
|
+
type TUI,
|
|
23
|
+
truncateToWidth,
|
|
24
|
+
wrapTextWithAnsi,
|
|
25
|
+
} from "@mariozechner/pi-tui";
|
|
26
|
+
|
|
27
|
+
interface QuestionOption {
|
|
28
|
+
title: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type AskOptionInput = QuestionOption | string;
|
|
33
|
+
|
|
34
|
+
interface AskParams {
|
|
35
|
+
question: string;
|
|
36
|
+
context?: string;
|
|
37
|
+
options?: AskOptionInput[];
|
|
38
|
+
allowMultiple?: boolean;
|
|
39
|
+
allowFreeform?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
|
|
43
|
+
return options
|
|
44
|
+
.map((option) => {
|
|
45
|
+
if (typeof option === "string") {
|
|
46
|
+
return { title: option };
|
|
47
|
+
}
|
|
48
|
+
if (option && typeof option === "object" && typeof option.title === "string") {
|
|
49
|
+
return { title: option.title, description: option.description };
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
})
|
|
53
|
+
.filter((option): option is QuestionOption => option !== null);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatOptionsForMessage(options: QuestionOption[]): string {
|
|
57
|
+
return options
|
|
58
|
+
.map((option, index) => {
|
|
59
|
+
const desc = option.description ? ` — ${option.description}` : "";
|
|
60
|
+
return `${index + 1}. ${option.title}${desc}`;
|
|
61
|
+
})
|
|
62
|
+
.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const FREEFORM_VALUE = "__freeform__";
|
|
66
|
+
|
|
67
|
+
function createSelectListTheme(theme: Theme) {
|
|
68
|
+
return {
|
|
69
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
70
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
71
|
+
description: (t: string) => theme.fg("muted", t),
|
|
72
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
73
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createEditorTheme(theme: Theme): EditorTheme {
|
|
78
|
+
return {
|
|
79
|
+
borderColor: (s: string) => theme.fg("accent", s),
|
|
80
|
+
selectList: createSelectListTheme(theme),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type AskMode = "select" | "freeform";
|
|
85
|
+
|
|
86
|
+
class MultiSelectList implements Component {
|
|
87
|
+
private options: QuestionOption[];
|
|
88
|
+
private allowFreeform: boolean;
|
|
89
|
+
private theme: Theme;
|
|
90
|
+
private selectedIndex = 0;
|
|
91
|
+
private checked = new Set<number>();
|
|
92
|
+
private cachedWidth?: number;
|
|
93
|
+
private cachedLines?: string[];
|
|
94
|
+
|
|
95
|
+
public onCancel?: () => void;
|
|
96
|
+
public onSubmit?: (result: string) => void;
|
|
97
|
+
public onEnterFreeform?: () => void;
|
|
98
|
+
|
|
99
|
+
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
|
|
100
|
+
this.options = options;
|
|
101
|
+
this.allowFreeform = allowFreeform;
|
|
102
|
+
this.theme = theme;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
invalidate(): void {
|
|
106
|
+
this.cachedWidth = undefined;
|
|
107
|
+
this.cachedLines = undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private getItemCount(): number {
|
|
111
|
+
return this.options.length + (this.allowFreeform ? 1 : 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private isFreeformRow(index: number): boolean {
|
|
115
|
+
return this.allowFreeform && index === this.options.length;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private toggle(index: number): void {
|
|
119
|
+
if (index < 0 || index >= this.options.length) return;
|
|
120
|
+
if (this.checked.has(index)) this.checked.delete(index);
|
|
121
|
+
else this.checked.add(index);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
handleInput(data: string): void {
|
|
125
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
126
|
+
this.onCancel?.();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const count = this.getItemCount();
|
|
131
|
+
if (count === 0) {
|
|
132
|
+
this.onCancel?.();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.shift("tab"))) {
|
|
137
|
+
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
138
|
+
this.invalidate();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
|
|
143
|
+
this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
|
|
144
|
+
this.invalidate();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Number keys (1-9) toggle checkboxes for normal items
|
|
149
|
+
const numMatch = data.match(/^[1-9]$/);
|
|
150
|
+
if (numMatch) {
|
|
151
|
+
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
152
|
+
if (idx >= 0 && idx < this.options.length) {
|
|
153
|
+
this.toggle(idx);
|
|
154
|
+
this.selectedIndex = Math.min(idx, count - 1);
|
|
155
|
+
this.invalidate();
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (matchesKey(data, Key.space)) {
|
|
161
|
+
if (this.isFreeformRow(this.selectedIndex)) {
|
|
162
|
+
this.onEnterFreeform?.();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.toggle(this.selectedIndex);
|
|
166
|
+
this.invalidate();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (matchesKey(data, Key.enter)) {
|
|
171
|
+
if (this.isFreeformRow(this.selectedIndex)) {
|
|
172
|
+
this.onEnterFreeform?.();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const selectedTitles = Array.from(this.checked)
|
|
177
|
+
.sort((a, b) => a - b)
|
|
178
|
+
.map((i) => this.options[i]?.title)
|
|
179
|
+
.filter((t): t is string => !!t);
|
|
180
|
+
|
|
181
|
+
// If nothing checked, fall back to current row
|
|
182
|
+
const fallback = this.options[this.selectedIndex]?.title;
|
|
183
|
+
const result = selectedTitles.length > 0 ? selectedTitles.join(", ") : fallback;
|
|
184
|
+
|
|
185
|
+
if (result) this.onSubmit?.(result);
|
|
186
|
+
else this.onCancel?.();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
render(width: number): string[] {
|
|
191
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
192
|
+
return this.cachedLines;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const theme = this.theme;
|
|
196
|
+
const count = this.getItemCount();
|
|
197
|
+
const maxVisible = Math.min(count, 10);
|
|
198
|
+
|
|
199
|
+
if (count === 0) {
|
|
200
|
+
this.cachedLines = [theme.fg("warning", "No options")];
|
|
201
|
+
this.cachedWidth = width;
|
|
202
|
+
return this.cachedLines;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), count - maxVisible));
|
|
206
|
+
const endIndex = Math.min(startIndex + maxVisible, count);
|
|
207
|
+
|
|
208
|
+
const lines: string[] = [];
|
|
209
|
+
|
|
210
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
211
|
+
const isSelected = i === this.selectedIndex;
|
|
212
|
+
const prefix = isSelected ? theme.fg("accent", "→") : " ";
|
|
213
|
+
|
|
214
|
+
if (this.isFreeformRow(i)) {
|
|
215
|
+
const label = theme.fg("text", theme.bold("Type something."));
|
|
216
|
+
const desc = theme.fg("muted", "Enter a custom response");
|
|
217
|
+
const line = `${prefix} ${label} ${theme.fg("dim", "—")} ${desc}`;
|
|
218
|
+
lines.push(truncateToWidth(line, width, ""));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const option = this.options[i];
|
|
223
|
+
if (!option) continue;
|
|
224
|
+
|
|
225
|
+
const checkbox = this.checked.has(i) ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
|
|
226
|
+
const num = theme.fg("dim", `${i + 1}.`);
|
|
227
|
+
const title = isSelected
|
|
228
|
+
? theme.fg("accent", theme.bold(option.title))
|
|
229
|
+
: theme.fg("text", theme.bold(option.title));
|
|
230
|
+
|
|
231
|
+
const firstLine = `${prefix} ${num} ${checkbox} ${title}`;
|
|
232
|
+
lines.push(truncateToWidth(firstLine, width, ""));
|
|
233
|
+
|
|
234
|
+
if (option.description) {
|
|
235
|
+
const indent = " ";
|
|
236
|
+
const wrapWidth = Math.max(10, width - indent.length);
|
|
237
|
+
const wrapped = wrapTextWithAnsi(option.description, wrapWidth);
|
|
238
|
+
for (const w of wrapped) {
|
|
239
|
+
lines.push(truncateToWidth(indent + theme.fg("muted", w), width, ""));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Scroll indicator
|
|
245
|
+
if (startIndex > 0 || endIndex < count) {
|
|
246
|
+
lines.push(theme.fg("dim", truncateToWidth(` (${this.selectedIndex + 1}/${count})`, width, "")));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this.cachedWidth = width;
|
|
250
|
+
this.cachedLines = lines;
|
|
251
|
+
return lines;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Interactive ask UI. Uses a root Container for layout and swaps the center
|
|
257
|
+
* component between SelectList/MultiSelectList and an Editor (freeform mode).
|
|
258
|
+
*/
|
|
259
|
+
class AskComponent extends Container {
|
|
260
|
+
private question: string;
|
|
261
|
+
private context?: string;
|
|
262
|
+
private options: QuestionOption[];
|
|
263
|
+
private allowMultiple: boolean;
|
|
264
|
+
private allowFreeform: boolean;
|
|
265
|
+
private tui: TUI;
|
|
266
|
+
private theme: Theme;
|
|
267
|
+
private onDone: (result: string | null) => void;
|
|
268
|
+
|
|
269
|
+
private mode: AskMode = "select";
|
|
270
|
+
|
|
271
|
+
// Static layout components
|
|
272
|
+
private titleText: Text;
|
|
273
|
+
private questionText: Text;
|
|
274
|
+
private contextText?: Text;
|
|
275
|
+
private modeContainer: Container;
|
|
276
|
+
private helpText: Text;
|
|
277
|
+
|
|
278
|
+
// Mode components
|
|
279
|
+
private selectList?: SelectList;
|
|
280
|
+
private multiSelectList?: MultiSelectList;
|
|
281
|
+
private editor?: Editor;
|
|
282
|
+
|
|
283
|
+
// Focus propagation for IME cursor positioning (Editor is Focusable)
|
|
284
|
+
private _focused = false;
|
|
285
|
+
get focused(): boolean {
|
|
286
|
+
return this._focused;
|
|
287
|
+
}
|
|
288
|
+
set focused(value: boolean) {
|
|
289
|
+
this._focused = value;
|
|
290
|
+
if (this.editor && this.mode === "freeform") {
|
|
291
|
+
const anyEditor = this.editor as unknown as { focused?: boolean };
|
|
292
|
+
anyEditor.focused = value;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
constructor(
|
|
297
|
+
question: string,
|
|
298
|
+
context: string | undefined,
|
|
299
|
+
options: QuestionOption[],
|
|
300
|
+
allowMultiple: boolean,
|
|
301
|
+
allowFreeform: boolean,
|
|
302
|
+
tui: TUI,
|
|
303
|
+
theme: Theme,
|
|
304
|
+
onDone: (result: string | null) => void,
|
|
305
|
+
) {
|
|
306
|
+
super();
|
|
307
|
+
|
|
308
|
+
this.question = question;
|
|
309
|
+
this.context = context;
|
|
310
|
+
this.options = options;
|
|
311
|
+
this.allowMultiple = allowMultiple;
|
|
312
|
+
this.allowFreeform = allowFreeform;
|
|
313
|
+
this.tui = tui;
|
|
314
|
+
this.theme = theme;
|
|
315
|
+
this.onDone = onDone;
|
|
316
|
+
|
|
317
|
+
// Layout skeleton
|
|
318
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
319
|
+
this.addChild(new Spacer(1));
|
|
320
|
+
|
|
321
|
+
this.titleText = new Text("", 1, 0);
|
|
322
|
+
this.addChild(this.titleText);
|
|
323
|
+
this.addChild(new Spacer(1));
|
|
324
|
+
|
|
325
|
+
this.questionText = new Text("", 1, 0);
|
|
326
|
+
this.addChild(this.questionText);
|
|
327
|
+
|
|
328
|
+
if (this.context) {
|
|
329
|
+
this.addChild(new Spacer(1));
|
|
330
|
+
this.contextText = new Text("", 1, 0);
|
|
331
|
+
this.addChild(this.contextText);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
this.addChild(new Spacer(1));
|
|
335
|
+
|
|
336
|
+
this.modeContainer = new Container();
|
|
337
|
+
this.addChild(this.modeContainer);
|
|
338
|
+
|
|
339
|
+
this.addChild(new Spacer(1));
|
|
340
|
+
this.helpText = new Text("", 1, 0);
|
|
341
|
+
this.addChild(this.helpText);
|
|
342
|
+
|
|
343
|
+
this.addChild(new Spacer(1));
|
|
344
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
345
|
+
|
|
346
|
+
this.updateStaticText();
|
|
347
|
+
this.showSelectMode();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
override invalidate(): void {
|
|
351
|
+
super.invalidate();
|
|
352
|
+
this.updateStaticText();
|
|
353
|
+
this.updateHelpText();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
override render(width: number): string[] {
|
|
357
|
+
// Defensive: ensure no line exceeds width, otherwise pi-tui will hard-crash.
|
|
358
|
+
const lines = super.render(width);
|
|
359
|
+
return lines.map((l) => truncateToWidth(l, width, ""));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private updateStaticText(): void {
|
|
363
|
+
const theme = this.theme;
|
|
364
|
+
this.titleText.setText(theme.fg("accent", theme.bold("Question")));
|
|
365
|
+
this.questionText.setText(theme.fg("text", theme.bold(this.question)));
|
|
366
|
+
if (this.contextText && this.context) {
|
|
367
|
+
this.contextText.setText(`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private updateHelpText(): void {
|
|
372
|
+
const theme = this.theme;
|
|
373
|
+
if (this.mode === "freeform") {
|
|
374
|
+
this.helpText.setText(
|
|
375
|
+
theme.fg(
|
|
376
|
+
"dim",
|
|
377
|
+
"enter submit • shift+enter newline • (ctrl+enter submit if supported) • esc back • ctrl+c cancel",
|
|
378
|
+
),
|
|
379
|
+
);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (this.allowMultiple) {
|
|
384
|
+
this.helpText.setText(theme.fg("dim", "↑↓ navigate • space toggle • enter submit • esc cancel"));
|
|
385
|
+
} else {
|
|
386
|
+
this.helpText.setText(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private buildSingleSelectItems(): SelectItem[] {
|
|
391
|
+
const items: SelectItem[] = this.options.map((o, idx) => ({
|
|
392
|
+
value: String(idx),
|
|
393
|
+
label: o.title,
|
|
394
|
+
description: o.description,
|
|
395
|
+
}));
|
|
396
|
+
|
|
397
|
+
if (this.allowFreeform) {
|
|
398
|
+
items.push({
|
|
399
|
+
value: FREEFORM_VALUE,
|
|
400
|
+
label: "Type something.",
|
|
401
|
+
description: "Enter a custom response",
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return items;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private ensureSingleSelectList(): SelectList {
|
|
409
|
+
if (this.selectList) return this.selectList;
|
|
410
|
+
|
|
411
|
+
const items = this.buildSingleSelectItems();
|
|
412
|
+
const selectList = new SelectList(items, Math.min(items.length, 10), createSelectListTheme(this.theme));
|
|
413
|
+
|
|
414
|
+
selectList.onSelect = (item) => {
|
|
415
|
+
if (item.value === FREEFORM_VALUE) {
|
|
416
|
+
this.showFreeformMode();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const idx = Number.parseInt(item.value, 10);
|
|
420
|
+
const option = this.options[idx];
|
|
421
|
+
this.onDone(option?.title ?? null);
|
|
422
|
+
};
|
|
423
|
+
selectList.onCancel = () => this.onDone(null);
|
|
424
|
+
|
|
425
|
+
this.selectList = selectList;
|
|
426
|
+
return selectList;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private ensureMultiSelectList(): MultiSelectList {
|
|
430
|
+
if (this.multiSelectList) return this.multiSelectList;
|
|
431
|
+
|
|
432
|
+
const list = new MultiSelectList(this.options, this.allowFreeform, this.theme);
|
|
433
|
+
list.onCancel = () => this.onDone(null);
|
|
434
|
+
list.onSubmit = (result) => this.onDone(result);
|
|
435
|
+
list.onEnterFreeform = () => this.showFreeformMode();
|
|
436
|
+
|
|
437
|
+
this.multiSelectList = list;
|
|
438
|
+
return list;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private ensureEditor(): Editor {
|
|
442
|
+
if (this.editor) return this.editor;
|
|
443
|
+
// Note: pi's bundled pi-tui Editor expects (tui, theme, options?)
|
|
444
|
+
const editor = new Editor(this.tui, createEditorTheme(this.theme));
|
|
445
|
+
// Default Editor behavior: Enter submits, Shift+Enter inserts newline.
|
|
446
|
+
// Ctrl+Enter is only distinguishable in terminals with Kitty protocol mappings,
|
|
447
|
+
// so we support it as an *additional* submit shortcut in our wrapper.
|
|
448
|
+
editor.disableSubmit = false;
|
|
449
|
+
editor.onSubmit = (text: string) => {
|
|
450
|
+
const trimmed = text.trim();
|
|
451
|
+
this.onDone(trimmed ? trimmed : null);
|
|
452
|
+
};
|
|
453
|
+
this.editor = editor;
|
|
454
|
+
return editor;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private showSelectMode(): void {
|
|
458
|
+
this.mode = "select";
|
|
459
|
+
this.modeContainer.clear();
|
|
460
|
+
|
|
461
|
+
if (this.allowMultiple) {
|
|
462
|
+
this.modeContainer.addChild(this.ensureMultiSelectList());
|
|
463
|
+
} else {
|
|
464
|
+
this.modeContainer.addChild(this.ensureSingleSelectList());
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
this.updateHelpText();
|
|
468
|
+
this.invalidate();
|
|
469
|
+
this.tui.requestRender();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private showFreeformMode(): void {
|
|
473
|
+
this.mode = "freeform";
|
|
474
|
+
this.modeContainer.clear();
|
|
475
|
+
|
|
476
|
+
const editor = this.ensureEditor();
|
|
477
|
+
// Ensure focus is propagated immediately when switching modes.
|
|
478
|
+
(editor as unknown as { focused?: boolean }).focused = this._focused;
|
|
479
|
+
|
|
480
|
+
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
|
|
481
|
+
this.modeContainer.addChild(new Spacer(1));
|
|
482
|
+
this.modeContainer.addChild(editor);
|
|
483
|
+
|
|
484
|
+
this.updateHelpText();
|
|
485
|
+
this.invalidate();
|
|
486
|
+
this.tui.requestRender();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private submitFreeform(): void {
|
|
490
|
+
const editor = this.ensureEditor();
|
|
491
|
+
const text = editor.getText().trim();
|
|
492
|
+
this.onDone(text ? text : null);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
handleInput(data: string): void {
|
|
496
|
+
if (this.mode === "freeform") {
|
|
497
|
+
if (matchesKey(data, Key.escape)) {
|
|
498
|
+
this.showSelectMode();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
503
|
+
this.onDone(null);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Submit on Ctrl+Enter (only works if terminal distinguishes it, e.g. Kitty protocol)
|
|
508
|
+
if (matchesKey(data, Key.ctrl("enter")) || matchesKey(data, "ctrl+enter")) {
|
|
509
|
+
this.submitFreeform();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Let Editor handle everything else (Enter submits, Shift+Enter newline)
|
|
514
|
+
this.ensureEditor().handleInput(data);
|
|
515
|
+
this.tui.requestRender();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Selection mode
|
|
520
|
+
if (this.allowMultiple) {
|
|
521
|
+
this.ensureMultiSelectList().handleInput?.(data);
|
|
522
|
+
this.tui.requestRender();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
this.ensureSingleSelectList().handleInput?.(data);
|
|
527
|
+
this.tui.requestRender();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export default function (pi: ExtensionAPI) {
|
|
532
|
+
pi.registerTool({
|
|
533
|
+
name: "ask_user",
|
|
534
|
+
label: "Ask User",
|
|
535
|
+
description:
|
|
536
|
+
"Ask the user a question with optional multiple-choice answers. Use this to gather information interactively. Before calling, gather context with tools (read/exa/ref) and pass a short summary via the context field.",
|
|
537
|
+
parameters: Type.Object({
|
|
538
|
+
question: Type.String({ description: "The question to ask the user" }),
|
|
539
|
+
context: Type.Optional(
|
|
540
|
+
Type.String({
|
|
541
|
+
description: "Relevant context to show before the question (summary of findings)",
|
|
542
|
+
}),
|
|
543
|
+
),
|
|
544
|
+
options: Type.Optional(
|
|
545
|
+
Type.Array(
|
|
546
|
+
Type.Union([
|
|
547
|
+
Type.String({ description: "Short title for this option" }),
|
|
548
|
+
Type.Object({
|
|
549
|
+
title: Type.String({ description: "Short title for this option" }),
|
|
550
|
+
description: Type.Optional(
|
|
551
|
+
Type.String({ description: "Longer description explaining this option" }),
|
|
552
|
+
),
|
|
553
|
+
}),
|
|
554
|
+
]),
|
|
555
|
+
{ description: "List of options for the user to choose from" },
|
|
556
|
+
),
|
|
557
|
+
),
|
|
558
|
+
allowMultiple: Type.Optional(
|
|
559
|
+
Type.Boolean({ description: "Allow selecting multiple options. Default: false" }),
|
|
560
|
+
),
|
|
561
|
+
allowFreeform: Type.Optional(
|
|
562
|
+
Type.Boolean({ description: "Add a freeform text option. Default: true" }),
|
|
563
|
+
),
|
|
564
|
+
}),
|
|
565
|
+
|
|
566
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
567
|
+
if (signal?.aborted) {
|
|
568
|
+
return { content: [{ type: "text", text: "Cancelled" }] };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const {
|
|
572
|
+
question,
|
|
573
|
+
context,
|
|
574
|
+
options: rawOptions = [],
|
|
575
|
+
allowMultiple = false,
|
|
576
|
+
allowFreeform = true,
|
|
577
|
+
} = params as AskParams;
|
|
578
|
+
const options = normalizeOptions(rawOptions);
|
|
579
|
+
const normalizedContext = context?.trim() || undefined;
|
|
580
|
+
|
|
581
|
+
if (!ctx.hasUI || !ctx.ui) {
|
|
582
|
+
const optionText = options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(options)}` : "";
|
|
583
|
+
const freeformHint = allowFreeform ? "\n\nYou can also answer freely." : "";
|
|
584
|
+
const contextText = normalizedContext ? `\n\nContext:\n${normalizedContext}` : "";
|
|
585
|
+
return {
|
|
586
|
+
content: [
|
|
587
|
+
{
|
|
588
|
+
type: "text",
|
|
589
|
+
text: `Ask requires interactive mode. Please answer:\n\n${question}${contextText}${optionText}${freeformHint}`,
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
isError: true,
|
|
593
|
+
details: { question, context: normalizedContext, options },
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// If no options provided, fall back to freeform input prompt.
|
|
598
|
+
if (options.length === 0) {
|
|
599
|
+
const prompt = normalizedContext ? `${question}\n\nContext:\n${normalizedContext}` : question;
|
|
600
|
+
const answer = await ctx.ui.input(prompt, "Type your answer...");
|
|
601
|
+
|
|
602
|
+
if (!answer) {
|
|
603
|
+
return { content: [{ type: "text", text: "User cancelled the question" }] };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return { content: [{ type: "text", text: `User answered: ${answer}` }] };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
let result: string | null;
|
|
610
|
+
try {
|
|
611
|
+
result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
612
|
+
return new AskComponent(
|
|
613
|
+
question,
|
|
614
|
+
normalizedContext,
|
|
615
|
+
options,
|
|
616
|
+
allowMultiple,
|
|
617
|
+
allowFreeform,
|
|
618
|
+
tui,
|
|
619
|
+
theme,
|
|
620
|
+
done,
|
|
621
|
+
);
|
|
622
|
+
});
|
|
623
|
+
} catch (error) {
|
|
624
|
+
const message =
|
|
625
|
+
error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
|
|
626
|
+
return {
|
|
627
|
+
content: [{ type: "text", text: `Ask tool failed: ${message}` }],
|
|
628
|
+
isError: true,
|
|
629
|
+
details: { error: message },
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (result === null) {
|
|
634
|
+
return { content: [{ type: "text", text: "User cancelled the question" }] };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return { content: [{ type: "text", text: `User answered: ${result}` }] };
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-ask-user",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive ask_user tool for pi-coding-agent with multi-select and freeform input UI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"extension",
|
|
11
|
+
"ask",
|
|
12
|
+
"ask_user",
|
|
13
|
+
"interactive"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"index.ts",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
28
|
+
"@mariozechner/pi-tui": "*",
|
|
29
|
+
"@sinclair/typebox": "*"
|
|
30
|
+
}
|
|
31
|
+
}
|