Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/pi-ask-question/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ pi install npm:@henryqw/pi-ask-question
}
```

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.
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.
181 changes: 16 additions & 165 deletions packages/pi-ask-question/extensions/ask-question.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,6 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
Editor,
type EditorTheme,
Key,
type KeyId,
matchesKey,
Text,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import { Type } from "typebox";

interface QuestionOption {
label: string;
description?: string;
}

type DisplayOption = QuestionOption & { isOther?: boolean };

const CUSTOM_OPTION_LABEL = "Something else.";
// Models are told to omit "(Recommended)" from labels but don't always comply; normalize instead of duplicating.
const RECOMMENDED_SUFFIX = /\s*\(recommended\)\s*$/i;
Expand Down Expand Up @@ -86,126 +69,20 @@ export default function askQuestionExtension(pi: ExtensionAPI): void {
};
}

const allOptions: DisplayOption[] = [...suppliedOptions, { label: CUSTOM_OPTION_LABEL, isOther: true }];
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>(
(tui, theme, _kb, done) => {
let optionIndex = 0;
let editMode = false;
let cachedLines: string[] | undefined;
const editorTheme: EditorTheme = {
borderColor: (text) => theme.fg("accent", text),
selectList: {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
},
};
const editor = new Editor(tui, editorTheme);

function refresh(): void {
cachedLines = undefined;
tui.requestRender();
}

editor.onSubmit = (value) => {
const answer = value.trim();
if (answer) done({ answer, wasCustom: true });
else {
editMode = false;
editor.setText("");
refresh();
}
};

function selectOption(): void {
const selected = allOptions[optionIndex]!;
if (selected.isOther) editMode = true;
else done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 });
}

function handleInput(data: string): void {
if (editMode) {
if (matchesKey(data, Key.escape)) {
editMode = false;
editor.setText("");
refresh();
return;
}
editor.handleInput(data);
refresh();
return;
}

const numberIndex = allOptions.findIndex((_, index) => matchesKey(data, `${index + 1}` as KeyId));
if (numberIndex >= 0) {
optionIndex = numberIndex;
selectOption();
} else if (matchesKey(data, Key.up)) optionIndex = Math.max(0, optionIndex - 1);
else if (matchesKey(data, Key.down)) optionIndex = Math.min(allOptions.length - 1, optionIndex + 1);
else if (matchesKey(data, Key.enter)) selectOption();
else if (matchesKey(data, Key.escape)) {
done(null);
return;
} else return;
refresh();
}

function render(width: number): string[] {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const renderWidth = Math.max(1, width);
const addWrappedWithPrefix = (prefix: string, text: string): void => {
const prefixWidth = visibleWidth(prefix);
if (prefixWidth >= renderWidth) {
lines.push(...wrapTextWithAnsi(prefix + text, renderWidth));
return;
}
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
const continuationPrefix = " ".repeat(prefixWidth);
for (let index = 0; index < wrapped.length; index++) {
lines.push(`${index === 0 ? prefix : continuationPrefix}${wrapped[index]}`);
}
};
const choices = suppliedOptions.map((option, index) => {
const label = index === 0 ? withRecommended(option.label) : option.label;
return `${index + 1}. ${label}${option.description ? ` — ${option.description}` : ""}`;
});
choices.push(`${choices.length + 1}. ${CUSTOM_OPTION_LABEL}`);

lines.push(theme.fg("accent", "─".repeat(renderWidth)));
addWrappedWithPrefix(" ", theme.fg("text", question));
lines.push("");
for (let index = 0; index < allOptions.length; index++) {
const option = allOptions[index]!;
const selected = index === optionIndex;
const prefix = selected ? theme.fg("accent", "> ") : " ";
const label = `${index + 1}. ${index === 0 ? withRecommended(option.label) : option.label}${option.isOther && editMode ? " ✎" : ""}`;
addWrappedWithPrefix(prefix, theme.fg(selected || (option.isOther && editMode) ? "accent" : "text", label));
if (option.description) addWrappedWithPrefix(" ", theme.fg("muted", option.description));
}
if (editMode) {
lines.push("");
addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
for (const line of editor.render(Math.max(1, renderWidth - 2))) lines.push(` ${line}`);
}
lines.push("");
addWrappedWithPrefix(" ", theme.fg("dim", editMode ? "Enter to submit • Esc to go back" : `↑↓ navigate • 1–${allOptions.length} or Enter to select • Esc to cancel`));
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
cachedLines = lines;
return lines;
}
const selected = await ctx.ui.select(question, choices, { signal });
const selectedIndex = selected === undefined ? -1 : choices.indexOf(selected);
const wasCustom = selectedIndex === suppliedOptions.length;
const answer = wasCustom
? (await ctx.ui.input(CUSTOM_OPTION_LABEL, "Type your answer", { signal }))?.trim()
: suppliedOptions[selectedIndex]?.label;

const abort = (): void => done(null);
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, { once: true });

return {
render,
invalidate: () => { cachedLines = undefined; },
handleInput,
dispose: () => signal?.removeEventListener("abort", abort),
};
},
);

if (!result) {
if (!answer) {
return {
content: [{ type: "text" as const, text: "User cancelled question" }],
details: { question, options, answer: null } satisfies QuestionDetails,
Expand All @@ -214,42 +91,16 @@ export default function askQuestionExtension(pi: ExtensionAPI): void {
return {
content: [{
type: "text" as const,
text: result.wasCustom ? `User wrote: ${result.answer}` : `User selected: ${result.index}. ${result.answer}`,
text: wasCustom ? `User wrote: ${answer}` : `User selected: ${selectedIndex + 1}. ${answer}`,
}],
details: {
question,
options,
answer: result.answer,
wasCustom: result.wasCustom,
selectedIndex: result.index,
answer,
wasCustom,
selectedIndex: wasCustom ? undefined : selectedIndex + 1,
} satisfies QuestionDetails,
};
},

renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("ask_question ")) + theme.fg("muted", args.question);
const options = Array.isArray(args.options) ? args.options : [];
if (options.length) {
const labels = options.map((option: QuestionOption) => option.label);
const numbered = [...labels, CUSTOM_OPTION_LABEL].map((option, index) => `${index + 1}. ${index === 0 ? withRecommended(option) : option}`);
text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
}
return new Text(text, 0, 0);
},

renderResult(result, _options, theme) {
const details = result.details as QuestionDetails | undefined;
if (!details) {
const content = result.content[0];
return new Text(content?.type === "text" ? content.text : "", 0, 0);
}
if (details.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
if (details.answer === null) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
if (details.wasCustom) {
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer), 0, 0);
}
const display = details.selectedIndex ? `${details.selectedIndex}. ${details.answer}` : details.answer;
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0);
},
});
}
6 changes: 3 additions & 3 deletions packages/pi-ask-question/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@henryqw/pi-ask-question",
"version": "0.1.10",
"version": "0.1.11",
"description": "Ask Pi users one interactive question with choices or a custom answer.",
"keywords": [
"pi-package",
Expand All @@ -19,13 +19,13 @@
"LICENSE"
],
"scripts": {
"test": "node --test test/*.test.ts",
"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.\"",
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/ask-question.ts",
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/ask-question.ts test/*.test.ts",
"pack:check": "npm pack --dry-run"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": ">=0.84.1",
"@earendil-works/pi-tui": ">=0.84.1",
"typebox": "^1.3.15"
},
"repository": {
Expand Down
43 changes: 43 additions & 0 deletions packages/pi-ask-question/test/ask-question.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import askQuestionExtension from "../extensions/ask-question.ts";

type Details = { answer: string | null; selectedIndex?: number };
type RegisteredTool = {
execute(
toolCallId: string,
params: { question: string; options: Array<{ label: string; description?: string }> },
signal: AbortSignal | undefined,
onUpdate: undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<Details>>;
};

function loadTool(): RegisteredTool {
let tool: RegisteredTool | undefined;
askQuestionExtension({
registerTool(definition) {
tool = definition as unknown as RegisteredTool;
},
} as ExtensionAPI);
assert.ok(tool);
return tool;
}

test("returns the selected source option when display labels would otherwise collide", async () => {
const ctx = {
mode: "tui",
ui: {
select: async (_title: string, choices: string[]) => choices[1],
input: async () => undefined,
},
} as unknown as ExtensionContext;
const result = await loadTool().execute("call-1", {
question: "Choose one",
options: [{ label: "A" }, { label: "A (Recommended)" }],
}, new AbortController().signal, undefined, ctx);

assert.equal(result.details?.answer, "A (Recommended)");
assert.equal(result.details?.selectedIndex, 2);
});
Loading