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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Mario Zechner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+60
View File
@@ -0,0 +1,60 @@
# pi-ask-user
A locally maintained Pi extension that consolidates the upstream `question.ts` and `questionnaire.ts` examples into one model-initiated tool. It intentionally does not include the `/qna` command.
## Tool
The extension registers `ask_user_question`. The model can present one to eight questions in one sequential TUI interaction:
- `select`: one to eight stable `{ value, label, description? }` choices, with an optional free-form choice;
- `text`: a free-form Editor answer;
- `required: false`: lets the user explicitly skip the question;
- multiple questions: progress tabs plus a final review page.
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
- `↑` / `↓`: move through choices;
- `Enter`: select, edit, or submit;
- `Tab`, `Shift+Tab`, `←`, `→`: move between questions and review;
- `Esc`: leave text entry, or cancel from a question/review page.
The outer component propagates focus to the embedded Editor for IME cursor positioning.
## Result contract
Successful results include readable text and structured `details`:
```json
{
"version": 1,
"cancelled": false,
"questions": [],
"answers": [
{
"id": "language",
"question": "Which language should be used?",
"type": "select",
"answer": "TypeScript",
"value": "typescript",
"label": "TypeScript",
"custom": false,
"skipped": false
}
]
}
```
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.
## Development
```sh
npm test
npm run check
```
See `UPSTREAM.md` for the imported reference snapshot and local differences.
+12
View File
@@ -0,0 +1,12 @@
# Upstream sources
`pi-ask-user` is maintained directly in this repository. Its initial UI and tool design was adapted from the following MIT-licensed Pi examples:
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/question.ts>
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/questionnaire.ts>
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, validation, bounded schemas, cancellation/abort handling, width-aware rendering, and IME focus propagation.
The copied MIT license and original copyright notice are retained in `LICENSE`.
+17
View File
@@ -0,0 +1,17 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerAskUserTool } from "./src/tool.ts";
export default function askUserExtension(pi: ExtensionAPI): void {
registerAskUserTool(pi);
}
export { formatAnswers, normalizeQuestions, orderedAnswers } from "./src/normalize.ts";
export type {
AskUserResult,
NormalizedQuestion,
QuestionInput,
QuestionOptionInput,
QuestionType,
UserAnswer,
} from "./src/types.ts";
+47
View File
@@ -0,0 +1,47 @@
{
"name": "pi-ask-user",
"version": "0.1.0",
"description": "A unified interactive question tool for Pi agents.",
"private": true,
"type": "module",
"license": "MIT",
"main": "./index.ts",
"exports": {
".": "./index.ts"
},
"files": [
"index.ts",
"src",
"README.md",
"LICENSE",
"UPSTREAM.md"
],
"pi": {
"extensions": [
"./index.ts"
]
},
"scripts": {
"test": "node --test test/*.test.ts",
"check": "node --experimental-strip-types --check index.ts && node --test test/*.test.ts"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
"@earendil-works/pi-tui": {
"optional": true
},
"typebox": {
"optional": true
}
},
"engines": {
"node": ">=22.19.0"
}
}
+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);
}
}
+86
View File
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatAnswers, normalizeQuestions, orderedAnswers } from "../src/normalize.ts";
import type { UserAnswer } from "../src/types.ts";
test("normalizes select and text questions with bounded defaults", () => {
const questions = normalizeQuestions([
{
id: "language",
label: " Language ",
prompt: " Choose a language ",
type: "select",
options: [{ value: "ts", label: " TypeScript " }],
},
{
id: "notes",
prompt: "Anything else?",
type: "text",
required: false,
},
]);
assert.deepEqual(questions[0], {
id: "language",
label: "Language",
prompt: "Choose a language",
type: "select",
options: [{ value: "ts", label: "TypeScript" }],
allowCustom: true,
required: true,
});
assert.equal(questions[1]?.label, "Q2");
assert.equal(questions[1]?.allowCustom, false);
assert.equal(questions[1]?.required, false);
});
test("rejects invalid ids, duplicate ids, invalid option combinations, and duplicate values", () => {
assert.throws(() => normalizeQuestions([{ id: "bad id", prompt: "Bad", type: "text" }]), /must start with a letter/);
assert.throws(() => normalizeQuestions([null as never]), /must be an object/);
assert.throws(() => normalizeQuestions([{ id: "bad_flag", prompt: "Bad", type: "text", required: "yes" as never }]), /must be a boolean/);
assert.throws(() => normalizeQuestions([
{ id: "same", prompt: "One", type: "text" },
{ id: "same", prompt: "Two", type: "text" },
]), /duplicate question id/);
assert.throws(() => normalizeQuestions([{ id: "pick", prompt: "Pick", type: "select" }]), /at least one option/);
assert.throws(() => normalizeQuestions([{ id: "text", prompt: "Write", type: "text", options: [{ value: "x", label: "X" }] }]), /must not define options/);
assert.throws(() => normalizeQuestions([{
id: "pick",
prompt: "Pick",
type: "select",
options: [{ value: "x", label: "X" }, { value: "x", label: "Again" }],
}]), /duplicate option value/);
});
test("orders and formats answers by question order", () => {
const questions = normalizeQuestions([
{ id: "first", prompt: "First?", type: "text" },
{ id: "second", prompt: "Second?", type: "select", options: [{ value: "yes", label: "Yes" }] },
{ id: "third", prompt: "Third?", type: "text", required: false },
]);
const answer = (id: string, overrides: Partial<UserAnswer>): UserAnswer => ({
id,
question: `${id}?`,
type: "text",
answer: id,
value: id,
label: id,
custom: false,
skipped: false,
...overrides,
});
const answers = new Map<string, UserAnswer>([
["third", answer("third", { answer: "", value: "", label: "Skipped", skipped: true })],
["second", answer("second", { type: "select", answer: "Yes", value: "yes", label: "Yes" })],
["first", answer("first", { answer: "hello", value: "hello", label: "hello", custom: true })],
]);
const ordered = orderedAnswers(questions, answers);
assert.deepEqual(ordered.map((item) => item.id), ["first", "second", "third"]);
assert.equal(formatAnswers(ordered), [
"first: user wrote: hello",
"second: user selected: Yes (value: yes)",
"third: skipped",
].join("\n"));
});