pi-ask-user 0.3.0 → 0.4.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 +3 -3
- package/index.ts +279 -80
- package/package.json +2 -1
- package/single-select-layout.ts +172 -0
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ High-quality video: [ask-user-demo.mp4](./media/ask-user-demo.mp4)
|
|
|
17
17
|
- Overlay mode — dialog floats over conversation, preserving context
|
|
18
18
|
- Custom TUI rendering for tool calls and results
|
|
19
19
|
- System prompt integration via `promptSnippet` and `promptGuidelines`
|
|
20
|
-
- Optional timeout for auto-dismiss
|
|
20
|
+
- Optional timeout for auto-dismiss in both overlay and fallback input modes
|
|
21
21
|
- Structured `details` on all results for session state reconstruction
|
|
22
22
|
- Graceful fallback when interactive UI is unavailable
|
|
23
23
|
- Bundled `ask-user` skill for mandatory decision-gating in high-stakes or ambiguous tasks
|
|
@@ -60,7 +60,7 @@ The registered tool name is:
|
|
|
60
60
|
| `options` | `(string \| {title, description?})[]?` | `[]` | Multiple-choice options |
|
|
61
61
|
| `allowMultiple` | `boolean?` | `false` | Enable multi-select mode |
|
|
62
62
|
| `allowFreeform` | `boolean?` | `true` | Add a "Type something" freeform option |
|
|
63
|
-
| `timeout` | `number?` | — | Auto-dismiss after N ms
|
|
63
|
+
| `timeout` | `number?` | — | Auto-dismiss after N ms and return `null` if the prompt times out |
|
|
64
64
|
|
|
65
65
|
## Example usage shape
|
|
66
66
|
|
|
@@ -99,7 +99,7 @@ interface AskToolDetails {
|
|
|
99
99
|
- Added `promptSnippet` and `promptGuidelines` for better LLM tool selection in the system prompt
|
|
100
100
|
- Added `renderCall` and `renderResult` for custom TUI rendering (compact tool call display, ✓/Cancelled result indicators)
|
|
101
101
|
- Added overlay mode — dialog now floats over the conversation instead of clearing the screen
|
|
102
|
-
- Added `timeout` parameter for auto-dismiss
|
|
102
|
+
- Added `timeout` parameter for auto-dismiss and cancellation support
|
|
103
103
|
- Added structured `details` (`AskToolDetails`) to all result paths for session state reconstruction and branching support
|
|
104
104
|
|
|
105
105
|
### 0.2.1
|
package/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { DynamicBorder, getMarkdownTheme, rawKeyHint } from "@mariozechner/pi-coding-agent";
|
|
10
10
|
import { Type } from "@sinclair/typebox";
|
|
11
11
|
import {
|
|
12
12
|
Container,
|
|
@@ -14,15 +14,16 @@ import {
|
|
|
14
14
|
Editor,
|
|
15
15
|
type EditorTheme,
|
|
16
16
|
Key,
|
|
17
|
+
Markdown,
|
|
18
|
+
type MarkdownTheme,
|
|
17
19
|
matchesKey,
|
|
18
|
-
SelectList,
|
|
19
|
-
type SelectItem,
|
|
20
20
|
Spacer,
|
|
21
21
|
Text,
|
|
22
22
|
type TUI,
|
|
23
23
|
truncateToWidth,
|
|
24
24
|
wrapTextWithAnsi,
|
|
25
25
|
} from "@mariozechner/pi-tui";
|
|
26
|
+
import { renderSingleSelectRows } from "./single-select-layout";
|
|
26
27
|
|
|
27
28
|
interface QuestionOption {
|
|
28
29
|
title: string;
|
|
@@ -72,8 +73,6 @@ function formatOptionsForMessage(options: QuestionOption[]): string {
|
|
|
72
73
|
.join("\n");
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
const FREEFORM_VALUE = "__freeform__";
|
|
76
|
-
|
|
77
76
|
function createSelectListTheme(theme: Theme) {
|
|
78
77
|
return {
|
|
79
78
|
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
@@ -93,6 +92,10 @@ function createEditorTheme(theme: Theme): EditorTheme {
|
|
|
93
92
|
|
|
94
93
|
type AskMode = "select" | "freeform";
|
|
95
94
|
|
|
95
|
+
const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
96
|
+
const ASK_OVERLAY_WIDTH = "92%";
|
|
97
|
+
const ASK_OVERLAY_MIN_WIDTH = 40;
|
|
98
|
+
|
|
96
99
|
class MultiSelectList implements Component {
|
|
97
100
|
private options: QuestionOption[];
|
|
98
101
|
private allowFreeform: boolean;
|
|
@@ -262,6 +265,135 @@ class MultiSelectList implements Component {
|
|
|
262
265
|
}
|
|
263
266
|
}
|
|
264
267
|
|
|
268
|
+
class WrappedSingleSelectList implements Component {
|
|
269
|
+
private options: QuestionOption[];
|
|
270
|
+
private allowFreeform: boolean;
|
|
271
|
+
private theme: Theme;
|
|
272
|
+
private selectedIndex = 0;
|
|
273
|
+
private maxVisibleRows = 12;
|
|
274
|
+
private cachedWidth?: number;
|
|
275
|
+
private cachedLines?: string[];
|
|
276
|
+
|
|
277
|
+
public onCancel?: () => void;
|
|
278
|
+
public onSubmit?: (result: string) => void;
|
|
279
|
+
public onEnterFreeform?: () => void;
|
|
280
|
+
|
|
281
|
+
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
|
|
282
|
+
this.options = options;
|
|
283
|
+
this.allowFreeform = allowFreeform;
|
|
284
|
+
this.theme = theme;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
setMaxVisibleRows(rows: number): void {
|
|
288
|
+
const next = Math.max(1, Math.floor(rows));
|
|
289
|
+
if (next !== this.maxVisibleRows) {
|
|
290
|
+
this.maxVisibleRows = next;
|
|
291
|
+
this.invalidate();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
invalidate(): void {
|
|
296
|
+
this.cachedWidth = undefined;
|
|
297
|
+
this.cachedLines = undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private getItemCount(): number {
|
|
301
|
+
return this.options.length + (this.allowFreeform ? 1 : 0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private isFreeformRow(index: number): boolean {
|
|
305
|
+
return this.allowFreeform && index === this.options.length;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
handleInput(data: string): void {
|
|
309
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
310
|
+
this.onCancel?.();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const count = this.getItemCount();
|
|
315
|
+
if (count === 0) {
|
|
316
|
+
this.onCancel?.();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.shift("tab"))) {
|
|
321
|
+
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
322
|
+
this.invalidate();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
|
|
327
|
+
this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
|
|
328
|
+
this.invalidate();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const numMatch = data.match(/^[1-9]$/);
|
|
333
|
+
if (numMatch) {
|
|
334
|
+
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
335
|
+
if (idx >= 0 && idx < count) {
|
|
336
|
+
this.selectedIndex = idx;
|
|
337
|
+
this.invalidate();
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (matchesKey(data, Key.enter)) {
|
|
343
|
+
if (this.isFreeformRow(this.selectedIndex)) {
|
|
344
|
+
this.onEnterFreeform?.();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const result = this.options[this.selectedIndex]?.title;
|
|
349
|
+
if (result) this.onSubmit?.(result);
|
|
350
|
+
else this.onCancel?.();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
render(width: number): string[] {
|
|
355
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
356
|
+
return this.cachedLines;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const count = this.getItemCount();
|
|
360
|
+
if (count === 0) {
|
|
361
|
+
this.cachedLines = [this.theme.fg("warning", "No options")];
|
|
362
|
+
this.cachedWidth = width;
|
|
363
|
+
return this.cachedLines;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const lines = renderSingleSelectRows({
|
|
367
|
+
options: this.options,
|
|
368
|
+
selectedIndex: this.selectedIndex,
|
|
369
|
+
width,
|
|
370
|
+
allowFreeform: this.allowFreeform,
|
|
371
|
+
maxRows: this.maxVisibleRows,
|
|
372
|
+
}).map((line) => {
|
|
373
|
+
const trimmed = line.trim();
|
|
374
|
+
let styled = line;
|
|
375
|
+
|
|
376
|
+
if (trimmed.startsWith("(")) {
|
|
377
|
+
styled = this.theme.fg("dim", line);
|
|
378
|
+
} else if (line.startsWith(" ")) {
|
|
379
|
+
styled = this.theme.fg("muted", line);
|
|
380
|
+
} else if (line.startsWith("→")) {
|
|
381
|
+
styled = this.theme.fg("accent", this.theme.bold(line));
|
|
382
|
+
} else if (trimmed.startsWith("Type something.")) {
|
|
383
|
+
styled = this.theme.fg("text", line);
|
|
384
|
+
} else {
|
|
385
|
+
styled = this.theme.fg("text", line);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return truncateToWidth(styled, width, "");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
this.cachedWidth = width;
|
|
392
|
+
this.cachedLines = lines;
|
|
393
|
+
return lines;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
265
397
|
/**
|
|
266
398
|
* Interactive ask UI. Uses a root Container for layout and swaps the center
|
|
267
399
|
* component between SelectList/MultiSelectList and an Editor (freeform mode).
|
|
@@ -281,16 +413,16 @@ class AskComponent extends Container {
|
|
|
281
413
|
// Static layout components
|
|
282
414
|
private titleText: Text;
|
|
283
415
|
private questionText: Text;
|
|
284
|
-
private
|
|
416
|
+
private contextComponent?: Component;
|
|
285
417
|
private modeContainer: Container;
|
|
286
418
|
private helpText: Text;
|
|
287
419
|
|
|
288
420
|
// Mode components
|
|
289
|
-
private
|
|
421
|
+
private singleSelectList?: WrappedSingleSelectList;
|
|
290
422
|
private multiSelectList?: MultiSelectList;
|
|
291
423
|
private editor?: Editor;
|
|
292
424
|
|
|
293
|
-
//
|
|
425
|
+
// Focusable - propagate to Editor for IME cursor positioning
|
|
294
426
|
private _focused = false;
|
|
295
427
|
get focused(): boolean {
|
|
296
428
|
return this._focused;
|
|
@@ -298,8 +430,7 @@ class AskComponent extends Container {
|
|
|
298
430
|
set focused(value: boolean) {
|
|
299
431
|
this._focused = value;
|
|
300
432
|
if (this.editor && this.mode === "freeform") {
|
|
301
|
-
|
|
302
|
-
anyEditor.focused = value;
|
|
433
|
+
(this.editor as any).focused = value;
|
|
303
434
|
}
|
|
304
435
|
}
|
|
305
436
|
|
|
@@ -337,8 +468,16 @@ class AskComponent extends Container {
|
|
|
337
468
|
|
|
338
469
|
if (this.context) {
|
|
339
470
|
this.addChild(new Spacer(1));
|
|
340
|
-
|
|
341
|
-
|
|
471
|
+
let mdTheme: MarkdownTheme | undefined;
|
|
472
|
+
try {
|
|
473
|
+
mdTheme = getMarkdownTheme();
|
|
474
|
+
} catch {}
|
|
475
|
+
if (mdTheme) {
|
|
476
|
+
this.contextComponent = new Markdown("", 1, 0, mdTheme);
|
|
477
|
+
} else {
|
|
478
|
+
this.contextComponent = new Text("", 1, 0);
|
|
479
|
+
}
|
|
480
|
+
this.addChild(this.contextComponent);
|
|
342
481
|
}
|
|
343
482
|
|
|
344
483
|
this.addChild(new Spacer(1));
|
|
@@ -364,76 +503,90 @@ class AskComponent extends Container {
|
|
|
364
503
|
}
|
|
365
504
|
|
|
366
505
|
override render(width: number): string[] {
|
|
506
|
+
if (this.mode === "select" && !this.allowMultiple) {
|
|
507
|
+
const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
|
|
508
|
+
const staticLines = this.countStaticLines(width);
|
|
509
|
+
const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
|
|
510
|
+
this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
|
|
511
|
+
}
|
|
512
|
+
|
|
367
513
|
// Defensive: ensure no line exceeds width, otherwise pi-tui will hard-crash.
|
|
368
514
|
const lines = super.render(width);
|
|
369
515
|
return lines.map((l) => truncateToWidth(l, width, ""));
|
|
370
516
|
}
|
|
371
517
|
|
|
518
|
+
private countWrappedLines(text: string, width: number): number {
|
|
519
|
+
return Math.max(1, wrapTextWithAnsi(text, Math.max(10, width - 2)).length);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private countStaticLines(width: number): number {
|
|
523
|
+
const titleLines = 1;
|
|
524
|
+
const questionLines = this.countWrappedLines(this.question, width);
|
|
525
|
+
const contextLines = this.context ? 1 + this.countWrappedLines(this.context, width) : 0;
|
|
526
|
+
const helpLines = 1;
|
|
527
|
+
const borderLines = 2;
|
|
528
|
+
const spacerLines = this.context ? 6 : 5;
|
|
529
|
+
return borderLines + spacerLines + titleLines + questionLines + contextLines + helpLines;
|
|
530
|
+
}
|
|
531
|
+
|
|
372
532
|
private updateStaticText(): void {
|
|
373
533
|
const theme = this.theme;
|
|
374
534
|
this.titleText.setText(theme.fg("accent", theme.bold("Question")));
|
|
375
535
|
this.questionText.setText(theme.fg("text", theme.bold(this.question)));
|
|
376
|
-
if (this.
|
|
377
|
-
this.
|
|
536
|
+
if (this.contextComponent && this.context) {
|
|
537
|
+
if (this.contextComponent instanceof Markdown) {
|
|
538
|
+
(this.contextComponent as Markdown).setText(
|
|
539
|
+
`**Context:**\n${this.context}`,
|
|
540
|
+
);
|
|
541
|
+
} else {
|
|
542
|
+
(this.contextComponent as Text).setText(
|
|
543
|
+
`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
378
546
|
}
|
|
379
547
|
}
|
|
380
548
|
|
|
381
549
|
private updateHelpText(): void {
|
|
382
550
|
const theme = this.theme;
|
|
383
551
|
if (this.mode === "freeform") {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
),
|
|
389
|
-
);
|
|
552
|
+
const hints = [
|
|
553
|
+
rawKeyHint("enter", "submit"),
|
|
554
|
+
rawKeyHint("shift+enter", "newline"),
|
|
555
|
+
rawKeyHint("esc", "back"),
|
|
556
|
+
rawKeyHint("ctrl+c", "cancel"),
|
|
557
|
+
].join(" • ");
|
|
558
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
390
559
|
return;
|
|
391
560
|
}
|
|
392
561
|
|
|
393
562
|
if (this.allowMultiple) {
|
|
394
|
-
|
|
563
|
+
const hints = [
|
|
564
|
+
rawKeyHint("↑↓", "navigate"),
|
|
565
|
+
rawKeyHint("space", "toggle"),
|
|
566
|
+
rawKeyHint("enter", "submit"),
|
|
567
|
+
rawKeyHint("esc", "cancel"),
|
|
568
|
+
].join(" • ");
|
|
569
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
395
570
|
} else {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
value: String(idx),
|
|
403
|
-
label: o.title,
|
|
404
|
-
description: o.description,
|
|
405
|
-
}));
|
|
406
|
-
|
|
407
|
-
if (this.allowFreeform) {
|
|
408
|
-
items.push({
|
|
409
|
-
value: FREEFORM_VALUE,
|
|
410
|
-
label: "Type something.",
|
|
411
|
-
description: "Enter a custom response",
|
|
412
|
-
});
|
|
571
|
+
const hints = [
|
|
572
|
+
rawKeyHint("↑↓", "navigate"),
|
|
573
|
+
rawKeyHint("enter", "select"),
|
|
574
|
+
rawKeyHint("esc", "cancel"),
|
|
575
|
+
].join(" • ");
|
|
576
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
413
577
|
}
|
|
414
|
-
|
|
415
|
-
return items;
|
|
416
578
|
}
|
|
417
579
|
|
|
418
|
-
private ensureSingleSelectList():
|
|
419
|
-
if (this.
|
|
420
|
-
|
|
421
|
-
const items = this.buildSingleSelectItems();
|
|
422
|
-
const selectList = new SelectList(items, Math.min(items.length, 10), createSelectListTheme(this.theme));
|
|
580
|
+
private ensureSingleSelectList(): WrappedSingleSelectList {
|
|
581
|
+
if (this.singleSelectList) return this.singleSelectList;
|
|
423
582
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
429
|
-
const idx = Number.parseInt(item.value, 10);
|
|
430
|
-
const option = this.options[idx];
|
|
431
|
-
this.onDone(option?.title ?? null);
|
|
432
|
-
};
|
|
433
|
-
selectList.onCancel = () => this.onDone(null);
|
|
583
|
+
const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme);
|
|
584
|
+
list.onSubmit = (result) => this.onDone(result);
|
|
585
|
+
list.onCancel = () => this.onDone(null);
|
|
586
|
+
list.onEnterFreeform = () => this.showFreeformMode();
|
|
434
587
|
|
|
435
|
-
this.
|
|
436
|
-
return
|
|
588
|
+
this.singleSelectList = list;
|
|
589
|
+
return list;
|
|
437
590
|
}
|
|
438
591
|
|
|
439
592
|
private ensureMultiSelectList(): MultiSelectList {
|
|
@@ -450,11 +603,7 @@ class AskComponent extends Container {
|
|
|
450
603
|
|
|
451
604
|
private ensureEditor(): Editor {
|
|
452
605
|
if (this.editor) return this.editor;
|
|
453
|
-
|
|
454
|
-
const editor = new Editor(this.tui, createEditorTheme(this.theme));
|
|
455
|
-
// Default Editor behavior: Enter submits, Shift+Enter inserts newline.
|
|
456
|
-
// Ctrl+Enter is only distinguishable in terminals with Kitty protocol mappings,
|
|
457
|
-
// so we support it as an *additional* submit shortcut in our wrapper.
|
|
606
|
+
const editor = new Editor(createEditorTheme(this.theme));
|
|
458
607
|
editor.disableSubmit = false;
|
|
459
608
|
editor.onSubmit = (text: string) => {
|
|
460
609
|
const trimmed = text.trim();
|
|
@@ -484,8 +633,7 @@ class AskComponent extends Container {
|
|
|
484
633
|
this.modeContainer.clear();
|
|
485
634
|
|
|
486
635
|
const editor = this.ensureEditor();
|
|
487
|
-
|
|
488
|
-
(editor as unknown as { focused?: boolean }).focused = this._focused;
|
|
636
|
+
(editor as any).focused = this._focused;
|
|
489
637
|
|
|
490
638
|
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
|
|
491
639
|
this.modeContainer.addChild(new Spacer(1));
|
|
@@ -496,12 +644,6 @@ class AskComponent extends Container {
|
|
|
496
644
|
this.tui.requestRender();
|
|
497
645
|
}
|
|
498
646
|
|
|
499
|
-
private submitFreeform(): void {
|
|
500
|
-
const editor = this.ensureEditor();
|
|
501
|
-
const text = editor.getText().trim();
|
|
502
|
-
this.onDone(text ? text : null);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
647
|
handleInput(data: string): void {
|
|
506
648
|
if (this.mode === "freeform") {
|
|
507
649
|
if (matchesKey(data, Key.escape)) {
|
|
@@ -514,13 +656,13 @@ class AskComponent extends Container {
|
|
|
514
656
|
return;
|
|
515
657
|
}
|
|
516
658
|
|
|
517
|
-
// Submit on Ctrl+Enter (only works if terminal distinguishes it, e.g. Kitty protocol)
|
|
518
659
|
if (matchesKey(data, Key.ctrl("enter")) || matchesKey(data, "ctrl+enter")) {
|
|
519
|
-
this.
|
|
660
|
+
const editor = this.ensureEditor();
|
|
661
|
+
const text = editor.getText().trim();
|
|
662
|
+
this.onDone(text ? text : null);
|
|
520
663
|
return;
|
|
521
664
|
}
|
|
522
665
|
|
|
523
|
-
// Let Editor handle everything else (Enter submits, Shift+Enter newline)
|
|
524
666
|
this.ensureEditor().handleInput(data);
|
|
525
667
|
this.tui.requestRender();
|
|
526
668
|
return;
|
|
@@ -578,11 +720,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
578
720
|
Type.Boolean({ description: "Add a freeform text option. Default: true" }),
|
|
579
721
|
),
|
|
580
722
|
timeout: Type.Optional(
|
|
581
|
-
Type.Number({ description: "Auto-dismiss after N milliseconds
|
|
723
|
+
Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
|
|
582
724
|
),
|
|
583
725
|
}),
|
|
584
726
|
|
|
585
|
-
async execute(_toolCallId, params, signal,
|
|
727
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
586
728
|
if (signal?.aborted) {
|
|
587
729
|
return {
|
|
588
730
|
content: [{ type: "text", text: "Cancelled" }],
|
|
@@ -629,16 +771,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
629
771
|
};
|
|
630
772
|
}
|
|
631
773
|
|
|
774
|
+
pi.events.emit("ask:answered", { question, context: normalizedContext, answer, wasCustom: true });
|
|
632
775
|
return {
|
|
633
776
|
content: [{ type: "text", text: `User answered: ${answer}` }],
|
|
634
777
|
details: { question, context: normalizedContext, options, answer, cancelled: false, wasCustom: true } as AskToolDetails,
|
|
635
778
|
};
|
|
636
779
|
}
|
|
637
780
|
|
|
781
|
+
onUpdate?.({
|
|
782
|
+
content: [{ type: "text", text: "Waiting for user input..." }],
|
|
783
|
+
details: { question, context: normalizedContext, options, answer: null, cancelled: false },
|
|
784
|
+
});
|
|
785
|
+
|
|
638
786
|
let result: string | null;
|
|
639
787
|
try {
|
|
640
788
|
result = await ctx.ui.custom<string | null>(
|
|
641
789
|
(tui, theme, _kb, done) => {
|
|
790
|
+
// Wire AbortSignal so agent cancellation auto-dismisses the overlay
|
|
791
|
+
if (signal) {
|
|
792
|
+
const onAbort = () => done(null);
|
|
793
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Wire timeout for overlay mode
|
|
797
|
+
if (timeout && timeout > 0) {
|
|
798
|
+
setTimeout(() => done(null), timeout);
|
|
799
|
+
}
|
|
800
|
+
|
|
642
801
|
return new AskComponent(
|
|
643
802
|
question,
|
|
644
803
|
normalizedContext,
|
|
@@ -650,7 +809,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
650
809
|
done,
|
|
651
810
|
);
|
|
652
811
|
},
|
|
653
|
-
{
|
|
812
|
+
{
|
|
813
|
+
overlay: true,
|
|
814
|
+
overlayOptions: {
|
|
815
|
+
anchor: "center",
|
|
816
|
+
width: ASK_OVERLAY_WIDTH,
|
|
817
|
+
minWidth: ASK_OVERLAY_MIN_WIDTH,
|
|
818
|
+
maxHeight: "85%",
|
|
819
|
+
margin: 1,
|
|
820
|
+
},
|
|
821
|
+
},
|
|
654
822
|
);
|
|
655
823
|
} catch (error) {
|
|
656
824
|
const message =
|
|
@@ -663,12 +831,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
663
831
|
}
|
|
664
832
|
|
|
665
833
|
if (result === null) {
|
|
834
|
+
pi.events.emit("ask:cancelled", { question, context: normalizedContext, options });
|
|
666
835
|
return {
|
|
667
836
|
content: [{ type: "text", text: "User cancelled the question" }],
|
|
668
837
|
details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
|
|
669
838
|
};
|
|
670
839
|
}
|
|
671
840
|
|
|
841
|
+
pi.events.emit("ask:answered", { question, context: normalizedContext, answer: result, wasCustom: false });
|
|
672
842
|
return {
|
|
673
843
|
content: [{ type: "text", text: `User answered: ${result}` }],
|
|
674
844
|
details: { question, context: normalizedContext, options, answer: result, cancelled: false } as AskToolDetails,
|
|
@@ -692,26 +862,55 @@ export default function (pi: ExtensionAPI) {
|
|
|
692
862
|
return new Text(text, 0, 0);
|
|
693
863
|
},
|
|
694
864
|
|
|
695
|
-
renderResult(result,
|
|
865
|
+
renderResult(result, options, theme) {
|
|
696
866
|
const details = result.details as (AskToolDetails & { error?: string }) | undefined;
|
|
697
867
|
|
|
698
|
-
// Error state
|
|
699
868
|
if (details?.error) {
|
|
700
869
|
return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
|
|
701
870
|
}
|
|
702
871
|
|
|
703
|
-
|
|
872
|
+
if (options.isPartial) {
|
|
873
|
+
const waitingText = result.content
|
|
874
|
+
?.filter((part: { type?: string; text?: string }) => part?.type === "text")
|
|
875
|
+
.map((part: { text?: string }) => part.text ?? "")
|
|
876
|
+
.join("\n")
|
|
877
|
+
.trim() || "Waiting for user input...";
|
|
878
|
+
return new Text(theme.fg("muted", waitingText), 0, 0);
|
|
879
|
+
}
|
|
880
|
+
|
|
704
881
|
if (!details || details.cancelled) {
|
|
705
882
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
706
883
|
}
|
|
707
884
|
|
|
708
|
-
// Success
|
|
709
885
|
const answer = details.answer ?? "";
|
|
710
886
|
let text = theme.fg("success", "✓ ");
|
|
711
887
|
if (details.wasCustom) {
|
|
712
888
|
text += theme.fg("muted", "(wrote) ");
|
|
713
889
|
}
|
|
714
890
|
text += theme.fg("accent", answer);
|
|
891
|
+
|
|
892
|
+
if (options.expanded) {
|
|
893
|
+
const selectedTitles = new Set(
|
|
894
|
+
answer
|
|
895
|
+
.split(",")
|
|
896
|
+
.map((value) => value.trim())
|
|
897
|
+
.filter(Boolean),
|
|
898
|
+
);
|
|
899
|
+
text += "\n" + theme.fg("dim", `Q: ${details.question}`);
|
|
900
|
+
if (details.context) {
|
|
901
|
+
text += "\n" + theme.fg("dim", details.context);
|
|
902
|
+
}
|
|
903
|
+
if (details.options && details.options.length > 0) {
|
|
904
|
+
text += "\n" + theme.fg("dim", "Options:");
|
|
905
|
+
for (const opt of details.options) {
|
|
906
|
+
const desc = opt.description ? ` — ${opt.description}` : "";
|
|
907
|
+
const isSelected = opt.title === answer || selectedTitles.has(opt.title);
|
|
908
|
+
const marker = isSelected ? theme.fg("success", "●") : theme.fg("dim", "○");
|
|
909
|
+
text += `\n ${marker} ${theme.fg("dim", opt.title)}${theme.fg("dim", desc)}`;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
715
914
|
return new Text(text, 0, 0);
|
|
716
915
|
},
|
|
717
916
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ask-user",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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
|
+
}
|