import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type, type Static } from "@sinclair/typebox"; import { OTHER_OPTION, type AskQuestion } from "./ask-logic"; import { askSingleQuestionWithInlineNote } from "./ask-inline-ui"; import { askQuestionsWithTabs } from "./ask-tabs-ui"; const OptionItemSchema = Type.Object({ label: Type.String({ description: "Display label" }), }); const QuestionItemSchema = Type.Object({ id: Type.String({ description: "Question id (e.g. auth, cache, priority)" }), question: Type.String({ description: "Question text" }), options: Type.Array(OptionItemSchema, { description: "Available options. Do not include 'Other'.", minItems: 1, }), multi: Type.Optional(Type.Boolean({ description: "Allow multi-select" })), recommended: Type.Optional( Type.Number({ description: "0-indexed recommended option. '(Recommended)' is shown automatically." }), ), }); const AskParamsSchema = Type.Object({ questions: Type.Array(QuestionItemSchema, { description: "Questions to ask", minItems: 1 }), }); type AskParams = Static; interface QuestionResult { id: string; question: string; options: string[]; multi: boolean; selectedOptions: string[]; customInput?: string; } interface AskToolDetails { id?: string; question?: string; options?: string[]; multi?: boolean; selectedOptions?: string[]; customInput?: string; results?: QuestionResult[]; } function sanitizeForSessionText(value: string): string { return value .replace(/[\r\n\t]/g, " ") .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "") .replace(/\s{2,}/g, " ") .trim(); } function sanitizeOptionForSessionText(option: string): string { const sanitizedOption = sanitizeForSessionText(option); return sanitizedOption.length > 0 ? sanitizedOption : "(empty option)"; } function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult { const selectedOptions = result.selectedOptions .map((selectedOption) => sanitizeForSessionText(selectedOption)) .filter((selectedOption) => selectedOption.length > 0); const rawCustomInput = result.customInput; const customInput = rawCustomInput == null ? undefined : sanitizeForSessionText(rawCustomInput); return { id: sanitizeForSessionText(result.id) || "(unknown)", question: sanitizeForSessionText(result.question) || "(empty question)", options: result.options.map(sanitizeOptionForSessionText), multi: result.multi, selectedOptions, customInput: customInput && customInput.length > 0 ? customInput : undefined, }; } function formatSelectionForSummary(result: QuestionResult): string { const hasSelectedOptions = result.selectedOptions.length > 0; const hasCustomInput = Boolean(result.customInput); if (!hasSelectedOptions && !hasCustomInput) { return "(cancelled)"; } if (hasSelectedOptions && hasCustomInput) { const selectedPart = result.multi ? `[${result.selectedOptions.join(", ")}]` : result.selectedOptions[0]; return `${selectedPart} + Other: "${result.customInput}"`; } if (hasCustomInput) { return `"${result.customInput}"`; } if (result.multi) { return `[${result.selectedOptions.join(", ")}]`; } return result.selectedOptions[0]; } function formatQuestionResult(result: QuestionResult): string { return `${result.id}: ${formatSelectionForSummary(result)}`; } function formatQuestionContext(result: QuestionResult, questionIndex: number): string { const lines: string[] = [ `Question ${questionIndex + 1} (${result.id})`, `Prompt: ${result.question}`, "Options:", ...result.options.map((option, optionIndex) => ` ${optionIndex + 1}. ${option}`), "Response:", ]; const hasSelectedOptions = result.selectedOptions.length > 0; const hasCustomInput = Boolean(result.customInput); if (!hasSelectedOptions && !hasCustomInput) { lines.push(" Selected: (cancelled)"); return lines.join("\n"); } if (hasSelectedOptions) { const selectedText = result.multi ? `[${result.selectedOptions.join(", ")}]` : result.selectedOptions[0]; lines.push(` Selected: ${selectedText}`); } if (hasCustomInput) { if (!hasSelectedOptions) { lines.push(` Selected: ${OTHER_OPTION}`); } lines.push(` Custom input: ${result.customInput}`); } return lines.join("\n"); } function buildAskSessionContent(results: QuestionResult[]): string { const safeResults = results.map(toSessionSafeQuestionResult); const summaryLines = safeResults.map(formatQuestionResult).join("\n"); const contextBlocks = safeResults.map((result, index) => formatQuestionContext(result, index)).join("\n\n"); return `User answers:\n${summaryLines}\n\nAnswer context:\n${contextBlocks}`; } const ASK_TOOL_DESCRIPTION = ` Ask the user for clarification when a choice materially affects the outcome. - Use when multiple valid approaches have different trade-offs. - Prefer 2-5 concise options. - Use multi=true when multiple answers are valid. - Use recommended= (0-indexed) to mark the default option. - You can ask multiple related questions in one call using questions[]. - Do NOT include an 'Other' option; UI adds it automatically. `.trim(); export default function askExtension(pi: ExtensionAPI) { pi.registerTool({ name: "ask", label: "Ask", description: ASK_TOOL_DESCRIPTION, parameters: AskParamsSchema, async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) { if (!ctx.hasUI) { return { content: [{ type: "text", text: "Error: ask tool requires interactive mode" }], details: {}, }; } if (params.questions.length === 0) { return { content: [{ type: "text", text: "Error: questions must not be empty" }], details: {}, }; } if (params.questions.length === 1) { const [q] = params.questions; const selection = q.multi ? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] } : await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion); const optionLabels = q.options.map((option) => option.label); const result: QuestionResult = { id: q.id, question: q.question, options: optionLabels, multi: q.multi ?? false, selectedOptions: selection.selectedOptions, customInput: selection.customInput, }; const details: AskToolDetails = { id: q.id, question: q.question, options: optionLabels, multi: q.multi ?? false, selectedOptions: selection.selectedOptions, customInput: selection.customInput, results: [result], }; return { content: [{ type: "text", text: buildAskSessionContent([result]) }], details, }; } const results: QuestionResult[] = []; const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]); for (let i = 0; i < params.questions.length; i++) { const q = params.questions[i]; const selection = tabResult.selections[i] ?? { selectedOptions: [] }; results.push({ id: q.id, question: q.question, options: q.options.map((option) => option.label), multi: q.multi ?? false, selectedOptions: selection.selectedOptions, customInput: selection.customInput, }); } return { content: [{ type: "text", text: buildAskSessionContent(results) }], details: { results } satisfies AskToolDetails, }; }, }); }