feat: add interactive ask-user extension

This commit is contained in:
云服务部-叶林立
2026-08-24 20:23:54 +08:00
parent e941e71ed8
commit 939139b5f3
21 changed files with 937 additions and 2 deletions
+101
View File
@@ -0,0 +1,101 @@
import type { NormalizedQuestion, QuestionInput, QuestionOptionInput, UserAnswer } from "./types.ts";
export const MAX_QUESTIONS = 8;
export const MAX_OPTIONS = 8;
function requiredText(value: unknown, field: string, maxLength: number): string {
if (typeof value !== "string") throw new Error(`${field} must be a string`);
const normalized = value.trim();
if (!normalized) throw new Error(`${field} must not be empty`);
if (normalized.length > maxLength) throw new Error(`${field} must be at most ${maxLength} characters`);
return normalized;
}
function normalizeOption(option: QuestionOptionInput, questionId: string, index: number): QuestionOptionInput {
return {
value: requiredText(option?.value, `questions[${questionId}].options[${index}].value`, 128),
label: requiredText(option?.label, `questions[${questionId}].options[${index}].label`, 160),
...(option?.description === undefined
? {}
: { description: requiredText(option.description, `questions[${questionId}].options[${index}].description`, 500) }),
};
}
export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQuestion[] {
if (!Array.isArray(input) || input.length === 0) throw new Error("questions must contain at least one question");
if (input.length > MAX_QUESTIONS) throw new Error(`questions must contain at most ${MAX_QUESTIONS} questions`);
const ids = new Set<string>();
return input.map((question, index) => {
if (question === null || typeof question !== "object" || Array.isArray(question)) {
throw new Error(`questions[${index}] must be an object`);
}
const id = requiredText(question?.id, `questions[${index}].id`, 64);
if (!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(id)) {
throw new Error(`questions[${index}].id must start with a letter and contain only letters, numbers, _ or -`);
}
if (ids.has(id)) throw new Error(`duplicate question id: ${id}`);
ids.add(id);
const prompt = requiredText(question?.prompt, `questions[${index}].prompt`, 1_000);
const label = question.label === undefined
? `Q${index + 1}`
: requiredText(question.label, `questions[${index}].label`, 48);
if (question.type !== "select" && question.type !== "text") {
throw new Error(`questions[${index}].type must be select or text`);
}
if (question.allowCustom !== undefined && typeof question.allowCustom !== "boolean") {
throw new Error(`questions[${index}].allowCustom must be a boolean`);
}
if (question.required !== undefined && typeof question.required !== "boolean") {
throw new Error(`questions[${index}].required must be a boolean`);
}
const rawOptions = question.options ?? [];
if (!Array.isArray(rawOptions)) throw new Error(`questions[${index}].options must be an array`);
if (rawOptions.length > MAX_OPTIONS) {
throw new Error(`questions[${index}].options must contain at most ${MAX_OPTIONS} options`);
}
if (question.type === "text" && rawOptions.length > 0) {
throw new Error(`text question ${id} must not define options`);
}
if (question.type === "select" && rawOptions.length === 0) {
throw new Error(`select question ${id} must define at least one option`);
}
const options = rawOptions.map((option, optionIndex) => normalizeOption(option, id, optionIndex));
const values = new Set<string>();
for (const option of options) {
if (values.has(option.value)) throw new Error(`duplicate option value ${option.value} in question ${id}`);
values.add(option.value);
}
return {
id,
label,
prompt,
type: question.type,
options,
allowCustom: question.type === "select" && question.allowCustom !== false,
required: question.required !== false,
};
});
}
export function orderedAnswers(questions: readonly NormalizedQuestion[], answers: ReadonlyMap<string, UserAnswer>): UserAnswer[] {
return questions.flatMap((question) => {
const answer = answers.get(question.id);
return answer ? [answer] : [];
});
}
export function formatAnswers(answers: readonly UserAnswer[]): string {
return answers
.map((answer) => {
if (answer.skipped) return `${answer.id}: skipped`;
if (answer.type === "text" || answer.custom) return `${answer.id}: user wrote: ${answer.answer}`;
return `${answer.id}: user selected: ${answer.label} (value: ${answer.value})`;
})
.join("\n");
}
+35
View File
@@ -0,0 +1,35 @@
import { Type } from "typebox";
import { MAX_OPTIONS, MAX_QUESTIONS } from "./normalize.ts";
const OptionSchema = Type.Object({
value: Type.String({ minLength: 1, maxLength: 128, description: "Stable machine-readable value returned when selected" }),
label: Type.String({ minLength: 1, maxLength: 160, description: "User-visible option label" }),
description: Type.Optional(Type.String({ minLength: 1, maxLength: 500, description: "Optional explanation shown below the label" })),
});
const QuestionSchema = Type.Object({
id: Type.String({
minLength: 1,
maxLength: 64,
pattern: "^[A-Za-z][A-Za-z0-9_-]*$",
description: "Unique machine-readable question identifier",
}),
label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short progress label; defaults to Q1, Q2, ..." })),
prompt: Type.String({ minLength: 1, maxLength: 1_000, description: "Full question shown to the user" }),
type: Type.String({ enum: ["select", "text"], description: "select for choices; text for a free-form answer" }),
options: Type.Optional(Type.Array(OptionSchema, {
maxItems: MAX_OPTIONS,
description: "Required and non-empty for select questions; omit for text questions",
})),
allowCustom: Type.Optional(Type.Boolean({ description: "For select questions, append a free-form answer choice (default: true)" })),
required: Type.Optional(Type.Boolean({ description: "Whether the question must be answered (default: true); optional questions can be skipped" })),
});
export const AskUserParamsSchema = Type.Object({
questions: Type.Array(QuestionSchema, {
minItems: 1,
maxItems: MAX_QUESTIONS,
description: "One or more questions to present in a single sequential interaction",
}),
});
+72
View File
@@ -0,0 +1,72 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { formatAnswers, normalizeQuestions } from "./normalize.ts";
import { AskUserParamsSchema } from "./schema.ts";
import type { AskUserResult, QuestionInput } from "./types.ts";
import { AskUserView } from "./view.ts";
function fallbackText(result: { content: Array<{ type: string; text?: string }> }): string {
return result.content.find((item) => item.type === "text")?.text ?? "";
}
export function registerAskUserTool(pi: ExtensionAPI): void {
pi.registerTool({
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.",
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 (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),
);
if (result.cancelled) {
return {
content: [{ type: "text", text: "User cancelled the questions" }],
details: result,
};
}
return {
content: [{ type: "text", text: formatAnswers(result.answers) }],
details: result,
};
},
renderCall(args, theme) {
const questions = Array.isArray(args.questions) ? args.questions as Array<{ id?: unknown; label?: unknown }> : [];
const labels = questions
.map((question) => typeof question.label === "string" ? question.label : typeof question.id === "string" ? question.id : "question")
.join(", ");
let text = theme.fg("toolTitle", theme.bold("ask user "));
text += theme.fg("muted", `${questions.length} question${questions.length === 1 ? "" : "s"}`);
if (labels) text += theme.fg("dim", ` (${labels})`);
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as AskUserResult | undefined;
if (!details) return new Text(fallbackText(result), 0, 0);
if (details.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
const lines = details.answers.map((answer) => {
const value = answer.skipped
? theme.fg("muted", "Skipped")
: `${answer.custom || answer.type === "text" ? theme.fg("muted", "(wrote) ") : ""}${theme.fg("accent", answer.label)}`;
return `${theme.fg("success", "✓ ")}${theme.fg("accent", answer.id)}: ${value}`;
});
return new Text(lines.join("\n"), 0, 0);
},
});
}
+49
View File
@@ -0,0 +1,49 @@
export type QuestionType = "select" | "text";
export interface QuestionOptionInput {
value: string;
label: string;
description?: string;
}
export interface QuestionInput {
id: string;
label?: string;
prompt: string;
type: QuestionType;
options?: QuestionOptionInput[];
allowCustom?: boolean;
required?: boolean;
}
export interface NormalizedQuestion {
id: string;
label: string;
prompt: string;
type: QuestionType;
options: QuestionOptionInput[];
allowCustom: boolean;
required: boolean;
}
/**
* `question` and `answer` intentionally match pi-permission-auto-review's
* trusted user-interaction envelope for ask_user_question.
*/
export interface UserAnswer {
id: string;
question: string;
type: QuestionType;
answer: string;
value: string;
label: string;
custom: boolean;
skipped: boolean;
}
export interface AskUserResult {
version: 1;
cancelled: boolean;
questions: NormalizedQuestion[];
answers: UserAnswer[];
}
+363
View File
@@ -0,0 +1,363 @@
import type { Theme } from "@earendil-works/pi-coding-agent";
import {
Editor,
type Component,
type EditorTheme,
type Focusable,
Key,
matchesKey,
type TUI,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import { orderedAnswers } from "./normalize.ts";
import type { AskUserResult, NormalizedQuestion, QuestionOptionInput, UserAnswer } from "./types.ts";
type InputKind = "text" | "custom";
type Done = (result: AskUserResult) => void;
type RenderOption = QuestionOptionInput & { kind: "option" | "custom" | "skip" };
export class AskUserView implements Component, Focusable {
private readonly editor: Editor;
private readonly answers = new Map<string, UserAnswer>();
private readonly abortHandler: () => void;
private currentPage = 0;
private optionIndex = 0;
private inputKind: InputKind | null = null;
private validationMessage: string | undefined;
private cachedWidth: number | undefined;
private cachedLines: string[] | undefined;
private completed = false;
private _focused = false;
constructor(
private readonly tui: TUI,
private readonly theme: Theme,
private readonly questions: NormalizedQuestion[],
private readonly done: Done,
private readonly signal?: AbortSignal,
) {
const editorTheme: EditorTheme = {
borderColor: (text) => theme.fg("accent", text),
selectList: {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
},
};
this.editor = new Editor(tui, editorTheme);
this.editor.onSubmit = (value) => this.submitEditor(value);
this.abortHandler = () => this.finish(true);
signal?.addEventListener("abort", this.abortHandler, { once: true });
}
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.syncEditorFocus();
}
private syncEditorFocus(): void {
this.editor.focused = this._focused && this.inputKind !== null;
}
private refresh(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
this.syncEditorFocus();
this.tui.requestRender();
}
private currentQuestion(): NormalizedQuestion | undefined {
return this.questions[this.currentPage];
}
private currentOptions(): RenderOption[] {
const question = this.currentQuestion();
if (!question || question.type !== "select") return [];
const options: RenderOption[] = question.options.map((option) => ({ ...option, kind: "option" }));
if (question.allowCustom) options.push({ value: "", label: "Type something.", kind: "custom" });
if (!question.required) options.push({ value: "", label: "Skip this question.", kind: "skip" });
return options;
}
private allAnswered(): boolean {
return this.questions.every((question) => this.answers.has(question.id));
}
private setCurrentPage(page: number): void {
this.currentPage = page;
this.inputKind = null;
this.validationMessage = undefined;
this.editor.setText("");
this.optionIndex = 0;
const question = this.currentQuestion();
const answer = question ? this.answers.get(question.id) : undefined;
if (question?.type === "select" && answer) {
const options = this.currentOptions();
const selected = options.findIndex((option) =>
answer.skipped ? option.kind === "skip" : answer.custom ? option.kind === "custom" : option.kind === "option" && option.value === answer.value,
);
this.optionIndex = Math.max(0, selected);
}
this.refresh();
}
private movePage(delta: number): void {
const pageCount = this.questions.length + 1;
this.setCurrentPage((this.currentPage + delta + pageCount) % pageCount);
}
private enterEditor(kind: InputKind): void {
const question = this.currentQuestion();
if (!question) return;
const existing = this.answers.get(question.id);
this.inputKind = kind;
this.validationMessage = undefined;
this.editor.setText(existing && (question.type === "text" || existing.custom) ? existing.answer : "");
this.refresh();
}
private saveAnswer(question: NormalizedQuestion, answer: Omit<UserAnswer, "id" | "question" | "type">): void {
this.answers.set(question.id, {
id: question.id,
question: question.prompt,
type: question.type,
...answer,
});
}
private submitEditor(value: string): void {
const question = this.currentQuestion();
if (!question || !this.inputKind) return;
const trimmed = value.trim();
if (!trimmed && (question.required || this.inputKind === "custom")) {
this.validationMessage = "An answer is required.";
this.refresh();
return;
}
this.saveAnswer(question, {
answer: trimmed,
value: trimmed,
label: trimmed,
custom: this.inputKind === "custom",
skipped: !trimmed,
});
this.inputKind = null;
this.editor.setText("");
this.advanceAfterAnswer();
}
private selectCurrentOption(): void {
const question = this.currentQuestion();
const option = this.currentOptions()[this.optionIndex];
if (!question || !option) return;
if (option.kind === "custom") {
this.enterEditor("custom");
return;
}
if (option.kind === "skip") {
this.saveAnswer(question, { answer: "", value: "", label: "Skipped", custom: false, skipped: true });
} else {
this.saveAnswer(question, {
answer: option.label,
value: option.value,
label: option.label,
custom: false,
skipped: false,
});
}
this.advanceAfterAnswer();
}
private advanceAfterAnswer(): void {
if (this.questions.length === 1) {
this.finish(false);
return;
}
this.setCurrentPage(this.currentPage < this.questions.length - 1 ? this.currentPage + 1 : this.questions.length);
}
private finish(cancelled: boolean): void {
if (this.completed) return;
this.completed = true;
this.signal?.removeEventListener("abort", this.abortHandler);
this.done({
version: 1,
cancelled,
questions: this.questions,
answers: orderedAnswers(this.questions, this.answers),
});
}
handleInput(data: string): void {
if (this.inputKind) {
if (matchesKey(data, Key.escape)) {
this.inputKind = null;
this.validationMessage = undefined;
this.editor.setText("");
this.refresh();
return;
}
this.editor.handleInput(data);
this.refresh();
return;
}
if (matchesKey(data, Key.escape)) {
this.finish(true);
return;
}
if (this.questions.length > 1) {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
this.movePage(1);
return;
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
this.movePage(-1);
return;
}
}
if (this.currentPage === this.questions.length) {
if (matchesKey(data, Key.enter) && this.allAnswered()) this.finish(false);
return;
}
const question = this.currentQuestion();
if (!question) return;
if (question.type === "text") {
if (!question.required && matchesKey(data, "s")) {
this.saveAnswer(question, { answer: "", value: "", label: "Skipped", custom: false, skipped: true });
this.advanceAfterAnswer();
} else if (matchesKey(data, Key.enter)) {
this.enterEditor("text");
}
return;
}
const options = this.currentOptions();
if (matchesKey(data, Key.up)) {
this.optionIndex = Math.max(0, this.optionIndex - 1);
this.refresh();
return;
}
if (matchesKey(data, Key.down)) {
this.optionIndex = Math.min(options.length - 1, this.optionIndex + 1);
this.refresh();
return;
}
if (matchesKey(data, Key.enter)) this.selectCurrentOption();
}
render(width: number): string[] {
const renderWidth = Math.max(1, width);
if (this.cachedLines && this.cachedWidth === renderWidth) return this.cachedLines;
const lines: string[] = [];
const question = this.currentQuestion();
const addWrapped = (text: string): void => {
lines.push(...wrapTextWithAnsi(text, renderWidth));
};
const addWrappedWithPrefix = (prefix: string, text: string): void => {
const prefixWidth = visibleWidth(prefix);
if (prefixWidth >= renderWidth) {
addWrapped(prefix + text);
return;
}
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
const continuation = " ".repeat(prefixWidth);
wrapped.forEach((line, index) => lines.push(`${index === 0 ? prefix : continuation}${line}`));
};
lines.push(this.theme.fg("accent", "─".repeat(renderWidth)));
if (this.questions.length > 1) {
const tabs = this.questions.map((item, index) => {
const answered = this.answers.has(item.id);
const text = ` ${answered ? "■" : "□"} ${item.label} `;
return index === this.currentPage
? this.theme.bg("selectedBg", this.theme.fg("text", text))
: this.theme.fg(answered ? "success" : "muted", text);
});
const reviewText = " ✓ Review ";
tabs.push(this.currentPage === this.questions.length
? this.theme.bg("selectedBg", this.theme.fg("text", reviewText))
: this.theme.fg(this.allAnswered() ? "success" : "dim", reviewText));
addWrappedWithPrefix(" ", tabs.join(" "));
lines.push("");
}
if (this.currentPage === this.questions.length) {
addWrappedWithPrefix(" ", this.theme.fg("accent", this.theme.bold("Review answers")));
lines.push("");
for (const item of this.questions) {
const answer = this.answers.get(item.id);
const value = !answer ? this.theme.fg("warning", "Unanswered") : answer.skipped ? this.theme.fg("muted", "Skipped") : this.theme.fg("text", answer.label);
addWrappedWithPrefix(" ", `${this.theme.fg("muted", `${item.label}: `)}${value}`);
}
lines.push("");
addWrappedWithPrefix(" ", this.allAnswered()
? this.theme.fg("success", "Press Enter to submit")
: this.theme.fg("warning", "Answer or explicitly skip every question before submitting"));
} else if (question) {
addWrappedWithPrefix(" ", this.theme.fg("text", question.prompt));
lines.push("");
if (question.type === "select") {
for (const [index, option] of this.currentOptions().entries()) {
const selected = index === this.optionIndex;
const prefix = selected ? this.theme.fg("accent", "> ") : " ";
addWrappedWithPrefix(prefix, this.theme.fg(selected ? "accent" : option.kind === "skip" ? "muted" : "text", `${index + 1}. ${option.label}`));
if (option.description) addWrappedWithPrefix(" ", this.theme.fg("muted", option.description));
}
} else if (!this.inputKind) {
const existing = this.answers.get(question.id);
if (existing) addWrappedWithPrefix(" ", `${this.theme.fg("muted", "Current answer: ")}${existing.skipped ? "Skipped" : existing.label}`);
const action = `${existing ? "Press Enter to edit" : "Press Enter to answer"}${question.required ? "" : " • s to skip"}`;
addWrappedWithPrefix(" ", this.theme.fg("accent", action));
}
if (this.inputKind) {
if (this.inputKind === "custom") lines.push("");
addWrappedWithPrefix(" ", this.theme.fg("muted", "Your answer:"));
for (const line of this.editor.render(Math.max(1, renderWidth - 2))) lines.push(` ${line}`);
if (this.validationMessage) addWrappedWithPrefix(" ", this.theme.fg("warning", this.validationMessage));
}
}
lines.push("");
const help = this.inputKind
? "Enter submit • Esc go back"
: this.currentPage === this.questions.length
? "Tab/←→ navigate • Enter submit • Esc cancel"
: question?.type === "text"
? `${question.required ? "Enter write" : "Enter write • s skip"} • Tab/←→ navigate • Esc cancel`
: "↑↓ select • Enter confirm • Tab/←→ navigate • Esc cancel";
addWrappedWithPrefix(" ", this.theme.fg("dim", this.questions.length === 1 ? help.replace(" • Tab/←→ navigate", "") : help));
lines.push(this.theme.fg("accent", "─".repeat(renderWidth)));
this.cachedWidth = renderWidth;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
this.editor.invalidate();
}
dispose(): void {
this.signal?.removeEventListener("abort", this.abortHandler);
}
}