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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/index.ts +107 -41
  3. 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 (fallback input mode)
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 (applies to fallback input mode) |
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 in fallback input mode (when no options are provided)
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 contextText?: Text;
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
- // Focus propagation for IME cursor positioning (Editor is Focusable)
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
- const anyEditor = this.editor as unknown as { focused?: boolean };
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
- this.contextText = new Text("", 1, 0);
472
- this.addChild(this.contextText);
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.contextText && this.context) {
529
- this.contextText.setText(`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`);
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
- this.helpText.setText(
537
- theme.fg(
538
- "dim",
539
- "enter submit • shift+enter newline • (ctrl+enter submit if supported) • esc back • ctrl+c cancel",
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
- this.helpText.setText(theme.fg("dim", "↑↓ navigate • space toggle • enter submit • esc cancel"));
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
- this.helpText.setText(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"));
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
- // Note: pi's bundled pi-tui Editor expects (tui, theme, options?)
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
- // Ensure focus is propagated immediately when switching modes.
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.submitFreeform();
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 (applies to fallback input mode when no options are provided)" }),
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, _onUpdate, ctx) {
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, _options, theme) {
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
- // Cancelled / no details
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ask-user",
3
- "version": "0.4.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": [