Files
pi-skill/extensions/user-question.ts
2026-05-25 16:41:08 +07:00

154 lines
5.5 KiB
TypeScript

// ABOUTME: User Question — Interactive UI tool for agent-to-user communication
// ABOUTME: Three inline modes: select (pick from list), input (free text), confirm (yes/no)
import { StringEnum } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import {
Text,
} from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import { outputLine } from "./lib/output-box.ts";
import { buildAskUserDetails, type AskUserDetails } from "./lib/ask-user-details.ts";
import { applyExtensionDefaults } from "./lib/themeMap.ts";
// ── Tool Parameters ────────────────────────────────────────────────────
const AskUserParams = Type.Object({
question: Type.String({ description: "The question to ask the user" }),
mode: StringEnum(["select", "input", "confirm"] as const),
options: Type.Optional(Type.Array(Type.Object({
label: Type.String({ description: "Option label shown in the list" }),
markdown: Type.Optional(Type.String({ description: "Markdown preview shown when this option is highlighted" })),
}), { description: "Options for select mode (required)" })),
placeholder: Type.Optional(Type.String({ description: "Placeholder text for input mode" })),
detail: Type.Optional(Type.String({ description: "Detail text for confirm mode" })),
});
// ── Extension ──────────────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "ask_user",
label: "Ask User",
description:
"Ask the user a question with inline interactive UI. " +
"Three modes: 'select' shows an inline picker with options. " +
"'input' prompts for free-text entry. 'confirm' asks a yes/no question. " +
"For select mode, provide options[] with label and optional markdown for each.",
parameters: AskUserParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { question, mode, options, placeholder, detail } = params;
if (mode === "select") {
if (!options || options.length === 0) {
return {
content: [{ type: "text" as const, text: "Error: options[] required for select mode" }],
};
}
const labels = options.map((o) => o.label);
const result = await ctx.ui.select(question, labels);
if (result == null) {
return {
content: [{ type: "text" as const, text: "[User cancelled]" }],
details: buildAskUserDetails({ mode, question, cancelled: true }),
};
}
const opt = options.find((o) => o.label === result);
return {
content: [{ type: "text" as const, text: `User selected: ${result}` }],
details: buildAskUserDetails({
mode, question, answer: result,
selectedMarkdown: opt?.markdown,
}),
};
}
if (mode === "input") {
const answer = await ctx.ui.input(question, placeholder || "");
if (!answer) {
return {
content: [{ type: "text" as const, text: "[User cancelled]" }],
details: buildAskUserDetails({ mode, question, cancelled: true }),
};
}
return {
content: [{ type: "text" as const, text: `User answered: ${answer}` }],
details: buildAskUserDetails({ mode, question, answer }),
};
}
if (mode === "confirm") {
const confirmed = await ctx.ui.confirm(
question,
detail || "",
{ timeout: 60000 },
);
return {
content: [{ type: "text" as const, text: confirmed ? "User confirmed: Yes" : "User declined: No" }],
details: buildAskUserDetails({ mode, question, answer: confirmed ? "Yes" : "No" }),
};
}
return {
content: [{ type: "text" as const, text: `Error: unknown mode '${mode}'` }],
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("ask_user "));
text += theme.fg("muted", args.mode || "");
text += theme.fg("dim", ` "${args.question}"`);
if (args.mode === "select" && args.options?.length) {
text += theme.fg("dim", ` ${args.options.length} options`);
}
return new Text(outputLine(theme, "accent", text), 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as AskUserDetails | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.cancelled) {
return new Text(outputLine(theme, "dim", "[Cancelled]"), 0, 0);
}
if (details.mode === "confirm") {
const color = details.answer === "Yes" ? "success" : "warning";
const bar = details.answer === "Yes" ? "success" : "warning";
const label = details.answer === "Yes" ? "Confirmed" : "Declined";
return new Text(outputLine(theme, bar, label), 0, 0);
}
// select or input
const summary = details.mode === "select"
? `Selected: ${details.answer}`
: `Answer: ${details.answer}`;
if (expanded && details.selectedMarkdown) {
// Show summary + markdown preview as plain text lines
const preview = details.selectedMarkdown
.split("\n")
.slice(0, 8)
.map((l) => theme.fg("muted", " " + l))
.join("\n");
return new Text(
outputLine(theme, "accent", summary) + "\n" + preview,
0, 0,
);
}
return new Text(outputLine(theme, "accent", summary), 0, 0);
},
});
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
});
}