import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { openQuestionnaireDialog } from "./src/dialog.ts"; import { AskUserParamsSchema, normalizeQuestionnaire, type AskUserDetails, type AskUserError, type QuestionAnswer, } from "./src/schema.ts"; const DISMISSED_MESSAGE = "User dismissed the questionnaire without submitting. Do not infer answers from unsubmitted choices."; export function buildToolResult(text: string, details: AskUserDetails) { return { content: [{ type: "text" as const, text }], details, }; } export function buildCancelledResult( error: AskUserError | undefined, message: string, ) { return buildToolResult(message, { answers: [], cancelled: true, ...(error ? { error } : {}), }); } function formatSelection( selection: QuestionAnswer["selections"][number], ): string { const stableValue = selection.value === selection.label ? "" : ` [value: ${selection.value}]`; const custom = selection.custom ? " (custom)" : ""; return `${selection.label}${stableValue}${custom}`; } export function formatAnswers(answers: QuestionAnswer[]): string { const lines = answers.map((answer) => { const selections = answer.selections.map(formatSelection).join(", "); return `- "${answer.question}" = ${selections}`; }); return `User submitted these answers:\n${lines.join("\n")}\nContinue with these answers in mind.`; } export default function askUser(pi: ExtensionAPI) { pi.registerTool({ name: "ask_user", label: "Ask User", description: `Ask the user one or more related structured questions in a single questionnaire. Each invocation accepts 1-4 questions with 2-5 options each. Set multiSelect when multiple choices may be selected. Every question also offers a free-form custom answer; multi-select questions can combine it with predefined choices. Option values are optional: provide a stable machine-readable value when labels may change or when downstream logic benefits from an identifier, otherwise the label is returned as the value. Questions are reviewed before submission and the user may dismiss the questionnaire without answering. Group related questions in one call rather than opening several dialogs.`, promptSnippet: "Ask 1-4 grouped single- or multi-select questions with optional stable values", promptGuidelines: [ "Use ask_user when related clarification questions can be answered together with concrete choices.", "Group related questions into one ask_user call instead of issuing multiple interactive calls.", "For ask_user options, provide a short label and useful description; add a stable value when the display label is not a reliable machine identifier.", "Set ask_user multiSelect to true only when choices are not mutually exclusive.", ], parameters: AskUserParamsSchema, executionMode: "sequential", async execute(_toolCallId, params, signal, _onUpdate, ctx) { if (ctx.mode !== "tui") { return buildCancelledResult( "no_ui", "No interactive TUI is available. Ask the user in plain text instead.", ); } if (signal?.aborted) { return buildCancelledResult("aborted", "Questionnaire aborted."); } const normalized = normalizeQuestionnaire(params); if (!normalized.ok) { return buildCancelledResult( normalized.error, `Invalid questionnaire: ${normalized.message}`, ); } const outcome = await openQuestionnaireDialog( ctx, normalized.questions, signal, ); if (!outcome || outcome.kind === "dismissed") { return buildCancelledResult(undefined, DISMISSED_MESSAGE); } if (outcome.kind === "aborted") { return buildCancelledResult("aborted", "Questionnaire aborted."); } return buildToolResult(formatAnswers(outcome.answers), { answers: outcome.answers, cancelled: false, }); }, renderCall(args, theme, _context) { const count = Array.isArray(args.questions) ? args.questions.length : 0; const headers = Array.isArray(args.questions) ? args.questions .map( (question, index) => question.header?.trim() || `Q${index + 1}`, ) .join(", ") : ""; let text = theme.fg("toolTitle", theme.bold("ask_user ")); text += theme.fg("muted", `${count} question${count === 1 ? "" : "s"}`); if (headers) text += theme.fg("dim", ` (${headers})`); return new Text(text, 0, 0); }, renderResult(result, _options, theme, _context) { const details = result.details as AskUserDetails | undefined; if (!details) { const first = result.content[0]; return new Text(first?.type === "text" ? first.text : "", 0, 0); } if (details.cancelled) { const label = details.error ? details.error === "aborted" ? "✗ aborted" : `✗ unavailable (${details.error})` : "✗ dismissed"; return new Text(theme.fg("warning", label), 0, 0); } const lines = details.answers.map((answer) => { const selected = answer.selections .map((selection) => selection.label) .join(", "); return `${theme.fg("success", "✓ ")}${theme.fg("accent", answer.questionId)}: ${theme.fg("text", selected)}`; }); return new Text(lines.join("\n"), 0, 0); }, }); }