feat(pi-ask-user): support RPC UI dialogs

This commit is contained in:
云服务部-叶林立
2026-09-01 11:04:31 +08:00
parent e80959cc3f
commit 44f27f7585
8 changed files with 291 additions and 17 deletions
+142
View File
@@ -0,0 +1,142 @@
import type { NormalizedQuestion, QuestionOptionInput, UserAnswer, AskUserResult } from "./types.ts";
interface RpcDialogOptions {
signal?: AbortSignal;
}
export interface RpcQuestionUI {
select(title: string, options: string[], opts?: RpcDialogOptions): Promise<string | undefined>;
input(title: string, placeholder?: string, opts?: RpcDialogOptions): Promise<string | undefined>;
notify(message: string, type?: "info" | "warning" | "error"): void;
}
type RpcChoice =
| { kind: "option"; display: string; option: QuestionOptionInput }
| { kind: "custom"; display: string }
| { kind: "skip"; display: string };
type TextResponse =
| { kind: "answer"; text: string }
| { kind: "skipped" }
| { kind: "cancelled" };
function dialogTitle(question: NormalizedQuestion, index: number, total: number): string {
const progress = total > 1 ? `[${index + 1}/${total}] ` : "";
return `${progress}${question.label}: ${question.prompt}`;
}
function choicesFor(question: NormalizedQuestion): RpcChoice[] {
const choices: RpcChoice[] = question.options.map((option, index) => ({
kind: "option",
display: `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`,
option,
}));
choices.push({ kind: "custom", display: "Type something." });
if (!question.required) choices.push({ kind: "skip", display: "Skip this question." });
return choices;
}
function saveTextAnswer(question: NormalizedQuestion, text: string, custom: boolean): UserAnswer {
return {
id: question.id,
question: question.prompt,
type: question.type,
answer: text,
value: text,
label: text,
custom,
skipped: false,
};
}
function saveSkippedAnswer(question: NormalizedQuestion): UserAnswer {
return {
id: question.id,
question: question.prompt,
type: question.type,
answer: "",
value: "",
label: "Skipped",
custom: false,
skipped: true,
};
}
function cancelledResult(questions: NormalizedQuestion[], answers: UserAnswer[]): AskUserResult {
return { version: 1, cancelled: true, questions, answers };
}
async function requestText(
ui: RpcQuestionUI,
title: string,
required: boolean,
placeholder: string,
signal?: AbortSignal,
): Promise<TextResponse> {
while (true) {
if (signal?.aborted) return { kind: "cancelled" };
const response = await ui.input(title, placeholder, { signal });
if (signal?.aborted || response === undefined) return { kind: "cancelled" };
const text = response.trim();
if (text) return { kind: "answer", text };
if (!required) return { kind: "skipped" };
ui.notify("An answer is required.", "warning");
}
}
export async function runRpcQuestions(
ui: RpcQuestionUI,
questions: NormalizedQuestion[],
signal?: AbortSignal,
): Promise<AskUserResult> {
const answers: UserAnswer[] = [];
for (const [index, question] of questions.entries()) {
if (signal?.aborted) return cancelledResult(questions, answers);
const title = dialogTitle(question, index, questions.length);
if (question.type === "text") {
const response = await requestText(
ui,
title,
question.required,
question.required ? "Type your answer" : "Type an answer, or leave blank to skip",
signal,
);
if (response.kind === "cancelled") return cancelledResult(questions, answers);
answers.push(response.kind === "skipped" ? saveSkippedAnswer(question) : saveTextAnswer(question, response.text, false));
continue;
}
const choices = choicesFor(question);
const selected = await ui.select(title, choices.map((choice) => choice.display), { signal });
if (signal?.aborted || selected === undefined) return cancelledResult(questions, answers);
const choice = choices.find((item) => item.display === selected);
if (!choice) throw new Error("RPC UI returned an unknown choice");
if (choice.kind === "skip") {
answers.push(saveSkippedAnswer(question));
continue;
}
if (choice.kind === "custom") {
const response = await requestText(ui, `${title} — custom answer`, true, "Type a different answer", signal);
if (response.kind !== "answer") return cancelledResult(questions, answers);
answers.push(saveTextAnswer(question, response.text, true));
continue;
}
answers.push({
id: question.id,
question: question.prompt,
type: question.type,
answer: choice.option.label,
value: choice.option.value,
label: choice.option.label,
custom: false,
skipped: false,
});
}
return { version: 1, cancelled: false, questions, answers };
}
+10 -5
View File
@@ -3,6 +3,7 @@ import { Text } from "@earendil-works/pi-tui";
import { formatAnswers, normalizeQuestions } from "./normalize.ts";
import { AskUserParamsSchema } from "./schema.ts";
import { runRpcQuestions } from "./rpc.ts";
import type { AskUserResult, QuestionInput } from "./types.ts";
import { AskUserView } from "./view.ts";
@@ -15,23 +16,27 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
name: "ask_user_question",
label: "Ask User",
description:
"Ask the user one or more structured questions in an interactive TUI when their input is genuinely needed to continue. Supports choices, free-form text, optional custom answers, review, and cancellation. Do not ask for information already stated by the user.",
"Ask the user one or more structured questions in an interactive UI when their input is genuinely needed to continue. Supports TUI and RPC clients, choices, free-form text, optional custom answers, and cancellation. Do not ask for information already stated by the user.",
promptSnippet: "Use ask_user_question to pause and collect missing user decisions instead of guessing",
parameters: AskUserParamsSchema,
executionMode: "sequential",
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const questions = normalizeQuestions(params.questions as QuestionInput[]);
if (ctx.mode !== "tui") throw new Error("ask_user_question requires interactive TUI mode");
if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
throw new Error("ask_user_question requires interactive TUI or RPC UI mode");
}
if (signal?.aborted) {
const result: AskUserResult = { version: 1, cancelled: true, questions, answers: [] };
return { content: [{ type: "text", text: "User interaction was cancelled" }], details: result };
}
const result = await ctx.ui.custom<AskUserResult>((tui, theme, _keybindings, done) =>
new AskUserView(tui, theme, questions, done, signal),
);
const result = ctx.mode === "tui"
? await ctx.ui.custom<AskUserResult>((tui, theme, _keybindings, done) =>
new AskUserView(tui, theme, questions, done, signal),
)
: await runRpcQuestions(ctx.ui, questions, signal);
if (result.cancelled) {
return {