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
+8 -5
View File
@@ -4,16 +4,17 @@ A locally maintained Pi extension that consolidates the upstream `question.ts` a
## Tool
The extension registers `ask_user_question`. The model can present one to eight questions in one sequential TUI interaction:
The extension registers `ask_user_question`. The model can present one to eight questions through an interactive Pi UI:
- `select`: one to eight stable `{ value, label, description? }` choices, always followed by a free-form choice that the model cannot disable;
- `text`: a free-form Editor answer;
- `text`: a free-form answer;
- `required: false`: lets the user explicitly skip the question;
- multiple questions: progress tabs plus a final review page.
- TUI mode: one sequential custom interaction with progress tabs and a final review page;
- RPC mode: standard Pi `select` / `input` dialogs shown one question at a time by compatible clients such as Paseo.
The model should call the tool only when a missing decision, preference, or clarification is needed to continue. It should not repeat questions already answered in the user's direct messages.
## Controls
## TUI controls
- `↑` / `↓`: move through choices;
- `Enter`: select, edit, or submit;
@@ -22,6 +23,8 @@ The model should call the tool only when a missing decision, preference, or clar
The outer component propagates focus to the embedded Editor for IME cursor positioning.
RPC clients use their native dialog controls. Dismissing a dialog or aborting the tool returns `cancelled: true`; required blank text is requested again, while optional blank text is recorded as an explicit skip.
## Result contract
Successful results include readable text and structured `details`:
@@ -48,7 +51,7 @@ Successful results include readable text and structured `details`:
The tool name and the `question` / `answer` fields intentionally match `pi-permission-auto-review`'s trusted structured user-interaction envelope. Cancelled results are never treated as authorization evidence.
The tool requires Pi's interactive TUI mode. RPC, JSON, and print modes receive a tool error rather than an invented answer.
The tool requires dialog-capable interactive UI. TUI mode uses the custom questionnaire, RPC mode uses Pi's standard extension UI bridge, and JSON/print modes receive a tool error rather than an invented answer.
## Development
+1 -1
View File
@@ -7,6 +7,6 @@
Initial reference snapshot: `dcd461925db2edf69a43c8135db1180d418afd54` (`main`, inspected 2026-08-24).
The upstream `qna.ts` command is intentionally not included: this package only supports model-initiated questions. Local changes consolidate single and multi-question flows into one tool, add text questions, keep a user-controlled free-form escape hatch on every select question, validation, bounded schemas, cancellation/abort handling, width-aware rendering, and IME focus propagation.
The upstream `qna.ts` command is intentionally not included: this package only supports model-initiated questions. Local changes consolidate single and multi-question flows into one tool, add text questions, keep a user-controlled free-form escape hatch on every select question, validation, bounded schemas, cancellation/abort handling, width-aware rendering, IME focus propagation, and a standard Pi extension-UI fallback for dialog-capable RPC clients.
The copied MIT license and original copyright notice are retained in `LICENSE`.
+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 {
+124
View File
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeQuestions } from "../src/normalize.ts";
import { runRpcQuestions, type RpcQuestionUI } from "../src/rpc.ts";
function mockUi(responses: {
select?: Array<string | undefined>;
input?: Array<string | undefined>;
}): RpcQuestionUI & {
calls: Array<{ kind: "select" | "input"; title: string; options?: string[] }>;
notifications: Array<{ message: string; type?: "info" | "warning" | "error" }>;
} {
const selectResponses = [...(responses.select ?? [])];
const inputResponses = [...(responses.input ?? [])];
const calls: Array<{ kind: "select" | "input"; title: string; options?: string[] }> = [];
const notifications: Array<{ message: string; type?: "info" | "warning" | "error" }> = [];
return {
calls,
notifications,
async select(title, options) {
calls.push({ kind: "select", title, options });
assert.ok(selectResponses.length > 0, "unexpected select dialog");
return selectResponses.shift();
},
async input(title) {
calls.push({ kind: "input", title });
assert.ok(inputResponses.length > 0, "unexpected input dialog");
return inputResponses.shift();
},
notify(message, type) {
notifications.push({ message, type });
},
};
}
test("asks RPC questions sequentially and preserves stable option values", async () => {
const questions = normalizeQuestions([
{
id: "language",
label: "Language",
prompt: "Which language?",
type: "select",
options: [
{ value: "ts", label: "TypeScript", description: "Strict typing" },
{ value: "js", label: "JavaScript" },
],
},
{ id: "notes", prompt: "Anything else?", type: "text", required: false },
]);
const ui = mockUi({ select: ["2. JavaScript"], input: [" "] });
const result = await runRpcQuestions(ui, questions);
assert.equal(result.cancelled, false);
assert.deepEqual(result.answers, [
{
id: "language",
question: "Which language?",
type: "select",
answer: "JavaScript",
value: "js",
label: "JavaScript",
custom: false,
skipped: false,
},
{
id: "notes",
question: "Anything else?",
type: "text",
answer: "",
value: "",
label: "Skipped",
custom: false,
skipped: true,
},
]);
assert.match(ui.calls[0]?.title ?? "", /^\[1\/2\] Language:/);
assert.ok(ui.calls[0]?.options?.includes("1. TypeScript — Strict typing"));
});
test("supports custom RPC answers and repeats required blank input", async () => {
const questions = normalizeQuestions([{
id: "other",
prompt: "Choose or write one",
type: "select",
options: [{ value: "known", label: "Known" }],
}]);
const ui = mockUi({ select: ["Type something."], input: [" ", " Custom value "] });
const result = await runRpcQuestions(ui, questions);
assert.equal(result.cancelled, false);
assert.equal(result.answers[0]?.answer, "Custom value");
assert.equal(result.answers[0]?.custom, true);
assert.deepEqual(ui.notifications, [{ message: "An answer is required.", type: "warning" }]);
});
test("returns cancelled with partial answers when an RPC dialog is dismissed", async () => {
const questions = normalizeQuestions([
{ id: "name", prompt: "Name?", type: "text" },
{ id: "confirm", prompt: "Continue?", type: "select", options: [{ value: "yes", label: "Yes" }] },
]);
const ui = mockUi({ input: ["Ada"], select: [undefined] });
const result = await runRpcQuestions(ui, questions);
assert.equal(result.cancelled, true);
assert.equal(result.answers.length, 1);
assert.equal(result.answers[0]?.answer, "Ada");
});
test("does not open RPC dialogs after abort", async () => {
const questions = normalizeQuestions([{ id: "name", prompt: "Name?", type: "text" }]);
const ui = mockUi({});
const controller = new AbortController();
controller.abort();
const result = await runRpcQuestions(ui, questions, controller.signal);
assert.equal(result.cancelled, true);
assert.deepEqual(result.answers, []);
assert.deepEqual(ui.calls, []);
});