mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
feat(pi-ask-user): support RPC UI dialogs
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user