@henryqw/pi-ask-question 0.1.9 → 0.1.11

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,23 +30,4 @@ 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.
34
-
35
- ## Remove
36
-
37
- ```bash
38
- pi remove npm:@henryqw/pi-ask-question
39
- ```
40
-
41
- ## Development
42
-
43
- ```bash
44
- npm run typecheck --workspace @henryqw/pi-ask-question
45
- npm run pack:check --workspace @henryqw/pi-ask-question
46
- ```
47
-
48
- Manual TUI check outside CI:
49
-
50
- ```bash
51
- npm run test:manual --workspace @henryqw/pi-ask-question
52
- ```
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.
@@ -1,23 +1,6 @@
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";
12
2
  import { Type } from "typebox";
13
3
 
14
- interface QuestionOption {
15
- label: string;
16
- description?: string;
17
- }
18
-
19
- type DisplayOption = QuestionOption & { isOther?: boolean };
20
-
21
4
  const CUSTOM_OPTION_LABEL = "Something else.";
22
5
  // Models are told to omit "(Recommended)" from labels but don't always comply; normalize instead of duplicating.
23
6
  const RECOMMENDED_SUFFIX = /\s*\(recommended\)\s*$/i;
@@ -86,126 +69,20 @@ export default function askQuestionExtension(pi: ExtensionAPI): void {
86
69
  };
87
70
  }
88
71
 
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
- };
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}`);
171
77
 
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
- }
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;
194
84
 
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) {
85
+ if (!answer) {
209
86
  return {
210
87
  content: [{ type: "text" as const, text: "User cancelled question" }],
211
88
  details: { question, options, answer: null } satisfies QuestionDetails,
@@ -214,42 +91,16 @@ export default function askQuestionExtension(pi: ExtensionAPI): void {
214
91
  return {
215
92
  content: [{
216
93
  type: "text" as const,
217
- text: result.wasCustom ? `User wrote: ${result.answer}` : `User selected: ${result.index}. ${result.answer}`,
94
+ text: wasCustom ? `User wrote: ${answer}` : `User selected: ${selectedIndex + 1}. ${answer}`,
218
95
  }],
219
96
  details: {
220
97
  question,
221
98
  options,
222
- answer: result.answer,
223
- wasCustom: result.wasCustom,
224
- selectedIndex: result.index,
99
+ answer,
100
+ wasCustom,
101
+ selectedIndex: wasCustom ? undefined : selectedIndex + 1,
225
102
  } satisfies QuestionDetails,
226
103
  };
227
104
  },
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
105
  });
255
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-ask-question",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Ask Pi users one interactive question with choices or a custom answer.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -19,13 +19,13 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "scripts": {
22
+ "test": "node --test test/*.test.ts",
22
23
  "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",
24
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/ask-question.ts test/*.test.ts",
24
25
  "pack:check": "npm pack --dry-run"
25
26
  },
26
27
  "peerDependencies": {
27
28
  "@earendil-works/pi-coding-agent": ">=0.84.1",
28
- "@earendil-works/pi-tui": ">=0.84.1",
29
29
  "typebox": "^1.3.15"
30
30
  },
31
31
  "repository": {