import { Type } from "@sinclair/typebox"; import type { ExtensionAPI, ExtensionContext, ToolResult } from "../_shared/pi-api.js"; import { errorResult, textResult } from "../_shared/pi-api.js"; import { validateParams } from "../_shared/validation.js"; import { redactForSensitivity } from "../_shared/redaction.js"; import { emitDevEvent } from "../_shared/event-bus.js"; const RECOMMENDED_SUFFIX = " (Recommended)"; const OTHER_OPTION = "Other (type your own)"; const DONE_OPTION = "Done selecting"; const CHECKED_PREFIX = "[x] "; const UNCHECKED_PREFIX = "[ ] "; const OmpAskOption = Type.Object({ label: Type.String({ description: "display label" }), }); const OmpAskQuestion = Type.Object({ id: Type.String({ description: "question id" }), question: Type.String({ description: "question text" }), options: Type.Array(OmpAskOption, { description: "available options" }), multi: Type.Optional(Type.Boolean({ description: "allow multiple selections" })), recommended: Type.Optional(Type.Number({ description: "recommended option index" })), }); const OmpAskParams = Type.Object({ questions: Type.Array(OmpAskQuestion, { minItems: 1, description: "questions to ask" }), }); const LegacyAskUserQuestionParams = Type.Object({ question: Type.String({ description: "The question to ask the user", maxLength: 500 }), kind: Type.Union([Type.Literal("select"), Type.Literal("multi-select"), Type.Literal("text"), Type.Literal("editor")], { description: "UI control type" }), options: Type.Optional(Type.Array(Type.String({ maxLength: 200 }), { maxItems: 20, description: "Choices for select/multi-select" })), allowCustom: Type.Optional(Type.Boolean({ default: false, description: "Allow a custom answer" })), default: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())])), timeoutMs: Type.Optional(Type.Number({ default: 30000, minimum: 1000, maximum: 300000, description: "Auto-cancel after this many ms" })), sensitivity: Type.Optional(Type.Union([Type.Literal("public"), Type.Literal("internal"), Type.Literal("secret")], { default: "internal" })), reason: Type.Optional(Type.String({ description: "Why this question is being asked", maxLength: 500 })), }); type LegacyAskKind = "select" | "multi-select" | "text" | "editor"; interface LegacyAskParams { question: string; kind: LegacyAskKind; options?: string[]; allowCustom?: boolean; default?: string | string[]; timeoutMs?: number; sensitivity?: "public" | "internal" | "secret"; reason?: string } interface OmpQuestion { id: string; question: string; options: Array<{ label: string }>; multi?: boolean; recommended?: number; } interface OmpAskParams { questions: OmpQuestion[]; } interface QuestionResult { id: string; question: string; options: string[]; multi: boolean; selectedOptions: string[]; customInput?: string; } interface AskSelection { selectedOptions: string[]; customInput?: string; cancelled: boolean; timedOut: boolean; } export default function askUserQuestion(pi: ExtensionAPI): void { pi.registerTool({ name: "ask", description: "Ask the interactive user one or more OMP-compatible option questions.", parameters: OmpAskParams, async execute(_toolCallId, params, signal, _update, ctx) { const valid = validateParams(OmpAskParams, params); if (!valid.ok) return valid.result; return askOmpCompatible(valid.value as OmpAskParams, ctx, signal); }, }); pi.registerTool({ name: "askUserQuestion", description: "Compatibility alias for the OMP-compatible ask tool.", parameters: LegacyAskUserQuestionParams, async execute(_toolCallId, params, signal, _update, ctx) { const valid = validateParams(LegacyAskUserQuestionParams, params); if (!valid.ok) return valid.result; return askLegacy(valid.value as LegacyAskParams, ctx, signal); }, }); } async function askOmpCompatible(params: OmpAskParams, ctx: ExtensionContext, signal: AbortSignal): Promise { if (params.questions.length === 0) return errorResult("Error: questions must not be empty"); const timeoutSetting = Number(ctx.settings.get("ask.timeout") ?? 0); const timeoutMs = Number.isFinite(timeoutSetting) && timeoutSetting > 0 ? timeoutSetting * 1000 : undefined; const results: QuestionResult[] = []; for (const [index, question] of params.questions.entries()) { const labels = question.options.map((option) => option.label); const title = params.questions.length > 1 ? `${question.question} (${index + 1}/${params.questions.length})` : question.question; const selection = await askSingleQuestion(ctx, { ...question, question: title }, labels, Boolean(question.multi), timeoutMs, signal); if (selection.cancelled && !selection.timedOut) return errorResult("Ask tool was cancelled by the user", { question: question.id }); results.push({ id: question.id, question: question.question, options: labels, multi: Boolean(question.multi), selectedOptions: selection.selectedOptions, ...(selection.customInput !== undefined ? { customInput: selection.customInput } : {}), }); } emitDevEvent("ask:answered", { questions: params.questions.length }); if (results.length === 1) { const result = results[0]!; return textResult(formatSingleAnswer(result), { question: result.question, options: result.options, multi: result.multi, selectedOptions: result.selectedOptions, ...(result.customInput !== undefined ? { customInput: result.customInput } : {}), }); } return textResult(`User answers:\n${results.map(formatQuestionLine).join("\n")}`, { results }); } async function askSingleQuestion( ctx: ExtensionContext, question: OmpQuestion, optionLabels: string[], multi: boolean, timeoutMs: number | undefined, signal: AbortSignal, ): Promise { if (multi) return askMultiQuestion(ctx, question, optionLabels, timeoutMs, signal); return askSingleSelectQuestion(ctx, question, optionLabels, timeoutMs, signal); } async function askSingleSelectQuestion( ctx: ExtensionContext, question: OmpQuestion, optionLabels: string[], timeoutMs: number | undefined, signal: AbortSignal, ): Promise { const shownLabels = addRecommendedSuffix(optionLabels, question.recommended); const choices = [...shownLabels, OTHER_OPTION]; const choice = await selectWithTimeout(ctx, question.question, choices, timeoutMs, signal); if (choice.timedOut) { return { selectedOptions: getAutoSelectionOnTimeout(optionLabels, question.recommended), cancelled: false, timedOut: true }; } if (choice.cancelled || choice.value === undefined) return { selectedOptions: [], cancelled: true, timedOut: false }; if (choice.value === OTHER_OPTION) { const custom = await promptCustomInput(ctx, signal); return custom === undefined ? { selectedOptions: [], cancelled: true, timedOut: false } : { selectedOptions: [], customInput: custom, cancelled: false, timedOut: false }; } return { selectedOptions: [stripRecommendedSuffix(choice.value)], cancelled: false, timedOut: false }; } async function askMultiQuestion( ctx: ExtensionContext, question: OmpQuestion, optionLabels: string[], timeoutMs: number | undefined, signal: AbortSignal, ): Promise { const selected = new Set(); while (true) { if (signal.aborted) return { selectedOptions: [...selected], cancelled: true, timedOut: false }; const choices = optionLabels.map((label) => `${selected.has(label) ? CHECKED_PREFIX : UNCHECKED_PREFIX}${label}`); if (selected.size > 0) choices.push(DONE_OPTION); choices.push(OTHER_OPTION); const prefix = selected.size > 0 ? `(${selected.size} selected) ` : ""; const choice = await selectWithTimeout(ctx, `${prefix}${question.question}`, choices, timeoutMs, signal); if (choice.timedOut) { return { selectedOptions: selected.size ? [...selected] : getAutoSelectionOnTimeout(optionLabels, question.recommended), cancelled: false, timedOut: true, }; } if (choice.cancelled || choice.value === undefined) return { selectedOptions: [...selected], cancelled: true, timedOut: false }; if (choice.value === DONE_OPTION) return { selectedOptions: [...selected], cancelled: false, timedOut: false }; if (choice.value === OTHER_OPTION) { const custom = await promptCustomInput(ctx, signal); return custom === undefined ? { selectedOptions: [...selected], cancelled: true, timedOut: false } : { selectedOptions: [], customInput: custom, cancelled: false, timedOut: false }; } const label = stripCheckboxPrefix(choice.value); if (label === undefined) continue; if (selected.has(label)) selected.delete(label); else selected.add(label); } } async function askLegacy(params: LegacyAskParams, ctx: ExtensionContext, signal: AbortSignal): Promise { if (params.kind === "text") { const result = await ctx.ui.input(promptWithReason(params), { default: asString(params.default) }); return legacyResult(params, result.value, result.cancelled ?? false, false); } if (params.kind === "editor") { const result = await ctx.ui.editor(promptWithReason(params), asString(params.default)); return legacyResult(params, result.value, result.cancelled ?? false, false); } const recommended = recommendedIndex(params); const question: OmpQuestion = { id: stableQuestionId(params.question), question: promptWithReason(params), options: (params.options ?? []).map((label) => ({ label })), multi: params.kind === "multi-select", }; if (recommended !== undefined) question.recommended = recommended; const converted: OmpAskParams = { questions: [question] }; const result = await askOmpCompatible(converted, ctx, signal); if (result.isError) return result; const details = result.details ?? {}; const value = params.kind === "multi-select" ? details.selectedOptions as string[] : firstLegacyValue(details); return legacyResult(params, value, false, false); } function promptWithReason(params: LegacyAskParams): string { return params.reason ? `${params.question}\n\nReason: ${params.reason}` : params.question; } function legacyResult(params: LegacyAskParams, value: string | string[], cancelled: boolean, timedOut: boolean): ToolResult { const visibleAnswer = Array.isArray(value) ? value.map((item) => redactForSensitivity(item, params.sensitivity).text) : redactForSensitivity(value, params.sensitivity).text; emitDevEvent("ask:answered", { kind: params.kind, cancelled, sensitivity: params.sensitivity ?? "internal" }); return textResult(cancelled ? "Question cancelled" : `Answer: ${Array.isArray(visibleAnswer) ? visibleAnswer.join(", ") : visibleAnswer}`, { questionId: stableQuestionId(params.question), kind: params.kind, value: params.sensitivity === "secret" ? undefined : value, visibleValue: visibleAnswer, cancelled, timedOut, sensitivity: params.sensitivity ?? "internal", }); } async function selectWithTimeout( ctx: ExtensionContext, title: string, labels: string[], timeoutMs: number | undefined, signal: AbortSignal, ): Promise<{ value?: string; cancelled: boolean; timedOut: boolean }> { const select = ctx.ui.select(title, labels.map((label) => ({ label, value: label }))); const result = await raceWithTimeout(select, timeoutMs, signal); if (result.timedOut) return { cancelled: false, timedOut: true }; if (result.aborted) return { cancelled: true, timedOut: false }; if (result.value === undefined) return { cancelled: true, timedOut: false }; return { value: result.value.value, cancelled: result.value.cancelled ?? false, timedOut: false }; } async function promptCustomInput(ctx: ExtensionContext, signal: AbortSignal): Promise { if (signal.aborted) return undefined; const result = await ctx.ui.editor("Enter your response:", "", { language: "markdown" }); return result.cancelled ? undefined : result.value; } async function raceWithTimeout( promise: Promise, timeoutMs: number | undefined, signal: AbortSignal, ): Promise<{ value?: T; timedOut: boolean; aborted: boolean }> { if (signal.aborted) return { timedOut: false, aborted: true }; let timeout: ReturnType | undefined; let abort: (() => void) | undefined; try { return await Promise.race([ promise.then((value) => ({ value, timedOut: false, aborted: false })), new Promise<{ timedOut: boolean; aborted: boolean }>((resolve) => { if (timeoutMs !== undefined) timeout = setTimeout(() => resolve({ timedOut: true, aborted: false }), timeoutMs); abort = () => resolve({ timedOut: false, aborted: true }); signal.addEventListener("abort", abort, { once: true }); }), ]); } finally { if (timeout) clearTimeout(timeout); if (abort) signal.removeEventListener("abort", abort); } } function addRecommendedSuffix(labels: string[], recommendedIndex?: number): string[] { if (recommendedIndex === undefined || recommendedIndex < 0 || recommendedIndex >= labels.length) return labels; return labels.map((label, index) => index === recommendedIndex && !label.endsWith(RECOMMENDED_SUFFIX) ? `${label}${RECOMMENDED_SUFFIX}` : label); } function stripRecommendedSuffix(label: string): string { return label.endsWith(RECOMMENDED_SUFFIX) ? label.slice(0, -RECOMMENDED_SUFFIX.length) : label; } function stripCheckboxPrefix(label: string): string | undefined { if (label.startsWith(CHECKED_PREFIX)) return label.slice(CHECKED_PREFIX.length); if (label.startsWith(UNCHECKED_PREFIX)) return label.slice(UNCHECKED_PREFIX.length); return undefined; } function getAutoSelectionOnTimeout(optionLabels: string[], recommended?: number): string[] { if (optionLabels.length === 0) return []; if (typeof recommended === "number" && recommended >= 0 && recommended < optionLabels.length) return [optionLabels[recommended]!]; return [optionLabels[0]!]; } function formatSingleAnswer(result: QuestionResult): string { const lines: string[] = []; if (result.selectedOptions.length > 0) lines.push(`User selected: ${result.selectedOptions.join(", ")}`); if (result.customInput !== undefined) { lines.push(result.customInput.includes("\n") ? `User provided custom input:\n${indentMultiline(result.customInput)}` : `User provided custom input: ${result.customInput}`); } return lines.join("\n") || "User answered with no selection."; } function formatQuestionLine(result: QuestionResult): string { if (result.customInput !== undefined) return `${result.id}: "${result.customInput}"`; if (result.selectedOptions.length > 0) { return result.multi ? `${result.id}: [${result.selectedOptions.join(", ")}]` : `${result.id}: ${result.selectedOptions[0]}`; } return `${result.id}: (cancelled)`; } function indentMultiline(text: string): string { return text.split(/\r?\n/).map((line) => ` ${line}`).join("\n"); } function recommendedIndex(params: LegacyAskParams): number | undefined { const defaultValue = Array.isArray(params.default) ? params.default[0] : params.default; if (!defaultValue) return undefined; const index = (params.options ?? []).indexOf(defaultValue); return index >= 0 ? index : undefined; } function firstLegacyValue(details: Record): string { const selected = details.selectedOptions; if (Array.isArray(selected) && typeof selected[0] === "string") return selected[0]; return typeof details.customInput === "string" ? details.customInput : ""; } function asString(value: string | string[] | undefined): string { return Array.isArray(value) ? value.join(", ") : value ?? ""; } function stableQuestionId(question: string): string { let hash = 2166136261; for (let index = 0; index < question.length; index += 1) { hash ^= question.charCodeAt(index); hash = Math.imul(hash, 16777619); } return `q_${(hash >>> 0).toString(16)}`; }