Files
dotfiles/pi/.pi/agent/extensions/pi-ask-tool/index.ts
2026-03-19 07:58:49 +01:00

238 lines
7.2 KiB
TypeScript

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<typeof AskParamsSchema>;
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=<index> (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,
};
},
});
}