Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi
This commit is contained in:
153
extensions/user-question.ts
Normal file
153
extensions/user-question.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user