pi-ask-user 0.4.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 +107 -41
- package/package.json +1 -1
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,6 +14,8 @@ import {
|
|
|
14
14
|
Editor,
|
|
15
15
|
type EditorTheme,
|
|
16
16
|
Key,
|
|
17
|
+
Markdown,
|
|
18
|
+
type MarkdownTheme,
|
|
17
19
|
matchesKey,
|
|
18
20
|
Spacer,
|
|
19
21
|
Text,
|
|
@@ -71,8 +73,6 @@ function formatOptionsForMessage(options: QuestionOption[]): string {
|
|
|
71
73
|
.join("\n");
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
const FREEFORM_VALUE = "__freeform__";
|
|
75
|
-
|
|
76
76
|
function createSelectListTheme(theme: Theme) {
|
|
77
77
|
return {
|
|
78
78
|
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
@@ -94,6 +94,7 @@ type AskMode = "select" | "freeform";
|
|
|
94
94
|
|
|
95
95
|
const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
96
96
|
const ASK_OVERLAY_WIDTH = "92%";
|
|
97
|
+
const ASK_OVERLAY_MIN_WIDTH = 40;
|
|
97
98
|
|
|
98
99
|
class MultiSelectList implements Component {
|
|
99
100
|
private options: QuestionOption[];
|
|
@@ -412,7 +413,7 @@ class AskComponent extends Container {
|
|
|
412
413
|
// Static layout components
|
|
413
414
|
private titleText: Text;
|
|
414
415
|
private questionText: Text;
|
|
415
|
-
private
|
|
416
|
+
private contextComponent?: Component;
|
|
416
417
|
private modeContainer: Container;
|
|
417
418
|
private helpText: Text;
|
|
418
419
|
|
|
@@ -421,7 +422,7 @@ class AskComponent extends Container {
|
|
|
421
422
|
private multiSelectList?: MultiSelectList;
|
|
422
423
|
private editor?: Editor;
|
|
423
424
|
|
|
424
|
-
//
|
|
425
|
+
// Focusable - propagate to Editor for IME cursor positioning
|
|
425
426
|
private _focused = false;
|
|
426
427
|
get focused(): boolean {
|
|
427
428
|
return this._focused;
|
|
@@ -429,8 +430,7 @@ class AskComponent extends Container {
|
|
|
429
430
|
set focused(value: boolean) {
|
|
430
431
|
this._focused = value;
|
|
431
432
|
if (this.editor && this.mode === "freeform") {
|
|
432
|
-
|
|
433
|
-
anyEditor.focused = value;
|
|
433
|
+
(this.editor as any).focused = value;
|
|
434
434
|
}
|
|
435
435
|
}
|
|
436
436
|
|
|
@@ -468,8 +468,16 @@ class AskComponent extends Container {
|
|
|
468
468
|
|
|
469
469
|
if (this.context) {
|
|
470
470
|
this.addChild(new Spacer(1));
|
|
471
|
-
|
|
472
|
-
|
|
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);
|
|
473
481
|
}
|
|
474
482
|
|
|
475
483
|
this.addChild(new Spacer(1));
|
|
@@ -525,27 +533,47 @@ class AskComponent extends Container {
|
|
|
525
533
|
const theme = this.theme;
|
|
526
534
|
this.titleText.setText(theme.fg("accent", theme.bold("Question")));
|
|
527
535
|
this.questionText.setText(theme.fg("text", theme.bold(this.question)));
|
|
528
|
-
if (this.
|
|
529
|
-
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
|
+
}
|
|
530
546
|
}
|
|
531
547
|
}
|
|
532
548
|
|
|
533
549
|
private updateHelpText(): void {
|
|
534
550
|
const theme = this.theme;
|
|
535
551
|
if (this.mode === "freeform") {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
),
|
|
541
|
-
);
|
|
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));
|
|
542
559
|
return;
|
|
543
560
|
}
|
|
544
561
|
|
|
545
562
|
if (this.allowMultiple) {
|
|
546
|
-
|
|
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));
|
|
547
570
|
} else {
|
|
548
|
-
|
|
571
|
+
const hints = [
|
|
572
|
+
rawKeyHint("↑↓", "navigate"),
|
|
573
|
+
rawKeyHint("enter", "select"),
|
|
574
|
+
rawKeyHint("esc", "cancel"),
|
|
575
|
+
].join(" • ");
|
|
576
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
549
577
|
}
|
|
550
578
|
}
|
|
551
579
|
|
|
@@ -575,11 +603,7 @@ class AskComponent extends Container {
|
|
|
575
603
|
|
|
576
604
|
private ensureEditor(): Editor {
|
|
577
605
|
if (this.editor) return this.editor;
|
|
578
|
-
|
|
579
|
-
const editor = new Editor(this.tui, createEditorTheme(this.theme));
|
|
580
|
-
// Default Editor behavior: Enter submits, Shift+Enter inserts newline.
|
|
581
|
-
// Ctrl+Enter is only distinguishable in terminals with Kitty protocol mappings,
|
|
582
|
-
// so we support it as an *additional* submit shortcut in our wrapper.
|
|
606
|
+
const editor = new Editor(createEditorTheme(this.theme));
|
|
583
607
|
editor.disableSubmit = false;
|
|
584
608
|
editor.onSubmit = (text: string) => {
|
|
585
609
|
const trimmed = text.trim();
|
|
@@ -609,8 +633,7 @@ class AskComponent extends Container {
|
|
|
609
633
|
this.modeContainer.clear();
|
|
610
634
|
|
|
611
635
|
const editor = this.ensureEditor();
|
|
612
|
-
|
|
613
|
-
(editor as unknown as { focused?: boolean }).focused = this._focused;
|
|
636
|
+
(editor as any).focused = this._focused;
|
|
614
637
|
|
|
615
638
|
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
|
|
616
639
|
this.modeContainer.addChild(new Spacer(1));
|
|
@@ -621,12 +644,6 @@ class AskComponent extends Container {
|
|
|
621
644
|
this.tui.requestRender();
|
|
622
645
|
}
|
|
623
646
|
|
|
624
|
-
private submitFreeform(): void {
|
|
625
|
-
const editor = this.ensureEditor();
|
|
626
|
-
const text = editor.getText().trim();
|
|
627
|
-
this.onDone(text ? text : null);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
647
|
handleInput(data: string): void {
|
|
631
648
|
if (this.mode === "freeform") {
|
|
632
649
|
if (matchesKey(data, Key.escape)) {
|
|
@@ -639,13 +656,13 @@ class AskComponent extends Container {
|
|
|
639
656
|
return;
|
|
640
657
|
}
|
|
641
658
|
|
|
642
|
-
// Submit on Ctrl+Enter (only works if terminal distinguishes it, e.g. Kitty protocol)
|
|
643
659
|
if (matchesKey(data, Key.ctrl("enter")) || matchesKey(data, "ctrl+enter")) {
|
|
644
|
-
this.
|
|
660
|
+
const editor = this.ensureEditor();
|
|
661
|
+
const text = editor.getText().trim();
|
|
662
|
+
this.onDone(text ? text : null);
|
|
645
663
|
return;
|
|
646
664
|
}
|
|
647
665
|
|
|
648
|
-
// Let Editor handle everything else (Enter submits, Shift+Enter newline)
|
|
649
666
|
this.ensureEditor().handleInput(data);
|
|
650
667
|
this.tui.requestRender();
|
|
651
668
|
return;
|
|
@@ -703,11 +720,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
703
720
|
Type.Boolean({ description: "Add a freeform text option. Default: true" }),
|
|
704
721
|
),
|
|
705
722
|
timeout: Type.Optional(
|
|
706
|
-
Type.Number({ description: "Auto-dismiss after N milliseconds
|
|
723
|
+
Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
|
|
707
724
|
),
|
|
708
725
|
}),
|
|
709
726
|
|
|
710
|
-
async execute(_toolCallId, params, signal,
|
|
727
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
711
728
|
if (signal?.aborted) {
|
|
712
729
|
return {
|
|
713
730
|
content: [{ type: "text", text: "Cancelled" }],
|
|
@@ -754,16 +771,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
754
771
|
};
|
|
755
772
|
}
|
|
756
773
|
|
|
774
|
+
pi.events.emit("ask:answered", { question, context: normalizedContext, answer, wasCustom: true });
|
|
757
775
|
return {
|
|
758
776
|
content: [{ type: "text", text: `User answered: ${answer}` }],
|
|
759
777
|
details: { question, context: normalizedContext, options, answer, cancelled: false, wasCustom: true } as AskToolDetails,
|
|
760
778
|
};
|
|
761
779
|
}
|
|
762
780
|
|
|
781
|
+
onUpdate?.({
|
|
782
|
+
content: [{ type: "text", text: "Waiting for user input..." }],
|
|
783
|
+
details: { question, context: normalizedContext, options, answer: null, cancelled: false },
|
|
784
|
+
});
|
|
785
|
+
|
|
763
786
|
let result: string | null;
|
|
764
787
|
try {
|
|
765
788
|
result = await ctx.ui.custom<string | null>(
|
|
766
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
|
+
|
|
767
801
|
return new AskComponent(
|
|
768
802
|
question,
|
|
769
803
|
normalizedContext,
|
|
@@ -780,6 +814,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
780
814
|
overlayOptions: {
|
|
781
815
|
anchor: "center",
|
|
782
816
|
width: ASK_OVERLAY_WIDTH,
|
|
817
|
+
minWidth: ASK_OVERLAY_MIN_WIDTH,
|
|
783
818
|
maxHeight: "85%",
|
|
784
819
|
margin: 1,
|
|
785
820
|
},
|
|
@@ -796,12 +831,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
796
831
|
}
|
|
797
832
|
|
|
798
833
|
if (result === null) {
|
|
834
|
+
pi.events.emit("ask:cancelled", { question, context: normalizedContext, options });
|
|
799
835
|
return {
|
|
800
836
|
content: [{ type: "text", text: "User cancelled the question" }],
|
|
801
837
|
details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
|
|
802
838
|
};
|
|
803
839
|
}
|
|
804
840
|
|
|
841
|
+
pi.events.emit("ask:answered", { question, context: normalizedContext, answer: result, wasCustom: false });
|
|
805
842
|
return {
|
|
806
843
|
content: [{ type: "text", text: `User answered: ${result}` }],
|
|
807
844
|
details: { question, context: normalizedContext, options, answer: result, cancelled: false } as AskToolDetails,
|
|
@@ -825,26 +862,55 @@ export default function (pi: ExtensionAPI) {
|
|
|
825
862
|
return new Text(text, 0, 0);
|
|
826
863
|
},
|
|
827
864
|
|
|
828
|
-
renderResult(result,
|
|
865
|
+
renderResult(result, options, theme) {
|
|
829
866
|
const details = result.details as (AskToolDetails & { error?: string }) | undefined;
|
|
830
867
|
|
|
831
|
-
// Error state
|
|
832
868
|
if (details?.error) {
|
|
833
869
|
return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
|
|
834
870
|
}
|
|
835
871
|
|
|
836
|
-
|
|
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
|
+
|
|
837
881
|
if (!details || details.cancelled) {
|
|
838
882
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
839
883
|
}
|
|
840
884
|
|
|
841
|
-
// Success
|
|
842
885
|
const answer = details.answer ?? "";
|
|
843
886
|
let text = theme.fg("success", "✓ ");
|
|
844
887
|
if (details.wasCustom) {
|
|
845
888
|
text += theme.fg("muted", "(wrote) ");
|
|
846
889
|
}
|
|
847
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
|
+
|
|
848
914
|
return new Text(text, 0, 0);
|
|
849
915
|
},
|
|
850
916
|
});
|