130 lines
4.1 KiB
TypeScript
130 lines
4.1 KiB
TypeScript
import { StringEnum } from "@mariozechner/pi-ai";
|
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
import { Type } from "@sinclair/typebox";
|
|
|
|
const AskUserParams = Type.Object({
|
|
mode: Type.Optional(
|
|
StringEnum(["input", "confirm", "select", "editor"] as const, {
|
|
description: "Question mode",
|
|
default: "input",
|
|
}),
|
|
),
|
|
question: Type.String({ description: "Question title to show the user" }),
|
|
message: Type.Optional(Type.String({ description: "Optional body text (confirm mode)" })),
|
|
placeholder: Type.Optional(Type.String({ description: "Placeholder text (input mode)" })),
|
|
options: Type.Optional(Type.Array(Type.String(), { description: "Options (select mode)" })),
|
|
prefill: Type.Optional(Type.String({ description: "Prefilled text (editor mode)" })),
|
|
});
|
|
|
|
function parseToolNames(raw: string | undefined): string[] {
|
|
if (!raw) return [];
|
|
const value = raw.trim();
|
|
if (!value) return [];
|
|
if (value.startsWith("[")) {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
if (Array.isArray(parsed)) {
|
|
return parsed.map((entry) => String(entry).trim()).filter(Boolean);
|
|
}
|
|
} catch {
|
|
// ignore invalid JSON and fall back to comma-separated parsing
|
|
}
|
|
}
|
|
return value
|
|
.split(",")
|
|
.map((tool) => tool.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export default function interactiveTools(pi: ExtensionAPI) {
|
|
pi.registerTool({
|
|
name: "ask_user",
|
|
label: "Ask User",
|
|
description:
|
|
"Ask the user an interactive clarification question. Supports input, confirm, select, and editor modes.",
|
|
parameters: AskUserParams,
|
|
promptGuidelines: [
|
|
"Use ask_user only when required information is missing or ambiguous.",
|
|
"Ask one focused question at a time and continue once answered.",
|
|
],
|
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
if (!ctx.hasUI) {
|
|
return {
|
|
content: [{ type: "text", text: "Error: ask_user requires an interactive UI." }],
|
|
isError: true,
|
|
details: { mode: params.mode ?? "input", cancelled: true },
|
|
};
|
|
}
|
|
|
|
const mode = params.mode ?? "input";
|
|
|
|
if (mode === "confirm") {
|
|
const confirmed = await ctx.ui.confirm(params.question, params.message ?? "Please confirm.");
|
|
return {
|
|
content: [{ type: "text", text: `User confirmation: ${confirmed ? "yes" : "no"}` }],
|
|
details: { mode, confirmed },
|
|
};
|
|
}
|
|
|
|
if (mode === "select") {
|
|
const options = (params.options ?? []).map((option) => option.trim()).filter(Boolean);
|
|
if (options.length === 0) {
|
|
return {
|
|
content: [{ type: "text", text: "Error: select mode requires a non-empty options array." }],
|
|
isError: true,
|
|
details: { mode, cancelled: true },
|
|
};
|
|
}
|
|
const value = await ctx.ui.select(params.question, options);
|
|
if (value === undefined) {
|
|
return {
|
|
content: [{ type: "text", text: "User cancelled the selection." }],
|
|
details: { mode, cancelled: true },
|
|
};
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: `User selected: ${value}` }],
|
|
details: { mode, value, cancelled: false },
|
|
};
|
|
}
|
|
|
|
if (mode === "editor") {
|
|
const value = await ctx.ui.editor(params.question, params.prefill ?? "");
|
|
if (value === undefined) {
|
|
return {
|
|
content: [{ type: "text", text: "User cancelled editor input." }],
|
|
details: { mode, cancelled: true },
|
|
};
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: `User input: ${value}` }],
|
|
details: { mode, value, cancelled: false },
|
|
};
|
|
}
|
|
|
|
const value = await ctx.ui.input(params.question, params.placeholder);
|
|
if (value === undefined) {
|
|
return {
|
|
content: [{ type: "text", text: "User cancelled input." }],
|
|
details: { mode: "input", cancelled: true },
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text: `User input: ${value}` }],
|
|
details: { mode: "input", value, cancelled: false },
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.on("session_start", () => {
|
|
const requestedTools = parseToolNames(process.env.PI_SUBAGENT_TOOLS);
|
|
if (requestedTools.length === 0) return;
|
|
|
|
const available = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
const validTools = requestedTools.filter((tool) => available.has(tool));
|
|
if (validTools.length > 0) {
|
|
pi.setActiveTools(validTools);
|
|
}
|
|
});
|
|
}
|