repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/ask/ask-tool.ts
Rawimport {
defineTool,
type ExtensionContext,
keyHint,
} from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { askInTui } from "./ask-overlay.js";
import { span } from "./src/debug.ts";
const DESCRIPTION =
"Ask the user 1-4 clarifying questions with multiple-choice options and wait for answers in interactive sessions. " +
"Non-interactive sessions return unavailable without asking. " +
"This is the equivalent of Claude Code's AskUserQuestion and Codex's request_user_input. " +
"Use it to gather preferences or requirements, clarify ambiguous instructions, or get decisions on implementation choices as you work. " +
"The user can always answer with free-form text instead of picking an option.";
export const OTHER_LABEL = "Other (free text)";
const parameters = Type.Object({
questions: Type.Array(
Type.Object({
id: Type.Optional(
Type.String({
description:
"Stable snake_case identifier for mapping answers. Derived from the question if omitted.",
}),
),
question: Type.String({
description:
"The complete question to ask. Clear, specific, ends with a question mark.",
}),
header: Type.Optional(
Type.String({
description:
'Very short label shown in the UI (max 12 chars), e.g. "Auth method". Derived if omitted.',
}),
),
multiSelect: Type.Optional(
Type.Boolean({
default: false,
description: "Allow selecting multiple options instead of just one.",
}),
),
options: Type.Array(
Type.Object({
label: Type.String({
description: "Concise display text (1-5 words).",
}),
description: Type.String({
description:
"One short sentence explaining impact/tradeoff if selected.",
}),
}),
{
minItems: 2,
maxItems: 4,
description:
'Distinct, mutually exclusive choices (unless multiSelect). Put the recommended option first and suffix its label with "(Recommended)". Do not include an "Other" option; free-form input is always available.',
},
),
}),
{
minItems: 1,
maxItems: 4,
description: "Questions to ask the user (1-4). Prefer fewer.",
},
),
});
export type AskOption = { label: string; description: string };
export type AskQuestionInput = {
id?: string;
question: string;
header?: string;
multiSelect?: boolean;
options: AskOption[];
};
export type AskQuestion = {
key: string;
header: string;
question: string;
multiSelect: boolean;
options: AskOption[];
};
export type AskAnswer = { answers: string[]; notes?: string };
export type AskFlowResult =
| { status: "answered"; answers: Record<string, AskAnswer> }
| { status: "cancelled" };
type AskDetails =
| {
kind: "ask";
status: "answered";
questions: Array<AskQuestion & { answer?: AskAnswer }>;
}
| { kind: "ask"; status: "cancelled" }
| { kind: "ask"; status: "unavailable" }
| { kind: "ask"; status: "error"; message: string };
const OTHER_PATTERN = /^\s*other\b/i;
const CANCELLED_MESSAGE = "The user interrupted. Stop and wait for direction.";
const UNAVAILABLE_MESSAGE =
"No interactive UI is available in this mode. Proceed with explicit stated assumptions, or ask in plain text in your reply.";
export function normalizeQuestions(raw: AskQuestionInput[]): AskQuestion[] {
if (!Array.isArray(raw) || raw.length === 0)
throw new Error("ask_user_question requires 1-4 questions.");
const usedKeys = new Set<string>();
return raw.map((question, index) => {
const options = (question.options ?? []).filter(
(option) => !OTHER_PATTERN.test(option.label),
);
if (options.length === 0)
throw new Error(
`Question ${index + 1} has no usable options; provide 2-4 distinct options and no "Other".`,
);
const baseKey = (question.id ?? question.question).trim();
let key = baseKey;
let suffix = 2;
while (usedKeys.has(key)) key = `${baseKey}-${suffix++}`;
usedKeys.add(key);
return {
key,
header: (question.header ?? question.id ?? question.question)
.trim()
.slice(0, 12),
question: question.question,
multiSelect: question.multiSelect === true,
options,
};
});
}
export async function runAskFlow(
questions: AskQuestion[],
ctx: ExtensionContext,
signal?: AbortSignal,
): Promise<AskFlowResult> {
const answers: Record<string, AskAnswer> = {};
for (const [index, question] of questions.entries()) {
const title = `Question ${index + 1}/${questions.length} · ${question.header}`;
const answer =
ctx.mode === "tui"
? await askInTui(title, question, ctx, signal)
: question.multiSelect
? await askMultiSelect(title, question, ctx, signal)
: await askSingleSelect(title, question, ctx, signal);
if (!answer) return { status: "cancelled" };
answers[question.key] = answer;
}
return { status: "answered", answers };
}
async function askSingleSelect(
title: string,
question: AskQuestion,
ctx: ExtensionContext,
signal?: AbortSignal,
): Promise<AskAnswer | undefined> {
const entries = question.options.map(
(option) => `${option.label}: ${option.description}`,
);
entries.push(OTHER_LABEL);
const choice = await ctx.ui.select(title, entries, { signal });
if (choice === undefined) return undefined;
const index = entries.indexOf(choice);
if (index < 0 || index >= question.options.length)
return await askOther(title, ctx, signal);
return { answers: [question.options[index].label] };
}
async function askMultiSelect(
title: string,
question: AskQuestion,
ctx: ExtensionContext,
signal?: AbortSignal,
): Promise<AskAnswer | undefined> {
const selected: string[] = [];
for (const option of question.options) {
if (await ctx.ui.confirm(title, `Include ${option.label}?`, { signal }))
selected.push(option.label);
}
const wrapUp = await ctx.ui.select(title, ["Done", OTHER_LABEL, "Cancel"], {
signal,
});
if (wrapUp === undefined || wrapUp === "Cancel") return undefined;
if (wrapUp === OTHER_LABEL) {
const other = await askOther(title, ctx, signal);
if (!other) return undefined;
selected.push(...other.answers);
}
return { answers: selected };
}
async function askOther(
title: string,
ctx: ExtensionContext,
signal?: AbortSignal,
): Promise<AskAnswer | undefined> {
const text = await ctx.ui.input(
`${title} · your answer`,
"Type your answer",
{ signal },
);
const trimmed = text?.trim();
if (!trimmed) return undefined;
return { answers: [trimmed] };
}
function textResult(payload: unknown): string {
return JSON.stringify(payload);
}
function cancelledResult() {
return {
content: [
{
type: "text" as const,
text: textResult({ status: "cancelled", message: CANCELLED_MESSAGE }),
},
],
details: { kind: "ask", status: "cancelled" as const },
terminate: true,
};
}
function unavailableResult() {
return {
content: [
{
type: "text" as const,
text: textResult({
status: "unavailable",
message: UNAVAILABLE_MESSAGE,
}),
},
],
details: { kind: "ask", status: "unavailable" as const },
};
}
function errorResult(message: string) {
return {
content: [
{
type: "text" as const,
text: textResult({ status: "error", message }),
},
],
details: { kind: "ask", status: "error" as const, message },
};
}
function answeredResult(
questions: AskQuestion[],
answers: Record<string, AskAnswer>,
) {
return {
content: [
{
type: "text" as const,
text: textResult({ status: "answered", answers }),
},
],
details: {
kind: "ask" as const,
status: "answered" as const,
questions: questions.map((question) => ({
...question,
answer: answers[question.key],
})),
},
};
}
export function createAskTool() {
return defineTool({
name: "ask_user_question",
label: "ask",
description: DESCRIPTION,
promptSnippet: "Ask the human structured multiple-choice questions",
promptGuidelines: [
"Use ask_user_question only when the human must decide; verify or safely infer what you can.",
'For ask_user_question, provide 2-4 distinct options per question, recommended option first, its label suffixed with "(Recommended)".',
'Never add an "Other" option to ask_user_question; the user always has free-form input available.',
],
parameters,
renderCall(args, theme, context) {
const text =
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const questions = Array.isArray(args.questions) ? args.questions : [];
const count = questions.length;
let output = `${theme.fg("toolTitle", theme.bold("ask_user_question"))} ${theme.fg("toolOutput", `· ${count} question${count === 1 ? "" : "s"}`)}`;
if (!context.expanded) {
output += theme.fg(
"dim",
` (${keyHint("app.tools.expand", "to expand")})`,
);
} else {
for (const question of questions) {
output += `\n${theme.fg("accent", String(question.header ?? ""))} ${theme.fg("toolOutput", String(question.question ?? ""))}`;
for (const option of question.options ?? []) {
output += `\n ${theme.fg("toolOutput", String(option.label ?? ""))} ${theme.fg("dim", String(option.description ?? ""))}`;
}
}
}
text.setText(output);
return text;
},
renderResult(result, { expanded }, theme, context) {
const text =
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const details = result.details as AskDetails | undefined;
let output: string;
if (details?.kind !== "ask") {
output = theme.fg("toolOutput", "ask_user_question finished");
} else if (details.status === "answered") {
const answered = details.questions.filter((q) => q.answer).length;
if (!expanded) {
output = theme.fg(
"toolOutput",
`${answered}/${details.questions.length} answered`,
);
} else {
const lines = details.questions.map((question) => {
const answer = question.answer
? [question.answer.answers.join(", "), question.answer.notes]
.filter(Boolean)
.join(" · ")
: "unanswered";
return `\n${theme.fg("toolOutput", question.question)} ${theme.fg("accent", answer)}`;
});
output = `${theme.fg("toolOutput", `${answered}/${details.questions.length} answered`)}${lines.join("")}`;
}
} else if (details.status === "cancelled") {
output = theme.fg("warning", "cancelled");
} else if (details.status === "unavailable") {
output = theme.fg("dim", "no interactive UI available");
} else {
output = theme.fg("warning", `error: ${details.message}`);
}
text.setText(output);
return text;
},
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const finish = span?.("ask.flow");
let questions: AskQuestion[];
try {
questions = normalizeQuestions(params.questions);
} catch (error) {
finish?.("finish", { status: "invalid" });
return errorResult(
error instanceof Error ? error.message : String(error),
);
}
if (!ctx.hasUI) {
finish?.("finish", { count: questions.length, status: "unavailable" });
return unavailableResult();
}
let flow: AskFlowResult;
try {
flow = await runAskFlow(questions, ctx, signal);
} catch {
finish?.("error");
return cancelledResult();
}
finish?.("finish", { count: questions.length, status: flow.status });
if (flow.status === "cancelled") return cancelledResult();
return answeredResult(questions, flow.answers);
},
});
}