import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { formatAnswers, normalizeQuestions } from "./normalize.ts"; import { AskUserParamsSchema } from "./schema.ts"; import { runRpcQuestions } from "./rpc.ts"; import type { AskUserResult, QuestionInput } from "./types.ts"; import { AskUserView } from "./view.ts"; function fallbackText(result: { content: Array<{ type: string; text?: string }> }): string { return result.content.find((item) => item.type === "text")?.text ?? ""; } export function registerAskUserTool(pi: ExtensionAPI): void { pi.registerTool({ name: "ask_user_question", label: "Ask User", description: "Ask the user one or more structured questions in an interactive UI when their input is genuinely needed to continue. Supports TUI and RPC clients, choices, free-form text, optional custom answers, and cancellation. Do not ask for information already stated by the user.", promptSnippet: "Use ask_user_question to pause and collect missing user decisions instead of guessing", parameters: AskUserParamsSchema, executionMode: "sequential", async execute(_toolCallId, params, signal, _onUpdate, ctx) { const questions = normalizeQuestions(params.questions as QuestionInput[]); if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) { throw new Error("ask_user_question requires interactive TUI or RPC UI mode"); } if (signal?.aborted) { const result: AskUserResult = { version: 1, cancelled: true, questions, answers: [] }; return { content: [{ type: "text", text: "User interaction was cancelled" }], details: result }; } const result = ctx.mode === "tui" ? await ctx.ui.custom((tui, theme, _keybindings, done) => new AskUserView(tui, theme, questions, done, signal), ) : await runRpcQuestions(ctx.ui, questions, signal); if (result.cancelled) { return { content: [{ type: "text", text: "User cancelled the questions" }], details: result, }; } return { content: [{ type: "text", text: formatAnswers(result.answers) }], details: result, }; }, renderCall(args, theme) { const questions = Array.isArray(args.questions) ? args.questions as Array<{ id?: unknown; label?: unknown }> : []; const labels = questions .map((question) => typeof question.label === "string" ? question.label : typeof question.id === "string" ? question.id : "question") .join(", "); let text = theme.fg("toolTitle", theme.bold("ask user ")); text += theme.fg("muted", `${questions.length} question${questions.length === 1 ? "" : "s"}`); if (labels) text += theme.fg("dim", ` (${labels})`); return new Text(text, 0, 0); }, renderResult(result, _options, theme) { const details = result.details as AskUserResult | undefined; if (!details) return new Text(fallbackText(result), 0, 0); if (details.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0); const lines = details.answers.map((answer) => { const value = answer.skipped ? theme.fg("muted", "Skipped") : `${answer.custom || answer.type === "text" ? theme.fg("muted", "(wrote) ") : ""}${theme.fg("accent", answer.label)}`; return `${theme.fg("success", "✓ ")}${theme.fg("accent", answer.id)}: ${value}`; }); return new Text(lines.join("\n"), 0, 0); }, }); }