Skip to content

Commit ed79f7c

Browse files
authored
refactor(pi-ask-question): use native dialogs (#177)
1 parent 70e7c2d commit ed79f7c

5 files changed

Lines changed: 64 additions & 171 deletions

File tree

package-lock.json

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/pi-ask-question/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,4 @@ pi install npm:@henryqw/pi-ask-question
3030
}
3131
```
3232

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.
Lines changed: 16 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,6 @@
11
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";
122
import { Type } from "typebox";
133

14-
interface QuestionOption {
15-
label: string;
16-
description?: string;
17-
}
18-
19-
type DisplayOption = QuestionOption & { isOther?: boolean };
20-
214
const CUSTOM_OPTION_LABEL = "Something else.";
225
// Models are told to omit "(Recommended)" from labels but don't always comply; normalize instead of duplicating.
236
const RECOMMENDED_SUFFIX = /\s*\(recommended\)\s*$/i;
@@ -86,126 +69,20 @@ export default function askQuestionExtension(pi: ExtensionAPI): void {
8669
};
8770
}
8871

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}`);
17177

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;
19484

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) {
20986
return {
21087
content: [{ type: "text" as const, text: "User cancelled question" }],
21188
details: { question, options, answer: null } satisfies QuestionDetails,
@@ -214,42 +91,16 @@ export default function askQuestionExtension(pi: ExtensionAPI): void {
21491
return {
21592
content: [{
21693
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}`,
21895
}],
21996
details: {
22097
question,
22198
options,
222-
answer: result.answer,
223-
wasCustom: result.wasCustom,
224-
selectedIndex: result.index,
99+
answer,
100+
wasCustom,
101+
selectedIndex: wasCustom ? undefined : selectedIndex + 1,
225102
} satisfies QuestionDetails,
226103
};
227104
},
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-
},
254105
});
255106
}

packages/pi-ask-question/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@henryqw/pi-ask-question",
3-
"version": "0.1.10",
3+
"version": "0.1.11",
44
"description": "Ask Pi users one interactive question with choices or a custom answer.",
55
"keywords": [
66
"pi-package",
@@ -19,13 +19,13 @@
1919
"LICENSE"
2020
],
2121
"scripts": {
22+
"test": "node --test test/*.test.ts",
2223
"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",
2425
"pack:check": "npm pack --dry-run"
2526
},
2627
"peerDependencies": {
2728
"@earendil-works/pi-coding-agent": ">=0.84.1",
28-
"@earendil-works/pi-tui": ">=0.84.1",
2929
"typebox": "^1.3.15"
3030
},
3131
"repository": {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4+
import askQuestionExtension from "../extensions/ask-question.ts";
5+
6+
type Details = { answer: string | null; selectedIndex?: number };
7+
type RegisteredTool = {
8+
execute(
9+
toolCallId: string,
10+
params: { question: string; options: Array<{ label: string; description?: string }> },
11+
signal: AbortSignal | undefined,
12+
onUpdate: undefined,
13+
ctx: ExtensionContext,
14+
): Promise<AgentToolResult<Details>>;
15+
};
16+
17+
function loadTool(): RegisteredTool {
18+
let tool: RegisteredTool | undefined;
19+
askQuestionExtension({
20+
registerTool(definition) {
21+
tool = definition as unknown as RegisteredTool;
22+
},
23+
} as ExtensionAPI);
24+
assert.ok(tool);
25+
return tool;
26+
}
27+
28+
test("returns the selected source option when display labels would otherwise collide", async () => {
29+
const ctx = {
30+
mode: "tui",
31+
ui: {
32+
select: async (_title: string, choices: string[]) => choices[1],
33+
input: async () => undefined,
34+
},
35+
} as unknown as ExtensionContext;
36+
const result = await loadTool().execute("call-1", {
37+
question: "Choose one",
38+
options: [{ label: "A" }, { label: "A (Recommended)" }],
39+
}, new AbortController().signal, undefined, ctx);
40+
41+
assert.equal(result.details?.answer, "A (Recommended)");
42+
assert.equal(result.details?.selectedIndex, 2);
43+
});

0 commit comments

Comments
 (0)