154 lines
5.5 KiB
TypeScript
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);
|
|
});
|
|
}
|