@henryqw/pi-ask-question 0.1.11 → 0.2.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 CHANGED
@@ -31,3 +31,5 @@ pi install npm:@henryqw/pi-ask-question
31
31
  ```
32
32
 
33
33
  Supply one to three options in preference order. UI marks the first `(Recommended)` and adds `Something else.`, which opens a text input for a custom answer. Empty questions, blank or duplicate labels, empty lists, more than three options, and non-interactive sessions return an error. Aborting the tool closes the pending question.
34
+
35
+ Extensions can reuse the same validated interaction with the `askQuestion(params, ctx, signal)` package export; it returns the tool's answer details without registering another UI flow.
@@ -0,0 +1,21 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ export interface AskQuestionOption {
3
+ label: string;
4
+ description?: string;
5
+ }
6
+ export interface AskQuestionRequest {
7
+ question: string;
8
+ options: AskQuestionOption[];
9
+ }
10
+ export interface AskQuestionResult {
11
+ question: string;
12
+ options: string[];
13
+ answer: string | null;
14
+ wasCustom?: boolean;
15
+ selectedIndex?: number;
16
+ error?: string;
17
+ }
18
+ type AskQuestionContext = Pick<ExtensionContext, "mode" | "ui">;
19
+ /** Run the validated interactive question flow shared by consumers. */
20
+ export declare function askQuestion(params: AskQuestionRequest, ctx: AskQuestionContext, signal?: AbortSignal): Promise<AskQuestionResult>;
21
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,49 @@
1
+ const CUSTOM_OPTION_LABEL = "Something else.";
2
+ const RECOMMENDED_SUFFIX = /\s*\(recommended\)\s*$/i;
3
+ const withRecommended = (label) => `${label.replace(RECOMMENDED_SUFFIX, "")} (Recommended)`;
4
+ /** Run the validated interactive question flow shared by consumers. */
5
+ export async function askQuestion(params, ctx, signal) {
6
+ const question = params.question.trim();
7
+ const suppliedOptions = params.options.map((option) => ({
8
+ label: option.label.trim(),
9
+ ...(option.description === undefined ? {} : { description: option.description.trim() }),
10
+ }));
11
+ const options = suppliedOptions.map((option) => option.label);
12
+ if (ctx.mode !== "tui") {
13
+ const error = "UI not available (running in non-interactive mode)";
14
+ return { question, options, answer: null, error };
15
+ }
16
+ let validationError;
17
+ if (!question)
18
+ validationError = "Question must not be blank";
19
+ else if (suppliedOptions.length < 1 || suppliedOptions.length > 3)
20
+ validationError = "Provide one to three options";
21
+ else if (suppliedOptions.some((option) => !option.label))
22
+ validationError = "Option labels must not be blank";
23
+ else if (new Set(options.map((option) => option.toLowerCase())).size !== options.length)
24
+ validationError = "Option labels must be unique";
25
+ else if (options.some((option) => option.toLowerCase() === CUSTOM_OPTION_LABEL.toLowerCase()))
26
+ validationError = `Option label "${CUSTOM_OPTION_LABEL}" is reserved`;
27
+ if (validationError)
28
+ return { question, options, answer: null, error: validationError };
29
+ const choices = suppliedOptions.map((option, index) => {
30
+ const label = index === 0 ? withRecommended(option.label) : option.label;
31
+ return `${index + 1}. ${label}${option.description ? ` — ${option.description}` : ""}`;
32
+ });
33
+ choices.push(`${choices.length + 1}. ${CUSTOM_OPTION_LABEL}`);
34
+ const selected = await ctx.ui.select(question, choices, { signal });
35
+ const selectedIndex = selected === undefined ? -1 : choices.indexOf(selected);
36
+ const wasCustom = selectedIndex === suppliedOptions.length;
37
+ const answer = wasCustom
38
+ ? (await ctx.ui.input(CUSTOM_OPTION_LABEL, "Type your answer", { signal }))?.trim()
39
+ : suppliedOptions[selectedIndex]?.label;
40
+ if (!answer)
41
+ return { question, options, answer: null };
42
+ return {
43
+ question,
44
+ options,
45
+ answer,
46
+ wasCustom,
47
+ selectedIndex: wasCustom ? undefined : selectedIndex + 1,
48
+ };
49
+ }
@@ -1,20 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { askQuestion } from "@henryqw/pi-ask-question";
2
3
  import { Type } from "typebox";
3
4
 
4
- const CUSTOM_OPTION_LABEL = "Something else.";
5
- // Models are told to omit "(Recommended)" from labels but don't always comply; normalize instead of duplicating.
6
- const RECOMMENDED_SUFFIX = /\s*\(recommended\)\s*$/i;
7
- const withRecommended = (label: string): string => `${label.replace(RECOMMENDED_SUFFIX, "")} (Recommended)`;
8
-
9
- interface QuestionDetails {
10
- question: string;
11
- options: string[];
12
- answer: string | null;
13
- wasCustom?: boolean;
14
- selectedIndex?: number;
15
- error?: string;
16
- }
17
-
18
5
  const QuestionOptionSchema = Type.Object({
19
6
  label: Type.String({ description: "Display label for the option", minLength: 1 }),
20
7
  description: Type.Optional(Type.String({ description: "Optional description shown below label", minLength: 1 })),
@@ -44,62 +31,19 @@ export default function askQuestionExtension(pi: ExtensionAPI): void {
44
31
  executionMode: "sequential",
45
32
 
46
33
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
47
- const question = params.question.trim();
48
- const suppliedOptions = params.options.map((option) => ({
49
- label: option.label.trim(),
50
- ...(option.description === undefined ? {} : { description: option.description.trim() }),
51
- }));
52
- const options = suppliedOptions.map((option) => option.label);
53
- if (ctx.mode !== "tui") {
54
- const error = "UI not available (running in non-interactive mode)";
55
- return {
56
- content: [{ type: "text" as const, text: `Error: ${error}` }],
57
- details: { question, options, answer: null, error } satisfies QuestionDetails,
58
- };
59
- }
60
- let validationError: string | undefined;
61
- if (!question) validationError = "Question must not be blank";
62
- else if (suppliedOptions.some((option) => !option.label)) validationError = "Option labels must not be blank";
63
- else if (new Set(options.map((option) => option.toLowerCase())).size !== options.length) validationError = "Option labels must be unique";
64
- else if (options.some((option) => option.toLowerCase() === CUSTOM_OPTION_LABEL.toLowerCase())) validationError = `Option label "${CUSTOM_OPTION_LABEL}" is reserved`;
65
- if (validationError) {
66
- return {
67
- content: [{ type: "text" as const, text: `Error: ${validationError}` }],
68
- details: { question, options, answer: null, error: validationError } satisfies QuestionDetails,
69
- };
70
- }
71
-
72
- const choices = suppliedOptions.map((option, index) => {
73
- const label = index === 0 ? withRecommended(option.label) : option.label;
74
- return `${index + 1}. ${label}${option.description ? ` — ${option.description}` : ""}`;
75
- });
76
- choices.push(`${choices.length + 1}. ${CUSTOM_OPTION_LABEL}`);
77
-
78
- const selected = await ctx.ui.select(question, choices, { signal });
79
- const selectedIndex = selected === undefined ? -1 : choices.indexOf(selected);
80
- const wasCustom = selectedIndex === suppliedOptions.length;
81
- const answer = wasCustom
82
- ? (await ctx.ui.input(CUSTOM_OPTION_LABEL, "Type your answer", { signal }))?.trim()
83
- : suppliedOptions[selectedIndex]?.label;
84
-
85
- if (!answer) {
86
- return {
87
- content: [{ type: "text" as const, text: "User cancelled question" }],
88
- details: { question, options, answer: null } satisfies QuestionDetails,
89
- };
90
- }
34
+ const details = await askQuestion(params, ctx, signal);
91
35
  return {
92
36
  content: [{
93
37
  type: "text" as const,
94
- text: wasCustom ? `User wrote: ${answer}` : `User selected: ${selectedIndex + 1}. ${answer}`,
38
+ text: details.error
39
+ ? `Error: ${details.error}`
40
+ : !details.answer
41
+ ? "User cancelled question"
42
+ : details.wasCustom
43
+ ? `User wrote: ${details.answer}`
44
+ : `User selected: ${details.selectedIndex}. ${details.answer}`,
95
45
  }],
96
- details: {
97
- question,
98
- options,
99
- answer,
100
- wasCustom,
101
- selectedIndex: wasCustom ? undefined : selectedIndex + 1,
102
- } satisfies QuestionDetails,
46
+ details,
103
47
  };
104
48
  },
105
49
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-ask-question",
3
- "version": "0.1.11",
3
+ "version": "0.2.0",
4
4
  "description": "Ask Pi users one interactive question with choices or a custom answer.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -14,14 +14,24 @@
14
14
  },
15
15
  "license": "MIT",
16
16
  "files": [
17
+ "dist",
17
18
  "extensions",
18
19
  "README.md",
19
20
  "LICENSE"
20
21
  ],
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ }
28
+ },
21
29
  "scripts": {
22
- "test": "node --test test/*.test.ts",
30
+ "build": "tsc --project tsconfig.build.json",
31
+ "test": "npm run build && node --test test/*.test.ts",
23
32
  "test:manual": "pi --no-extensions -e ./extensions/ask-question.ts --tools ask_question --no-session \"We need storage for a small team app. Before making changes, ask me to choose storage.\"",
24
- "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/ask-question.ts test/*.test.ts",
33
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck src/*.ts extensions/ask-question.ts test/*.test.ts",
34
+ "prepack": "npm run build",
25
35
  "pack:check": "npm pack --dry-run"
26
36
  },
27
37
  "peerDependencies": {