@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 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
+ }