import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { playSound } from "../notify-sound/index.ts"; import { buildExpandedParams, kylinFrameForCall, kylinFrameForResult } from "../../../vera-theme/src/public"; import { promptQuestions } from "./prompt"; import type { Answer, AskUserDetails, Question } from "./types"; // ── Schema ─────────────────────────────────────────────────────────────── const OptionSchema = Type.Object({ value: Type.String({ description: "Machine-readable value returned in the answer" }), label: Type.String({ description: "Human-readable label shown to the user" }), }); const QuestionSchema = Type.Object({ id: Type.String({ description: "Unique identifier for this question" }), text: Type.String({ description: "The question text displayed to the user" }), type: Type.Union([Type.Literal("single"), Type.Literal("multi")], { description: "single = pick one, multi = pick any number", }), options: Type.Array(OptionSchema, { description: "Available choices", minItems: 1, }), allowComment: Type.Optional(Type.Boolean({ description: "Whether the user can add free-text alongside their selection (default: false)", })), commentPlaceholder: Type.Optional(Type.String({ description: "Placeholder text for the optional comment input", })), required: Type.Optional(Type.Boolean({ description: "Whether the user must select at least one option (default: true)", })), }); const AskUserParams = Type.Object({ questions: Type.Array(QuestionSchema, { description: "One or more questions to present to the user", minItems: 1, }), }); // ── Formatting ─────────────────────────────────────────────────────────── function formatAnswersForLLM(answers: Answer[], questions: Question[]): string { const lines: string[] = []; for (const answer of answers) { const q = questions.find((qu) => qu.id === answer.questionId); const label = q?.text ?? answer.questionId; if (answer.skipped) { lines.push(`## ${label}\n*Skipped*`); continue; } const selectedLabels = answer.selected.map((v) => { const opt = q?.options.find((o) => o.value === v); return opt ? `${opt.label} (${v})` : v; }); lines.push(`## ${label}`); if (selectedLabels.length === 1) { lines.push(`Selected: ${selectedLabels[0]}`); } else { lines.push(`Selected:\n${selectedLabels.map((s) => `- ${s}`).join("\n")}`); } if (answer.comment) { lines.push(`Comment: ${answer.comment}`); } lines.push(""); } return lines.join("\n").trim(); } // ── Rendering ──────────────────────────────────────────────────────────── function truncate(text: string, max = 60): string { return text.length > max ? text.slice(0, max - 1) + "…" : text; } function buildParamSummary(args: any, theme: any): string { const questions: Question[] = args.questions ?? []; const count = questions.length; const first = questions[0]?.text ? truncate(String(questions[0].text), 56) : "no question"; return ( theme.fg("accent", `${count} question${count === 1 ? "" : "s"}`) + theme.fg("borderMuted", " · ") + theme.fg("text", first) ); } function renderCall(args: any, theme: any, ctx: any): any { const state = ctx.state as { startedAt?: number }; if (state.startedAt === undefined) state.startedAt = Date.now(); return kylinFrameForCall(ctx, { name: "ask_user", theme, status: "pending", paramSummary: buildParamSummary(args, theme), startedAt: state.startedAt, }); } function renderResult(result: any, opts: any, theme: any, ctx: any): any { const details = result.details as AskUserDetails | undefined; const state = ctx.state as { startedAt?: number }; const startedAt = state.startedAt; const paramSummary = buildParamSummary(ctx.args, theme); const questionsForDisplay = Array.isArray(ctx.args?.questions) ? ctx.args.questions.flatMap((q: any, i: number) => { const lines: string[] = []; lines.push(`[${i + 1}] ${q?.id ?? "?"} (${q?.type ?? "?"})`); if (q?.text) lines.push(` text: ${String(q.text)}`); if (q?.allowComment) lines.push(" allowComment: true"); if (q?.commentPlaceholder) lines.push(` placeholder: ${String(q.commentPlaceholder)}`); if (Array.isArray(q?.options)) { for (const opt of q.options) { lines.push(` - ${opt?.value ?? "?"}: ${String(opt?.label ?? "")}`); } } return lines; }) : undefined; const expandedParams = opts.expanded ? buildExpandedParams([ { label: "questions", value: questionsForDisplay, multiline: true }, ], theme) : undefined; if (opts.isPartial) { return kylinFrameForResult(ctx, { name: "ask_user", theme, status: "pending", paramSummary, resultSummary: theme.fg("warning", "waiting"), expanded: opts.expanded, expandedParams, startedAt, }); } if (!details) { return kylinFrameForResult(ctx, { name: "ask_user", theme, status: "error", paramSummary, errorMessage: "no response", expanded: opts.expanded, expandedParams, startedAt, }); } if (details.phase === "cancelled") { return kylinFrameForResult(ctx, { name: "ask_user", theme, status: "success", paramSummary, resultSummary: theme.fg("warning", "cancelled"), expanded: opts.expanded, expandedParams, startedAt, }); } const answered = details.answers.filter((a: Answer) => !a.skipped).length; const skipped = details.answers.filter((a: Answer) => a.skipped).length; const parts = [theme.fg("success", `${answered} answered`)]; if (skipped > 0) parts.push(theme.fg("warning", `${skipped} skipped`)); return kylinFrameForResult(ctx, { name: "ask_user", theme, status: "success", paramSummary, resultSummary: parts.join(theme.fg("borderMuted", " · ")), expanded: opts.expanded, expandedParams, startedAt, }); } // ── Extension ──────────────────────────────────────────────────────────── export default function askUser(pi: ExtensionAPI) { pi.registerTool({ name: "ask_user", label: "Ask User", description: "Present interactive questions to the user with single-select or multi-select options. " + "Each question can optionally accept a free-text comment. " + "Use this when you need user input to make a decision, clarify requirements, " + "or confirm choices before proceeding.", promptSnippet: "Use ask_user to present structured questions with selectable options to the user. " + "Supports single-select, multi-select, and optional free-text comments per question.", promptGuidelines: [ "Use ask_user instead of asking open-ended questions in chat when the choices are enumerable.", "Keep questions concise. Use 2-6 options per question for best UX.", "Set allowComment: true when the user might need to add context beyond the predefined options.", "For yes/no questions, prefer ask_user with clear option labels over plain text questions.", ], parameters: AskUserParams, renderShell: "self", renderCall, renderResult, async execute(toolCallId, params, signal, onUpdate, ctx) { const questions: Question[] = params.questions; if (!ctx.hasUI) { return { content: [{ type: "text" as const, text: "Error: ask_user requires an interactive terminal (not available in RPC/print mode)." }], details: { phase: "cancelled", totalQuestions: questions.length, answeredCount: 0, answers: [], } as AskUserDetails, }; } onUpdate?.({ content: [{ type: "text" as const, text: "Waiting for user input..." }], details: { phase: "asking", totalQuestions: questions.length, answeredCount: 0, answers: [], } as AskUserDetails, }); playSound("question"); const answers = await promptQuestions(ctx.ui, questions, signal); const allCancelled = answers.length === 0 || answers.every((a) => a.skipped); const formatted = allCancelled ? "The user cancelled or skipped all questions." : formatAnswersForLLM(answers, questions); return { content: [{ type: "text" as const, text: formatted }], details: { phase: allCancelled ? "cancelled" : "done", totalQuestions: questions.length, answeredCount: answers.filter((a) => !a.skipped).length, answers, } as AskUserDetails, }; }, }); }