@henryqw/pi-ask-question 0.1.10 → 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
@@ -30,4 +30,6 @@ pi install npm:@henryqw/pi-ask-question
30
30
  }
31
31
  ```
32
32
 
33
- Supply one to three options in preference order. UI marks the first `(Recommended)` and adds `Something else.` for a custom answer. Number keys select options. 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.
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,37 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import {
3
- Editor,
4
- type EditorTheme,
5
- Key,
6
- type KeyId,
7
- matchesKey,
8
- Text,
9
- visibleWidth,
10
- wrapTextWithAnsi,
11
- } from "@earendil-works/pi-tui";
2
+ import { askQuestion } from "@henryqw/pi-ask-question";
12
3
  import { Type } from "typebox";
13
4
 
14
- interface QuestionOption {
15
- label: string;
16
- description?: string;
17
- }
18
-
19
- type DisplayOption = QuestionOption & { isOther?: boolean };
20
-
21
- const CUSTOM_OPTION_LABEL = "Something else.";
22
- // Models are told to omit "(Recommended)" from labels but don't always comply; normalize instead of duplicating.
23
- const RECOMMENDED_SUFFIX = /\s*\(recommended\)\s*$/i;
24
- const withRecommended = (label: string): string => `${label.replace(RECOMMENDED_SUFFIX, "")} (Recommended)`;
25
-
26
- interface QuestionDetails {
27
- question: string;
28
- options: string[];
29
- answer: string | null;
30
- wasCustom?: boolean;
31
- selectedIndex?: number;
32
- error?: string;
33
- }
34
-
35
5
  const QuestionOptionSchema = Type.Object({
36
6
  label: Type.String({ description: "Display label for the option", minLength: 1 }),
37
7
  description: Type.Optional(Type.String({ description: "Optional description shown below label", minLength: 1 })),
@@ -61,195 +31,20 @@ export default function askQuestionExtension(pi: ExtensionAPI): void {
61
31
  executionMode: "sequential",
62
32
 
63
33
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
64
- const question = params.question.trim();
65
- const suppliedOptions = params.options.map((option) => ({
66
- label: option.label.trim(),
67
- ...(option.description === undefined ? {} : { description: option.description.trim() }),
68
- }));
69
- const options = suppliedOptions.map((option) => option.label);
70
- if (ctx.mode !== "tui") {
71
- const error = "UI not available (running in non-interactive mode)";
72
- return {
73
- content: [{ type: "text" as const, text: `Error: ${error}` }],
74
- details: { question, options, answer: null, error } satisfies QuestionDetails,
75
- };
76
- }
77
- let validationError: string | undefined;
78
- if (!question) validationError = "Question must not be blank";
79
- else if (suppliedOptions.some((option) => !option.label)) validationError = "Option labels must not be blank";
80
- else if (new Set(options.map((option) => option.toLowerCase())).size !== options.length) validationError = "Option labels must be unique";
81
- else if (options.some((option) => option.toLowerCase() === CUSTOM_OPTION_LABEL.toLowerCase())) validationError = `Option label "${CUSTOM_OPTION_LABEL}" is reserved`;
82
- if (validationError) {
83
- return {
84
- content: [{ type: "text" as const, text: `Error: ${validationError}` }],
85
- details: { question, options, answer: null, error: validationError } satisfies QuestionDetails,
86
- };
87
- }
88
-
89
- const allOptions: DisplayOption[] = [...suppliedOptions, { label: CUSTOM_OPTION_LABEL, isOther: true }];
90
- const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>(
91
- (tui, theme, _kb, done) => {
92
- let optionIndex = 0;
93
- let editMode = false;
94
- let cachedLines: string[] | undefined;
95
- const editorTheme: EditorTheme = {
96
- borderColor: (text) => theme.fg("accent", text),
97
- selectList: {
98
- selectedPrefix: (text) => theme.fg("accent", text),
99
- selectedText: (text) => theme.fg("accent", text),
100
- description: (text) => theme.fg("muted", text),
101
- scrollInfo: (text) => theme.fg("dim", text),
102
- noMatch: (text) => theme.fg("warning", text),
103
- },
104
- };
105
- const editor = new Editor(tui, editorTheme);
106
-
107
- function refresh(): void {
108
- cachedLines = undefined;
109
- tui.requestRender();
110
- }
111
-
112
- editor.onSubmit = (value) => {
113
- const answer = value.trim();
114
- if (answer) done({ answer, wasCustom: true });
115
- else {
116
- editMode = false;
117
- editor.setText("");
118
- refresh();
119
- }
120
- };
121
-
122
- function selectOption(): void {
123
- const selected = allOptions[optionIndex]!;
124
- if (selected.isOther) editMode = true;
125
- else done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 });
126
- }
127
-
128
- function handleInput(data: string): void {
129
- if (editMode) {
130
- if (matchesKey(data, Key.escape)) {
131
- editMode = false;
132
- editor.setText("");
133
- refresh();
134
- return;
135
- }
136
- editor.handleInput(data);
137
- refresh();
138
- return;
139
- }
140
-
141
- const numberIndex = allOptions.findIndex((_, index) => matchesKey(data, `${index + 1}` as KeyId));
142
- if (numberIndex >= 0) {
143
- optionIndex = numberIndex;
144
- selectOption();
145
- } else if (matchesKey(data, Key.up)) optionIndex = Math.max(0, optionIndex - 1);
146
- else if (matchesKey(data, Key.down)) optionIndex = Math.min(allOptions.length - 1, optionIndex + 1);
147
- else if (matchesKey(data, Key.enter)) selectOption();
148
- else if (matchesKey(data, Key.escape)) {
149
- done(null);
150
- return;
151
- } else return;
152
- refresh();
153
- }
154
-
155
- function render(width: number): string[] {
156
- if (cachedLines) return cachedLines;
157
- const lines: string[] = [];
158
- const renderWidth = Math.max(1, width);
159
- const addWrappedWithPrefix = (prefix: string, text: string): void => {
160
- const prefixWidth = visibleWidth(prefix);
161
- if (prefixWidth >= renderWidth) {
162
- lines.push(...wrapTextWithAnsi(prefix + text, renderWidth));
163
- return;
164
- }
165
- const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
166
- const continuationPrefix = " ".repeat(prefixWidth);
167
- for (let index = 0; index < wrapped.length; index++) {
168
- lines.push(`${index === 0 ? prefix : continuationPrefix}${wrapped[index]}`);
169
- }
170
- };
171
-
172
- lines.push(theme.fg("accent", "─".repeat(renderWidth)));
173
- addWrappedWithPrefix(" ", theme.fg("text", question));
174
- lines.push("");
175
- for (let index = 0; index < allOptions.length; index++) {
176
- const option = allOptions[index]!;
177
- const selected = index === optionIndex;
178
- const prefix = selected ? theme.fg("accent", "> ") : " ";
179
- const label = `${index + 1}. ${index === 0 ? withRecommended(option.label) : option.label}${option.isOther && editMode ? " ✎" : ""}`;
180
- addWrappedWithPrefix(prefix, theme.fg(selected || (option.isOther && editMode) ? "accent" : "text", label));
181
- if (option.description) addWrappedWithPrefix(" ", theme.fg("muted", option.description));
182
- }
183
- if (editMode) {
184
- lines.push("");
185
- addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
186
- for (const line of editor.render(Math.max(1, renderWidth - 2))) lines.push(` ${line}`);
187
- }
188
- lines.push("");
189
- addWrappedWithPrefix(" ", theme.fg("dim", editMode ? "Enter to submit • Esc to go back" : `↑↓ navigate • 1–${allOptions.length} or Enter to select • Esc to cancel`));
190
- lines.push(theme.fg("accent", "─".repeat(renderWidth)));
191
- cachedLines = lines;
192
- return lines;
193
- }
194
-
195
- const abort = (): void => done(null);
196
- if (signal?.aborted) abort();
197
- else signal?.addEventListener("abort", abort, { once: true });
198
-
199
- return {
200
- render,
201
- invalidate: () => { cachedLines = undefined; },
202
- handleInput,
203
- dispose: () => signal?.removeEventListener("abort", abort),
204
- };
205
- },
206
- );
207
-
208
- if (!result) {
209
- return {
210
- content: [{ type: "text" as const, text: "User cancelled question" }],
211
- details: { question, options, answer: null } satisfies QuestionDetails,
212
- };
213
- }
34
+ const details = await askQuestion(params, ctx, signal);
214
35
  return {
215
36
  content: [{
216
37
  type: "text" as const,
217
- text: result.wasCustom ? `User wrote: ${result.answer}` : `User selected: ${result.index}. ${result.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}`,
218
45
  }],
219
- details: {
220
- question,
221
- options,
222
- answer: result.answer,
223
- wasCustom: result.wasCustom,
224
- selectedIndex: result.index,
225
- } satisfies QuestionDetails,
46
+ details,
226
47
  };
227
48
  },
228
-
229
- renderCall(args, theme) {
230
- let text = theme.fg("toolTitle", theme.bold("ask_question ")) + theme.fg("muted", args.question);
231
- const options = Array.isArray(args.options) ? args.options : [];
232
- if (options.length) {
233
- const labels = options.map((option: QuestionOption) => option.label);
234
- const numbered = [...labels, CUSTOM_OPTION_LABEL].map((option, index) => `${index + 1}. ${index === 0 ? withRecommended(option) : option}`);
235
- text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
236
- }
237
- return new Text(text, 0, 0);
238
- },
239
-
240
- renderResult(result, _options, theme) {
241
- const details = result.details as QuestionDetails | undefined;
242
- if (!details) {
243
- const content = result.content[0];
244
- return new Text(content?.type === "text" ? content.text : "", 0, 0);
245
- }
246
- if (details.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
247
- if (details.answer === null) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
248
- if (details.wasCustom) {
249
- return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer), 0, 0);
250
- }
251
- const display = details.selectedIndex ? `${details.selectedIndex}. ${details.answer}` : details.answer;
252
- return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0);
253
- },
254
49
  });
255
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-ask-question",
3
- "version": "0.1.10",
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,18 +14,28 @@
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": {
30
+ "build": "tsc --project tsconfig.build.json",
31
+ "test": "npm run build && node --test test/*.test.ts",
22
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.\"",
23
- "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/ask-question.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",
24
35
  "pack:check": "npm pack --dry-run"
25
36
  },
26
37
  "peerDependencies": {
27
38
  "@earendil-works/pi-coding-agent": ">=0.84.1",
28
- "@earendil-works/pi-tui": ">=0.84.1",
29
39
  "typebox": "^1.3.15"
30
40
  },
31
41
  "repository": {