pi-ask-user 0.2.1 → 0.4.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/README.md +45 -0
- package/index.ts +268 -57
- package/package.json +2 -1
- package/single-select-layout.ts +172 -0
package/README.md
CHANGED
|
@@ -14,6 +14,11 @@ High-quality video: [ask-user-demo.mp4](./media/ask-user-demo.mp4)
|
|
|
14
14
|
- Multi-select option lists
|
|
15
15
|
- Optional freeform responses
|
|
16
16
|
- Context display support
|
|
17
|
+
- Overlay mode — dialog floats over conversation, preserving context
|
|
18
|
+
- Custom TUI rendering for tool calls and results
|
|
19
|
+
- System prompt integration via `promptSnippet` and `promptGuidelines`
|
|
20
|
+
- Optional timeout for auto-dismiss (fallback input mode)
|
|
21
|
+
- Structured `details` on all results for session state reconstruction
|
|
17
22
|
- Graceful fallback when interactive UI is unavailable
|
|
18
23
|
- Bundled `ask-user` skill for mandatory decision-gating in high-stakes or ambiguous tasks
|
|
19
24
|
|
|
@@ -46,6 +51,17 @@ The registered tool name is:
|
|
|
46
51
|
|
|
47
52
|
- `ask_user`
|
|
48
53
|
|
|
54
|
+
## Parameters
|
|
55
|
+
|
|
56
|
+
| Parameter | Type | Default | Description |
|
|
57
|
+
|-----------|------|---------|-------------|
|
|
58
|
+
| `question` | `string` | *required* | The question to ask the user |
|
|
59
|
+
| `context` | `string?` | — | Relevant context summary shown before the question |
|
|
60
|
+
| `options` | `(string \| {title, description?})[]?` | `[]` | Multiple-choice options |
|
|
61
|
+
| `allowMultiple` | `boolean?` | `false` | Enable multi-select mode |
|
|
62
|
+
| `allowFreeform` | `boolean?` | `true` | Add a "Type something" freeform option |
|
|
63
|
+
| `timeout` | `number?` | — | Auto-dismiss after N ms (applies to fallback input mode) |
|
|
64
|
+
|
|
49
65
|
## Example usage shape
|
|
50
66
|
|
|
51
67
|
```json
|
|
@@ -60,3 +76,32 @@ The registered tool name is:
|
|
|
60
76
|
"allowFreeform": true
|
|
61
77
|
}
|
|
62
78
|
```
|
|
79
|
+
|
|
80
|
+
## Result details
|
|
81
|
+
|
|
82
|
+
All tool results include a structured `details` object for rendering and session state reconstruction:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
interface AskToolDetails {
|
|
86
|
+
question: string;
|
|
87
|
+
context?: string;
|
|
88
|
+
options: QuestionOption[];
|
|
89
|
+
answer: string | null;
|
|
90
|
+
cancelled: boolean;
|
|
91
|
+
wasCustom?: boolean;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Changelog
|
|
96
|
+
|
|
97
|
+
### 0.3.0
|
|
98
|
+
|
|
99
|
+
- Added `promptSnippet` and `promptGuidelines` for better LLM tool selection in the system prompt
|
|
100
|
+
- Added `renderCall` and `renderResult` for custom TUI rendering (compact tool call display, ✓/Cancelled result indicators)
|
|
101
|
+
- Added overlay mode — dialog now floats over the conversation instead of clearing the screen
|
|
102
|
+
- Added `timeout` parameter for auto-dismiss in fallback input mode (when no options are provided)
|
|
103
|
+
- Added structured `details` (`AskToolDetails`) to all result paths for session state reconstruction and branching support
|
|
104
|
+
|
|
105
|
+
### 0.2.1
|
|
106
|
+
|
|
107
|
+
- Initial public release
|
package/index.ts
CHANGED
|
@@ -15,14 +15,13 @@ import {
|
|
|
15
15
|
type EditorTheme,
|
|
16
16
|
Key,
|
|
17
17
|
matchesKey,
|
|
18
|
-
SelectList,
|
|
19
|
-
type SelectItem,
|
|
20
18
|
Spacer,
|
|
21
19
|
Text,
|
|
22
20
|
type TUI,
|
|
23
21
|
truncateToWidth,
|
|
24
22
|
wrapTextWithAnsi,
|
|
25
23
|
} from "@mariozechner/pi-tui";
|
|
24
|
+
import { renderSingleSelectRows } from "./single-select-layout";
|
|
26
25
|
|
|
27
26
|
interface QuestionOption {
|
|
28
27
|
title: string;
|
|
@@ -37,6 +36,16 @@ interface AskParams {
|
|
|
37
36
|
options?: AskOptionInput[];
|
|
38
37
|
allowMultiple?: boolean;
|
|
39
38
|
allowFreeform?: boolean;
|
|
39
|
+
timeout?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AskToolDetails {
|
|
43
|
+
question: string;
|
|
44
|
+
context?: string;
|
|
45
|
+
options: QuestionOption[];
|
|
46
|
+
answer: string | null;
|
|
47
|
+
cancelled: boolean;
|
|
48
|
+
wasCustom?: boolean;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
|
|
@@ -83,6 +92,9 @@ function createEditorTheme(theme: Theme): EditorTheme {
|
|
|
83
92
|
|
|
84
93
|
type AskMode = "select" | "freeform";
|
|
85
94
|
|
|
95
|
+
const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
96
|
+
const ASK_OVERLAY_WIDTH = "92%";
|
|
97
|
+
|
|
86
98
|
class MultiSelectList implements Component {
|
|
87
99
|
private options: QuestionOption[];
|
|
88
100
|
private allowFreeform: boolean;
|
|
@@ -252,6 +264,135 @@ class MultiSelectList implements Component {
|
|
|
252
264
|
}
|
|
253
265
|
}
|
|
254
266
|
|
|
267
|
+
class WrappedSingleSelectList implements Component {
|
|
268
|
+
private options: QuestionOption[];
|
|
269
|
+
private allowFreeform: boolean;
|
|
270
|
+
private theme: Theme;
|
|
271
|
+
private selectedIndex = 0;
|
|
272
|
+
private maxVisibleRows = 12;
|
|
273
|
+
private cachedWidth?: number;
|
|
274
|
+
private cachedLines?: string[];
|
|
275
|
+
|
|
276
|
+
public onCancel?: () => void;
|
|
277
|
+
public onSubmit?: (result: string) => void;
|
|
278
|
+
public onEnterFreeform?: () => void;
|
|
279
|
+
|
|
280
|
+
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
|
|
281
|
+
this.options = options;
|
|
282
|
+
this.allowFreeform = allowFreeform;
|
|
283
|
+
this.theme = theme;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
setMaxVisibleRows(rows: number): void {
|
|
287
|
+
const next = Math.max(1, Math.floor(rows));
|
|
288
|
+
if (next !== this.maxVisibleRows) {
|
|
289
|
+
this.maxVisibleRows = next;
|
|
290
|
+
this.invalidate();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
invalidate(): void {
|
|
295
|
+
this.cachedWidth = undefined;
|
|
296
|
+
this.cachedLines = undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private getItemCount(): number {
|
|
300
|
+
return this.options.length + (this.allowFreeform ? 1 : 0);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private isFreeformRow(index: number): boolean {
|
|
304
|
+
return this.allowFreeform && index === this.options.length;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
handleInput(data: string): void {
|
|
308
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
309
|
+
this.onCancel?.();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const count = this.getItemCount();
|
|
314
|
+
if (count === 0) {
|
|
315
|
+
this.onCancel?.();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.shift("tab"))) {
|
|
320
|
+
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
321
|
+
this.invalidate();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
|
|
326
|
+
this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
|
|
327
|
+
this.invalidate();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const numMatch = data.match(/^[1-9]$/);
|
|
332
|
+
if (numMatch) {
|
|
333
|
+
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
334
|
+
if (idx >= 0 && idx < count) {
|
|
335
|
+
this.selectedIndex = idx;
|
|
336
|
+
this.invalidate();
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (matchesKey(data, Key.enter)) {
|
|
342
|
+
if (this.isFreeformRow(this.selectedIndex)) {
|
|
343
|
+
this.onEnterFreeform?.();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const result = this.options[this.selectedIndex]?.title;
|
|
348
|
+
if (result) this.onSubmit?.(result);
|
|
349
|
+
else this.onCancel?.();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
render(width: number): string[] {
|
|
354
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
355
|
+
return this.cachedLines;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const count = this.getItemCount();
|
|
359
|
+
if (count === 0) {
|
|
360
|
+
this.cachedLines = [this.theme.fg("warning", "No options")];
|
|
361
|
+
this.cachedWidth = width;
|
|
362
|
+
return this.cachedLines;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const lines = renderSingleSelectRows({
|
|
366
|
+
options: this.options,
|
|
367
|
+
selectedIndex: this.selectedIndex,
|
|
368
|
+
width,
|
|
369
|
+
allowFreeform: this.allowFreeform,
|
|
370
|
+
maxRows: this.maxVisibleRows,
|
|
371
|
+
}).map((line) => {
|
|
372
|
+
const trimmed = line.trim();
|
|
373
|
+
let styled = line;
|
|
374
|
+
|
|
375
|
+
if (trimmed.startsWith("(")) {
|
|
376
|
+
styled = this.theme.fg("dim", line);
|
|
377
|
+
} else if (line.startsWith(" ")) {
|
|
378
|
+
styled = this.theme.fg("muted", line);
|
|
379
|
+
} else if (line.startsWith("→")) {
|
|
380
|
+
styled = this.theme.fg("accent", this.theme.bold(line));
|
|
381
|
+
} else if (trimmed.startsWith("Type something.")) {
|
|
382
|
+
styled = this.theme.fg("text", line);
|
|
383
|
+
} else {
|
|
384
|
+
styled = this.theme.fg("text", line);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return truncateToWidth(styled, width, "");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
this.cachedWidth = width;
|
|
391
|
+
this.cachedLines = lines;
|
|
392
|
+
return lines;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
255
396
|
/**
|
|
256
397
|
* Interactive ask UI. Uses a root Container for layout and swaps the center
|
|
257
398
|
* component between SelectList/MultiSelectList and an Editor (freeform mode).
|
|
@@ -276,7 +417,7 @@ class AskComponent extends Container {
|
|
|
276
417
|
private helpText: Text;
|
|
277
418
|
|
|
278
419
|
// Mode components
|
|
279
|
-
private
|
|
420
|
+
private singleSelectList?: WrappedSingleSelectList;
|
|
280
421
|
private multiSelectList?: MultiSelectList;
|
|
281
422
|
private editor?: Editor;
|
|
282
423
|
|
|
@@ -354,11 +495,32 @@ class AskComponent extends Container {
|
|
|
354
495
|
}
|
|
355
496
|
|
|
356
497
|
override render(width: number): string[] {
|
|
498
|
+
if (this.mode === "select" && !this.allowMultiple) {
|
|
499
|
+
const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
|
|
500
|
+
const staticLines = this.countStaticLines(width);
|
|
501
|
+
const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
|
|
502
|
+
this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
|
|
503
|
+
}
|
|
504
|
+
|
|
357
505
|
// Defensive: ensure no line exceeds width, otherwise pi-tui will hard-crash.
|
|
358
506
|
const lines = super.render(width);
|
|
359
507
|
return lines.map((l) => truncateToWidth(l, width, ""));
|
|
360
508
|
}
|
|
361
509
|
|
|
510
|
+
private countWrappedLines(text: string, width: number): number {
|
|
511
|
+
return Math.max(1, wrapTextWithAnsi(text, Math.max(10, width - 2)).length);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private countStaticLines(width: number): number {
|
|
515
|
+
const titleLines = 1;
|
|
516
|
+
const questionLines = this.countWrappedLines(this.question, width);
|
|
517
|
+
const contextLines = this.context ? 1 + this.countWrappedLines(this.context, width) : 0;
|
|
518
|
+
const helpLines = 1;
|
|
519
|
+
const borderLines = 2;
|
|
520
|
+
const spacerLines = this.context ? 6 : 5;
|
|
521
|
+
return borderLines + spacerLines + titleLines + questionLines + contextLines + helpLines;
|
|
522
|
+
}
|
|
523
|
+
|
|
362
524
|
private updateStaticText(): void {
|
|
363
525
|
const theme = this.theme;
|
|
364
526
|
this.titleText.setText(theme.fg("accent", theme.bold("Question")));
|
|
@@ -387,43 +549,16 @@ class AskComponent extends Container {
|
|
|
387
549
|
}
|
|
388
550
|
}
|
|
389
551
|
|
|
390
|
-
private
|
|
391
|
-
|
|
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
|
-
}
|
|
552
|
+
private ensureSingleSelectList(): WrappedSingleSelectList {
|
|
553
|
+
if (this.singleSelectList) return this.singleSelectList;
|
|
404
554
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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);
|
|
555
|
+
const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme);
|
|
556
|
+
list.onSubmit = (result) => this.onDone(result);
|
|
557
|
+
list.onCancel = () => this.onDone(null);
|
|
558
|
+
list.onEnterFreeform = () => this.showFreeformMode();
|
|
424
559
|
|
|
425
|
-
this.
|
|
426
|
-
return
|
|
560
|
+
this.singleSelectList = list;
|
|
561
|
+
return list;
|
|
427
562
|
}
|
|
428
563
|
|
|
429
564
|
private ensureMultiSelectList(): MultiSelectList {
|
|
@@ -534,6 +669,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
534
669
|
label: "Ask User",
|
|
535
670
|
description:
|
|
536
671
|
"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.",
|
|
672
|
+
promptSnippet:
|
|
673
|
+
"Ask the user a question with optional multiple-choice answers to gather information interactively",
|
|
674
|
+
promptGuidelines: [
|
|
675
|
+
"Before calling ask_user, gather context with tools (read/exa/ref) and pass a short summary via the context field.",
|
|
676
|
+
"Use ask_user when the user's intent is ambiguous, when a decision requires explicit user input, or when multiple valid options exist.",
|
|
677
|
+
],
|
|
537
678
|
parameters: Type.Object({
|
|
538
679
|
question: Type.String({ description: "The question to ask the user" }),
|
|
539
680
|
context: Type.Optional(
|
|
@@ -561,11 +702,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
561
702
|
allowFreeform: Type.Optional(
|
|
562
703
|
Type.Boolean({ description: "Add a freeform text option. Default: true" }),
|
|
563
704
|
),
|
|
705
|
+
timeout: Type.Optional(
|
|
706
|
+
Type.Number({ description: "Auto-dismiss after N milliseconds (applies to fallback input mode when no options are provided)" }),
|
|
707
|
+
),
|
|
564
708
|
}),
|
|
565
709
|
|
|
566
710
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
567
711
|
if (signal?.aborted) {
|
|
568
|
-
return {
|
|
712
|
+
return {
|
|
713
|
+
content: [{ type: "text", text: "Cancelled" }],
|
|
714
|
+
details: { question: params.question, options: [], answer: null, cancelled: true } as AskToolDetails,
|
|
715
|
+
};
|
|
569
716
|
}
|
|
570
717
|
|
|
571
718
|
const {
|
|
@@ -574,6 +721,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
574
721
|
options: rawOptions = [],
|
|
575
722
|
allowMultiple = false,
|
|
576
723
|
allowFreeform = true,
|
|
724
|
+
timeout,
|
|
577
725
|
} = params as AskParams;
|
|
578
726
|
const options = normalizeOptions(rawOptions);
|
|
579
727
|
const normalizedContext = context?.trim() || undefined;
|
|
@@ -590,36 +738,53 @@ export default function (pi: ExtensionAPI) {
|
|
|
590
738
|
},
|
|
591
739
|
],
|
|
592
740
|
isError: true,
|
|
593
|
-
details: { question, context: normalizedContext, options },
|
|
741
|
+
details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
|
|
594
742
|
};
|
|
595
743
|
}
|
|
596
744
|
|
|
597
745
|
// If no options provided, fall back to freeform input prompt.
|
|
598
746
|
if (options.length === 0) {
|
|
599
747
|
const prompt = normalizedContext ? `${question}\n\nContext:\n${normalizedContext}` : question;
|
|
600
|
-
const answer = await ctx.ui.input(prompt, "Type your answer...");
|
|
748
|
+
const answer = await ctx.ui.input(prompt, "Type your answer...", timeout ? { timeout } : undefined);
|
|
601
749
|
|
|
602
750
|
if (!answer) {
|
|
603
|
-
return {
|
|
751
|
+
return {
|
|
752
|
+
content: [{ type: "text", text: "User cancelled the question" }],
|
|
753
|
+
details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
|
|
754
|
+
};
|
|
604
755
|
}
|
|
605
756
|
|
|
606
|
-
return {
|
|
757
|
+
return {
|
|
758
|
+
content: [{ type: "text", text: `User answered: ${answer}` }],
|
|
759
|
+
details: { question, context: normalizedContext, options, answer, cancelled: false, wasCustom: true } as AskToolDetails,
|
|
760
|
+
};
|
|
607
761
|
}
|
|
608
762
|
|
|
609
763
|
let result: string | null;
|
|
610
764
|
try {
|
|
611
|
-
result = await ctx.ui.custom<string | null>(
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
765
|
+
result = await ctx.ui.custom<string | null>(
|
|
766
|
+
(tui, theme, _kb, done) => {
|
|
767
|
+
return new AskComponent(
|
|
768
|
+
question,
|
|
769
|
+
normalizedContext,
|
|
770
|
+
options,
|
|
771
|
+
allowMultiple,
|
|
772
|
+
allowFreeform,
|
|
773
|
+
tui,
|
|
774
|
+
theme,
|
|
775
|
+
done,
|
|
776
|
+
);
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
overlay: true,
|
|
780
|
+
overlayOptions: {
|
|
781
|
+
anchor: "center",
|
|
782
|
+
width: ASK_OVERLAY_WIDTH,
|
|
783
|
+
maxHeight: "85%",
|
|
784
|
+
margin: 1,
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
);
|
|
623
788
|
} catch (error) {
|
|
624
789
|
const message =
|
|
625
790
|
error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
|
|
@@ -631,10 +796,56 @@ export default function (pi: ExtensionAPI) {
|
|
|
631
796
|
}
|
|
632
797
|
|
|
633
798
|
if (result === null) {
|
|
634
|
-
return {
|
|
799
|
+
return {
|
|
800
|
+
content: [{ type: "text", text: "User cancelled the question" }],
|
|
801
|
+
details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
|
|
802
|
+
};
|
|
635
803
|
}
|
|
636
804
|
|
|
637
|
-
return {
|
|
805
|
+
return {
|
|
806
|
+
content: [{ type: "text", text: `User answered: ${result}` }],
|
|
807
|
+
details: { question, context: normalizedContext, options, answer: result, cancelled: false } as AskToolDetails,
|
|
808
|
+
};
|
|
809
|
+
},
|
|
810
|
+
|
|
811
|
+
renderCall(args, theme) {
|
|
812
|
+
const question = (args.question as string) || "";
|
|
813
|
+
const rawOptions = Array.isArray(args.options) ? args.options : [];
|
|
814
|
+
let text = theme.fg("toolTitle", theme.bold("ask_user "));
|
|
815
|
+
text += theme.fg("muted", question);
|
|
816
|
+
if (rawOptions.length > 0) {
|
|
817
|
+
const labels = rawOptions.map((o: unknown) =>
|
|
818
|
+
typeof o === "string" ? o : (o as QuestionOption)?.title ?? "",
|
|
819
|
+
);
|
|
820
|
+
text += "\n" + theme.fg("dim", ` ${rawOptions.length} option(s): ${labels.join(", ")}`);
|
|
821
|
+
}
|
|
822
|
+
if (args.allowMultiple) {
|
|
823
|
+
text += theme.fg("dim", " [multi-select]");
|
|
824
|
+
}
|
|
825
|
+
return new Text(text, 0, 0);
|
|
826
|
+
},
|
|
827
|
+
|
|
828
|
+
renderResult(result, _options, theme) {
|
|
829
|
+
const details = result.details as (AskToolDetails & { error?: string }) | undefined;
|
|
830
|
+
|
|
831
|
+
// Error state
|
|
832
|
+
if (details?.error) {
|
|
833
|
+
return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Cancelled / no details
|
|
837
|
+
if (!details || details.cancelled) {
|
|
838
|
+
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Success
|
|
842
|
+
const answer = details.answer ?? "";
|
|
843
|
+
let text = theme.fg("success", "✓ ");
|
|
844
|
+
if (details.wasCustom) {
|
|
845
|
+
text += theme.fg("muted", "(wrote) ");
|
|
846
|
+
}
|
|
847
|
+
text += theme.fg("accent", answer);
|
|
848
|
+
return new Text(text, 0, 0);
|
|
638
849
|
},
|
|
639
850
|
});
|
|
640
851
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ask-user",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Interactive ask_user tool for pi-coding-agent with multi-select and freeform input UI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"index.ts",
|
|
38
|
+
"single-select-layout.ts",
|
|
38
39
|
"skills",
|
|
39
40
|
"README.md",
|
|
40
41
|
"LICENSE"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
export interface QuestionOption {
|
|
2
|
+
title: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface RenderSingleSelectRowsParams {
|
|
7
|
+
options: QuestionOption[];
|
|
8
|
+
selectedIndex: number;
|
|
9
|
+
width: number;
|
|
10
|
+
allowFreeform: boolean;
|
|
11
|
+
maxRows?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function wrapText(text: string, width: number): string[] {
|
|
15
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
16
|
+
if (!normalized) return [""];
|
|
17
|
+
if (width <= 1) return normalized.split("");
|
|
18
|
+
|
|
19
|
+
const words = normalized.split(" ");
|
|
20
|
+
const lines: string[] = [];
|
|
21
|
+
let current = "";
|
|
22
|
+
|
|
23
|
+
for (const word of words) {
|
|
24
|
+
if (!current) {
|
|
25
|
+
if (word.length <= width) {
|
|
26
|
+
current = word;
|
|
27
|
+
} else {
|
|
28
|
+
for (let i = 0; i < word.length; i += width) {
|
|
29
|
+
lines.push(word.slice(i, i + width));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const candidate = `${current} ${word}`;
|
|
36
|
+
if (candidate.length <= width) {
|
|
37
|
+
current = candidate;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
lines.push(current);
|
|
42
|
+
if (word.length <= width) {
|
|
43
|
+
current = word;
|
|
44
|
+
} else {
|
|
45
|
+
for (let i = 0; i < word.length; i += width) {
|
|
46
|
+
const chunk = word.slice(i, i + width);
|
|
47
|
+
if (chunk.length === width || i + width < word.length) lines.push(chunk);
|
|
48
|
+
else current = chunk;
|
|
49
|
+
}
|
|
50
|
+
if (!current || current.length === width) current = "";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (current) lines.push(current);
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function padLine(prefix: string, content: string): string {
|
|
59
|
+
return `${prefix}${content}`.trimEnd();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ItemBlock {
|
|
63
|
+
itemIndex: number;
|
|
64
|
+
lines: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildItemBlocks(
|
|
68
|
+
options: QuestionOption[],
|
|
69
|
+
width: number,
|
|
70
|
+
allowFreeform: boolean,
|
|
71
|
+
selectedIndex: number,
|
|
72
|
+
): ItemBlock[] {
|
|
73
|
+
const normalizedWidth = Math.max(12, width);
|
|
74
|
+
const freeformLabel = "Type something. — Enter a custom response";
|
|
75
|
+
const allItems = options.map((option) => ({ type: "option" as const, option }));
|
|
76
|
+
if (allowFreeform) {
|
|
77
|
+
allItems.push({ type: "freeform" as const, option: { title: freeformLabel } });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return allItems.map((item, itemIndex) => {
|
|
81
|
+
const pointer = itemIndex === selectedIndex ? "→" : " ";
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
|
|
84
|
+
if (item.type === "freeform") {
|
|
85
|
+
const prefix = `${pointer} `;
|
|
86
|
+
const wrapped = wrapText(item.option.title, Math.max(8, normalizedWidth - prefix.length));
|
|
87
|
+
wrapped.forEach((line, lineIndex) => {
|
|
88
|
+
lines.push(padLine(lineIndex === 0 ? prefix : " ".repeat(prefix.length), line));
|
|
89
|
+
});
|
|
90
|
+
return { itemIndex, lines };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const numberPrefix = `${pointer} ${itemIndex + 1}. `;
|
|
94
|
+
const continuationPrefix = " ".repeat(numberPrefix.length);
|
|
95
|
+
const titleLines = wrapText(item.option.title, Math.max(8, normalizedWidth - numberPrefix.length));
|
|
96
|
+
titleLines.forEach((line, lineIndex) => {
|
|
97
|
+
lines.push(padLine(lineIndex === 0 ? numberPrefix : continuationPrefix, line));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (item.option.description) {
|
|
101
|
+
const descriptionPrefix = " ";
|
|
102
|
+
const descriptionLines = wrapText(
|
|
103
|
+
item.option.description,
|
|
104
|
+
Math.max(8, normalizedWidth - descriptionPrefix.length),
|
|
105
|
+
);
|
|
106
|
+
descriptionLines.forEach((line) => {
|
|
107
|
+
lines.push(padLine(descriptionPrefix, line));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { itemIndex, lines };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function flatten(blocks: ItemBlock[]): string[] {
|
|
116
|
+
return blocks.flatMap((block) => block.lines);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function renderSingleSelectRows({
|
|
120
|
+
options,
|
|
121
|
+
selectedIndex,
|
|
122
|
+
width,
|
|
123
|
+
allowFreeform,
|
|
124
|
+
maxRows,
|
|
125
|
+
}: RenderSingleSelectRowsParams): string[] {
|
|
126
|
+
const itemCount = options.length + (allowFreeform ? 1 : 0);
|
|
127
|
+
const blocks = buildItemBlocks(options, width, allowFreeform, selectedIndex);
|
|
128
|
+
const allRows = flatten(blocks);
|
|
129
|
+
|
|
130
|
+
if (!Number.isFinite(maxRows) || !maxRows || maxRows <= 0 || allRows.length <= maxRows) {
|
|
131
|
+
return allRows;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const safeMaxRows = Math.max(1, Math.floor(maxRows));
|
|
135
|
+
const selectedBlock = blocks[selectedIndex] ?? blocks[0];
|
|
136
|
+
if (!selectedBlock) return [];
|
|
137
|
+
|
|
138
|
+
const indicator = ` (${selectedIndex + 1}/${itemCount})`;
|
|
139
|
+
const availableRows = safeMaxRows > 1 ? safeMaxRows - 1 : 1;
|
|
140
|
+
|
|
141
|
+
if (selectedBlock.lines.length >= availableRows) {
|
|
142
|
+
const visible = selectedBlock.lines.slice(0, availableRows);
|
|
143
|
+
if (safeMaxRows > 1) visible.push(indicator);
|
|
144
|
+
return visible.slice(0, safeMaxRows);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let start = selectedIndex;
|
|
148
|
+
let end = selectedIndex + 1;
|
|
149
|
+
let usedRows = selectedBlock.lines.length;
|
|
150
|
+
|
|
151
|
+
while (true) {
|
|
152
|
+
const nextCanFit = end < blocks.length && usedRows + blocks[end]!.lines.length <= availableRows;
|
|
153
|
+
if (nextCanFit) {
|
|
154
|
+
usedRows += blocks[end]!.lines.length;
|
|
155
|
+
end += 1;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const prevCanFit = start > 0 && usedRows + blocks[start - 1]!.lines.length <= availableRows;
|
|
160
|
+
if (prevCanFit) {
|
|
161
|
+
start -= 1;
|
|
162
|
+
usedRows += blocks[start]!.lines.length;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const visible = flatten(blocks.slice(start, end));
|
|
170
|
+
visible.push(indicator);
|
|
171
|
+
return visible.slice(0, safeMaxRows);
|
|
172
|
+
}
|