@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/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @pi-unipi/ask-user
|
|
2
|
+
|
|
3
|
+
Structured user input tool for the Pi coding agent — part of the Unipi suite.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
### `ask_user` Tool
|
|
8
|
+
|
|
9
|
+
Ask the user a question with structured options. Supports three modes:
|
|
10
|
+
|
|
11
|
+
- **Single-select** — Pick one option from a list
|
|
12
|
+
- **Multi-select** — Toggle multiple options, then submit
|
|
13
|
+
- **Freeform** — Type a custom answer
|
|
14
|
+
|
|
15
|
+
### Usage
|
|
16
|
+
|
|
17
|
+
The agent calls the tool when it needs user input:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
ask_user({
|
|
21
|
+
question: "Which database should we use?",
|
|
22
|
+
options: [
|
|
23
|
+
{ label: "PostgreSQL", description: "Reliable, feature-rich" },
|
|
24
|
+
{ label: "SQLite", description: "Simple, serverless" },
|
|
25
|
+
],
|
|
26
|
+
})
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Parameters
|
|
30
|
+
|
|
31
|
+
| Parameter | Type | Default | Description |
|
|
32
|
+
|-----------|------|---------|-------------|
|
|
33
|
+
| `question` | string | required | The question to ask |
|
|
34
|
+
| `context` | string? | — | Additional context shown before question |
|
|
35
|
+
| `options` | array? | [] | Multiple-choice options |
|
|
36
|
+
| `allowMultiple` | boolean? | false | Enable multi-select mode |
|
|
37
|
+
| `allowFreeform` | boolean? | true | Allow freeform text input |
|
|
38
|
+
| `timeout` | number? | — | Auto-dismiss after N ms |
|
|
39
|
+
|
|
40
|
+
### Keyboard Controls
|
|
41
|
+
|
|
42
|
+
| Mode | Keys |
|
|
43
|
+
|------|------|
|
|
44
|
+
| Single-select | ↑↓ navigate, Enter select, Esc cancel |
|
|
45
|
+
| Multi-select | ↑↓ navigate, Space toggle, Enter submit, Esc cancel |
|
|
46
|
+
| Freeform | Type text, Enter submit, Esc back |
|
|
47
|
+
|
|
48
|
+
### TUI Display
|
|
49
|
+
|
|
50
|
+
**Single-select:**
|
|
51
|
+
```
|
|
52
|
+
─────────────────────────────
|
|
53
|
+
Which approach should we use?
|
|
54
|
+
─────────────────────────────
|
|
55
|
+
> Option A
|
|
56
|
+
Option B
|
|
57
|
+
Option C
|
|
58
|
+
Type something...
|
|
59
|
+
|
|
60
|
+
↑↓ navigate • Enter select • Esc cancel
|
|
61
|
+
─────────────────────────────
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Multi-select:**
|
|
65
|
+
```
|
|
66
|
+
─────────────────────────────
|
|
67
|
+
Which features to enable?
|
|
68
|
+
─────────────────────────────
|
|
69
|
+
> [✓] Logging
|
|
70
|
+
[ ] Metrics
|
|
71
|
+
[✓] Tracing
|
|
72
|
+
[ ] Type something...
|
|
73
|
+
|
|
74
|
+
↑↓ navigate • Space toggle • Enter submit • Esc cancel
|
|
75
|
+
─────────────────────────────
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Installation
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pi install npm:@pi-unipi/ask-user
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Or install the full Unipi suite:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pi install npm:@pi-unipi/unipi
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Bundled Skill
|
|
91
|
+
|
|
92
|
+
The package includes a skill that guides the agent to use `ask_user` for high-stakes decisions. The skill is automatically discovered when the extension loads.
|
|
93
|
+
|
|
94
|
+
## Dependencies
|
|
95
|
+
|
|
96
|
+
- `@pi-unipi/core` — Shared constants and utilities
|
|
97
|
+
- `@mariozechner/pi-coding-agent` — Pi extension API
|
|
98
|
+
- `@mariozechner/pi-tui` — TUI components
|
|
99
|
+
- `@sinclair/typebox` — Schema validation
|
package/ask-ui.ts
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/ask-user — TUI Components
|
|
3
|
+
*
|
|
4
|
+
* Interactive UI for single-select, multi-select, and freeform input.
|
|
5
|
+
* Uses ctx.ui.custom() callback pattern following question.ts/questionnaire.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
9
|
+
import type { NormalizedOption, AskUserResponse } from "./types.js";
|
|
10
|
+
|
|
11
|
+
/** Result returned by the ask UI */
|
|
12
|
+
export interface AskUIResult {
|
|
13
|
+
response: AskUserResponse;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Render the ask_user interactive UI.
|
|
18
|
+
*
|
|
19
|
+
* Supports:
|
|
20
|
+
* - Single-select: arrow keys + Enter
|
|
21
|
+
* - Multi-select: Space to toggle, Enter to submit
|
|
22
|
+
* - Freeform: text input via Editor
|
|
23
|
+
* - Timeout: auto-dismiss after N ms
|
|
24
|
+
* - Cancel: Escape key
|
|
25
|
+
*/
|
|
26
|
+
export function renderAskUI(params: {
|
|
27
|
+
question: string;
|
|
28
|
+
context?: string;
|
|
29
|
+
options: NormalizedOption[];
|
|
30
|
+
allowMultiple: boolean;
|
|
31
|
+
allowFreeform: boolean;
|
|
32
|
+
timeout?: number;
|
|
33
|
+
}): (
|
|
34
|
+
tui: any,
|
|
35
|
+
theme: any,
|
|
36
|
+
kb: any,
|
|
37
|
+
done: (result: AskUIResult | null) => void,
|
|
38
|
+
) => {
|
|
39
|
+
render: (width: number) => string[];
|
|
40
|
+
invalidate: () => void;
|
|
41
|
+
handleInput: (data: string) => void;
|
|
42
|
+
} {
|
|
43
|
+
return (tui, theme, _kb, done) => {
|
|
44
|
+
const { question, context, options, allowMultiple, allowFreeform, timeout } = params;
|
|
45
|
+
|
|
46
|
+
// Build display options — add "Custom response" if allowFreeform
|
|
47
|
+
const displayOptions: (NormalizedOption & { isFreeform?: boolean })[] = [
|
|
48
|
+
...options,
|
|
49
|
+
];
|
|
50
|
+
if (allowFreeform) {
|
|
51
|
+
displayOptions.push({
|
|
52
|
+
label: "Custom response",
|
|
53
|
+
value: "__freeform__",
|
|
54
|
+
isFreeform: true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// State
|
|
59
|
+
let optionIndex = 0;
|
|
60
|
+
let editMode = false;
|
|
61
|
+
let cachedLines: string[] | undefined;
|
|
62
|
+
const selected = new Set<string>();
|
|
63
|
+
let customText: string | null = null; // Store custom text
|
|
64
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
+
let remainingMs = timeout;
|
|
66
|
+
|
|
67
|
+
// Editor for freeform input
|
|
68
|
+
const editorTheme: EditorTheme = {
|
|
69
|
+
borderColor: (s: any) => theme.fg("accent", s),
|
|
70
|
+
selectList: {
|
|
71
|
+
selectedPrefix: (t: any) => theme.fg("accent", t),
|
|
72
|
+
selectedText: (t: any) => theme.fg("accent", t),
|
|
73
|
+
description: (t: any) => theme.fg("muted", t),
|
|
74
|
+
scrollInfo: (t: any) => theme.fg("dim", t),
|
|
75
|
+
noMatch: (t: any) => theme.fg("warning", t),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const editor = new Editor(tui, editorTheme);
|
|
79
|
+
|
|
80
|
+
editor.onSubmit = (value: string) => {
|
|
81
|
+
const trimmed = value.trim();
|
|
82
|
+
if (trimmed) {
|
|
83
|
+
customText = trimmed;
|
|
84
|
+
editMode = false;
|
|
85
|
+
editor.setText("");
|
|
86
|
+
refresh();
|
|
87
|
+
} else {
|
|
88
|
+
// If empty and no previous custom text, uncheck freeform option
|
|
89
|
+
if (!customText) {
|
|
90
|
+
selected.delete("__freeform__");
|
|
91
|
+
}
|
|
92
|
+
editMode = false;
|
|
93
|
+
editor.setText("");
|
|
94
|
+
refresh();
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function cleanup() {
|
|
99
|
+
if (timeoutId) {
|
|
100
|
+
clearTimeout(timeoutId);
|
|
101
|
+
timeoutId = undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function refresh() {
|
|
106
|
+
cachedLines = undefined;
|
|
107
|
+
tui.requestRender();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Setup timeout if specified
|
|
111
|
+
if (timeout && timeout > 0) {
|
|
112
|
+
remainingMs = timeout;
|
|
113
|
+
const startTime = Date.now();
|
|
114
|
+
const tickInterval = setInterval(() => {
|
|
115
|
+
const elapsed = Date.now() - startTime;
|
|
116
|
+
remainingMs = Math.max(0, timeout - elapsed);
|
|
117
|
+
refresh();
|
|
118
|
+
if (remainingMs <= 0) {
|
|
119
|
+
clearInterval(tickInterval);
|
|
120
|
+
}
|
|
121
|
+
}, 1000);
|
|
122
|
+
|
|
123
|
+
timeoutId = setTimeout(() => {
|
|
124
|
+
clearInterval(tickInterval);
|
|
125
|
+
cleanup();
|
|
126
|
+
done({
|
|
127
|
+
response: {
|
|
128
|
+
kind: "timed_out",
|
|
129
|
+
comment: `Timed out after ${timeout}ms`,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}, timeout);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function handleInput(data: string) {
|
|
136
|
+
// Edit mode: route to editor
|
|
137
|
+
if (editMode) {
|
|
138
|
+
if (matchesKey(data, Key.escape)) {
|
|
139
|
+
// Cancel text input
|
|
140
|
+
if (!customText) {
|
|
141
|
+
// No custom text yet: uncheck freeform option
|
|
142
|
+
selected.delete("__freeform__");
|
|
143
|
+
}
|
|
144
|
+
editMode = false;
|
|
145
|
+
editor.setText("");
|
|
146
|
+
refresh();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
editor.handleInput(data);
|
|
150
|
+
refresh();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Navigation
|
|
155
|
+
if (matchesKey(data, Key.up)) {
|
|
156
|
+
optionIndex = Math.max(0, optionIndex - 1);
|
|
157
|
+
refresh();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (matchesKey(data, Key.down)) {
|
|
161
|
+
optionIndex = Math.min(displayOptions.length - 1, optionIndex + 1);
|
|
162
|
+
refresh();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Multi-select: Space to toggle
|
|
167
|
+
if (allowMultiple && matchesKey(data, Key.space)) {
|
|
168
|
+
const opt = displayOptions[optionIndex];
|
|
169
|
+
const val = opt.value;
|
|
170
|
+
if (opt.isFreeform) {
|
|
171
|
+
// Freeform option: toggle and enter edit mode if checking
|
|
172
|
+
if (selected.has(val)) {
|
|
173
|
+
// Unchecking: clear custom text
|
|
174
|
+
selected.delete(val);
|
|
175
|
+
customText = null;
|
|
176
|
+
} else {
|
|
177
|
+
// Checking: enter edit mode to get custom text
|
|
178
|
+
selected.add(val);
|
|
179
|
+
if (!customText) {
|
|
180
|
+
editMode = true;
|
|
181
|
+
editor.setText("");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// Regular option: toggle
|
|
186
|
+
if (selected.has(val)) {
|
|
187
|
+
selected.delete(val);
|
|
188
|
+
} else {
|
|
189
|
+
selected.add(val);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
refresh();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Enter: select or submit
|
|
197
|
+
if (matchesKey(data, Key.enter)) {
|
|
198
|
+
const opt = displayOptions[optionIndex];
|
|
199
|
+
|
|
200
|
+
if (opt.isFreeform) {
|
|
201
|
+
// Freeform option: if already checked with text, submit; otherwise enter edit mode
|
|
202
|
+
if (selected.has(opt.value) && customText) {
|
|
203
|
+
// Already checked with text: submit the form
|
|
204
|
+
cleanup();
|
|
205
|
+
const regularSelections = Array.from(selected).filter(v => v !== "__freeform__");
|
|
206
|
+
if (regularSelections.length > 0) {
|
|
207
|
+
done({
|
|
208
|
+
response: {
|
|
209
|
+
kind: "combined",
|
|
210
|
+
selections: regularSelections,
|
|
211
|
+
text: customText,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
done({
|
|
216
|
+
response: {
|
|
217
|
+
kind: "freeform",
|
|
218
|
+
text: customText,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
// Not checked or no text: check it and enter edit mode
|
|
224
|
+
selected.add(opt.value);
|
|
225
|
+
editMode = true;
|
|
226
|
+
editor.setText("");
|
|
227
|
+
}
|
|
228
|
+
refresh();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (allowMultiple) {
|
|
233
|
+
// In multi-select, Enter submits current selection
|
|
234
|
+
if (selected.size > 0) {
|
|
235
|
+
cleanup();
|
|
236
|
+
if (customText && selected.has("__freeform__")) {
|
|
237
|
+
// Combined response with custom text
|
|
238
|
+
const regularSelections = Array.from(selected).filter(v => v !== "__freeform__");
|
|
239
|
+
done({
|
|
240
|
+
response: {
|
|
241
|
+
kind: "combined",
|
|
242
|
+
selections: regularSelections,
|
|
243
|
+
text: customText,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
} else {
|
|
247
|
+
// Regular selection
|
|
248
|
+
done({
|
|
249
|
+
response: {
|
|
250
|
+
kind: "selection",
|
|
251
|
+
selections: Array.from(selected),
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Single-select: return immediately
|
|
260
|
+
cleanup();
|
|
261
|
+
done({
|
|
262
|
+
response: {
|
|
263
|
+
kind: "selection",
|
|
264
|
+
selections: [opt.value],
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Escape: cancel
|
|
271
|
+
if (matchesKey(data, Key.escape)) {
|
|
272
|
+
cleanup();
|
|
273
|
+
done(null);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function render(width: number): string[] {
|
|
278
|
+
if (cachedLines) return cachedLines;
|
|
279
|
+
|
|
280
|
+
const lines: string[] = [];
|
|
281
|
+
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
|
282
|
+
|
|
283
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
284
|
+
|
|
285
|
+
// Context
|
|
286
|
+
if (context) {
|
|
287
|
+
add(theme.fg("muted", ` ${context}`));
|
|
288
|
+
lines.push("");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Question
|
|
292
|
+
add(theme.fg("text", ` ${question}`));
|
|
293
|
+
lines.push("");
|
|
294
|
+
|
|
295
|
+
// Options (editor is now inline with freeform option)
|
|
296
|
+
renderOptions(lines, add, theme, width);
|
|
297
|
+
|
|
298
|
+
// Timeout countdown
|
|
299
|
+
if (timeout && remainingMs !== undefined && remainingMs > 0) {
|
|
300
|
+
lines.push("");
|
|
301
|
+
const secs = Math.ceil(remainingMs / 1000);
|
|
302
|
+
add(theme.fg("dim", ` ⏱ ${secs}s remaining`));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
lines.push("");
|
|
306
|
+
if (editMode) {
|
|
307
|
+
add(theme.fg("dim", " Enter to confirm text • Esc to cancel text input"));
|
|
308
|
+
} else if (allowMultiple) {
|
|
309
|
+
add(theme.fg("dim", " ↑↓ navigate • Space toggle • Enter submit • Esc cancel"));
|
|
310
|
+
} else {
|
|
311
|
+
add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
|
|
312
|
+
}
|
|
313
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
314
|
+
|
|
315
|
+
cachedLines = lines;
|
|
316
|
+
return lines;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function renderOptions(
|
|
320
|
+
lines: string[],
|
|
321
|
+
add: (s: string) => void,
|
|
322
|
+
theme: any,
|
|
323
|
+
width: number,
|
|
324
|
+
) {
|
|
325
|
+
for (let i = 0; i < displayOptions.length; i++) {
|
|
326
|
+
const opt = displayOptions[i];
|
|
327
|
+
const isSelected = i === optionIndex;
|
|
328
|
+
const prefix = isSelected ? theme.fg("accent", "> ") : " ";
|
|
329
|
+
|
|
330
|
+
if (opt.isFreeform) {
|
|
331
|
+
// Freeform option: show checkbox like regular option
|
|
332
|
+
const checked = selected.has(opt.value);
|
|
333
|
+
const box = checked ? "✓" : " ";
|
|
334
|
+
const color = checked ? "success" : isSelected ? "accent" : "text";
|
|
335
|
+
|
|
336
|
+
let label = opt.label;
|
|
337
|
+
if (checked && customText) {
|
|
338
|
+
// Show custom text next to label
|
|
339
|
+
label = `${opt.label}: "${customText}"`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
add(
|
|
343
|
+
prefix +
|
|
344
|
+
theme.fg(color, `[${box}]`) +
|
|
345
|
+
" " +
|
|
346
|
+
theme.fg(isSelected ? "accent" : "text", label),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// Show edit indicator if in edit mode for this option
|
|
350
|
+
if (editMode && isSelected) {
|
|
351
|
+
add(` ${theme.fg("muted", "Type your response:")}`);
|
|
352
|
+
for (const line of editor.render(width - 4)) {
|
|
353
|
+
add(` ${line}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} else if (allowMultiple) {
|
|
357
|
+
// Multi-select: show checkbox
|
|
358
|
+
const checked = selected.has(opt.value);
|
|
359
|
+
const box = checked ? "✓" : " ";
|
|
360
|
+
const color = checked ? "success" : isSelected ? "accent" : "text";
|
|
361
|
+
add(
|
|
362
|
+
prefix +
|
|
363
|
+
theme.fg(color, `[${box}]`) +
|
|
364
|
+
" " +
|
|
365
|
+
theme.fg(isSelected ? "accent" : "text", opt.label),
|
|
366
|
+
);
|
|
367
|
+
} else {
|
|
368
|
+
// Single-select: simple option
|
|
369
|
+
add(
|
|
370
|
+
prefix +
|
|
371
|
+
(isSelected
|
|
372
|
+
? theme.fg("accent", opt.label)
|
|
373
|
+
: theme.fg("text", opt.label)),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Description
|
|
378
|
+
if (opt.description) {
|
|
379
|
+
add(` ${theme.fg("muted", opt.description)}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
render,
|
|
386
|
+
invalidate: () => {
|
|
387
|
+
cachedLines = undefined;
|
|
388
|
+
},
|
|
389
|
+
handleInput,
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Create a renderCall function for the ask_user tool.
|
|
396
|
+
*/
|
|
397
|
+
export function createRenderCall() {
|
|
398
|
+
return (args: any, theme: any, _context: any) => {
|
|
399
|
+
const question = args.question || "";
|
|
400
|
+
const options = Array.isArray(args.options) ? args.options : [];
|
|
401
|
+
const mode = args.allowMultiple ? "multi-select" : "single-select";
|
|
402
|
+
const count = options.length;
|
|
403
|
+
|
|
404
|
+
let text =
|
|
405
|
+
theme.fg("toolTitle", theme.bold("ask_user ")) +
|
|
406
|
+
theme.fg("muted", question);
|
|
407
|
+
if (count > 0) {
|
|
408
|
+
text += theme.fg("dim", ` (${count} option${count !== 1 ? "s" : ""}, ${mode})`);
|
|
409
|
+
}
|
|
410
|
+
return new Text(text, 0, 0);
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Create a renderResult function for the ask_user tool.
|
|
416
|
+
*/
|
|
417
|
+
export function createRenderResult() {
|
|
418
|
+
return (result: any, _options: any, theme: any, _context: any) => {
|
|
419
|
+
const details = result.details;
|
|
420
|
+
if (!details) {
|
|
421
|
+
const text = result.content?.[0];
|
|
422
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const response = details.response as AskUserResponse;
|
|
426
|
+
if (!response) {
|
|
427
|
+
return new Text(theme.fg("warning", "No response"), 0, 0);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
switch (response.kind) {
|
|
431
|
+
case "cancelled":
|
|
432
|
+
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
433
|
+
case "timed_out":
|
|
434
|
+
return new Text(theme.fg("warning", "Timed out"), 0, 0);
|
|
435
|
+
case "freeform":
|
|
436
|
+
return new Text(
|
|
437
|
+
theme.fg("success", "✓ ") +
|
|
438
|
+
theme.fg("muted", "(wrote) ") +
|
|
439
|
+
theme.fg("accent", response.text || ""),
|
|
440
|
+
0,
|
|
441
|
+
0,
|
|
442
|
+
);
|
|
443
|
+
case "selection": {
|
|
444
|
+
const selections = response.selections || [];
|
|
445
|
+
const display =
|
|
446
|
+
selections.length === 1
|
|
447
|
+
? selections[0]
|
|
448
|
+
: selections.join(", ");
|
|
449
|
+
return new Text(
|
|
450
|
+
theme.fg("success", "✓ ") + theme.fg("accent", display),
|
|
451
|
+
0,
|
|
452
|
+
0,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
case "combined": {
|
|
456
|
+
const selections = response.selections || [];
|
|
457
|
+
const selDisplay = selections.length === 1
|
|
458
|
+
? selections[0]
|
|
459
|
+
: selections.join(", ");
|
|
460
|
+
return new Text(
|
|
461
|
+
theme.fg("success", "✓ ") +
|
|
462
|
+
theme.fg("accent", selDisplay) +
|
|
463
|
+
theme.fg("muted", " and wrote ") +
|
|
464
|
+
theme.fg("accent", response.text || ""),
|
|
465
|
+
0,
|
|
466
|
+
0,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
default:
|
|
470
|
+
return new Text(
|
|
471
|
+
theme.fg("text", JSON.stringify(response)),
|
|
472
|
+
0,
|
|
473
|
+
0,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
}
|
package/commands.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/ask-user — Command registration
|
|
3
|
+
*
|
|
4
|
+
* Registers settings command for ask_user tool.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import { UNIPI_PREFIX } from "@pi-unipi/core";
|
|
9
|
+
import { AskUserSettingsOverlay } from "./settings-tui.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register ask-user commands.
|
|
13
|
+
*/
|
|
14
|
+
export function registerAskUserCommands(pi: ExtensionAPI): void {
|
|
15
|
+
pi.registerCommand(`${UNIPI_PREFIX}ask-user-settings`, {
|
|
16
|
+
description: "Configure ask_user tool settings",
|
|
17
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
18
|
+
if (!ctx.hasUI) {
|
|
19
|
+
if (ctx.hasUI) {
|
|
20
|
+
ctx.ui.notify("Settings require an interactive UI.", "warning");
|
|
21
|
+
}
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ctx.ui.custom(
|
|
26
|
+
(tui: any, _theme: any, _keybindings: any, done: any) => {
|
|
27
|
+
const overlay = new AskUserSettingsOverlay();
|
|
28
|
+
overlay.onClose = () => done(undefined);
|
|
29
|
+
return {
|
|
30
|
+
render: (w: number) => overlay.render(w),
|
|
31
|
+
invalidate: () => overlay.invalidate(),
|
|
32
|
+
handleInput: (data: string) => {
|
|
33
|
+
overlay.handleInput(data);
|
|
34
|
+
tui.requestRender();
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
overlay: true,
|
|
40
|
+
overlayOptions: {
|
|
41
|
+
width: "80%",
|
|
42
|
+
minWidth: 60,
|
|
43
|
+
anchor: "center",
|
|
44
|
+
margin: 2,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|