fix(ask): always allow custom select answers

This commit is contained in:
云服务部-叶林立
2026-08-28 14:26:58 +08:00
parent 221e978622
commit d1b11f86f7
7 changed files with 19 additions and 14 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ A locally maintained Pi extension that consolidates the upstream `question.ts` a
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 in one sequential TUI interaction:
- `select`: one to eight stable `{ value, label, description? }` choices, with an optional free-form choice; - `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 Editor answer;
- `required: false`: lets the user explicitly skip the question; - `required: false`: lets the user explicitly skip the question;
- multiple questions: progress tabs plus a final review page. - multiple questions: progress tabs plus a final review page.
+1 -1
View File
@@ -7,6 +7,6 @@
Initial reference snapshot: `dcd461925db2edf69a43c8135db1180d418afd54` (`main`, inspected 2026-08-24). 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 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 copied MIT license and original copyright notice are retained in `LICENSE`. The copied MIT license and original copyright notice are retained in `LICENSE`.
-4
View File
@@ -45,9 +45,6 @@ export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQ
throw new Error(`questions[${index}].type must be select or 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") { if (question.required !== undefined && typeof question.required !== "boolean") {
throw new Error(`questions[${index}].required must be a boolean`); throw new Error(`questions[${index}].required must be a boolean`);
} }
@@ -77,7 +74,6 @@ export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQ
prompt, prompt,
type: question.type, type: question.type,
options, options,
allowCustom: question.type === "select" && question.allowCustom !== false,
required: question.required !== false, required: question.required !== false,
}; };
}); });
+1 -2
View File
@@ -20,9 +20,8 @@ const QuestionSchema = Type.Object({
type: Type.String({ enum: ["select", "text"], description: "select for choices; text for a free-form answer" }), type: Type.String({ enum: ["select", "text"], description: "select for choices; text for a free-form answer" }),
options: Type.Optional(Type.Array(OptionSchema, { options: Type.Optional(Type.Array(OptionSchema, {
maxItems: MAX_OPTIONS, maxItems: MAX_OPTIONS,
description: "Required and non-empty for select questions; omit for text questions", description: "Required and non-empty for select questions; omit for text questions. The tool always appends a free-form answer choice.",
})), })),
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" })), required: Type.Optional(Type.Boolean({ description: "Whether the question must be answered (default: true); optional questions can be skipped" })),
}); });
-2
View File
@@ -12,7 +12,6 @@ export interface QuestionInput {
prompt: string; prompt: string;
type: QuestionType; type: QuestionType;
options?: QuestionOptionInput[]; options?: QuestionOptionInput[];
allowCustom?: boolean;
required?: boolean; required?: boolean;
} }
@@ -22,7 +21,6 @@ export interface NormalizedQuestion {
prompt: string; prompt: string;
type: QuestionType; type: QuestionType;
options: QuestionOptionInput[]; options: QuestionOptionInput[];
allowCustom: boolean;
required: boolean; required: boolean;
} }
+1 -1
View File
@@ -82,7 +82,7 @@ export class AskUserView implements Component, Focusable {
const question = this.currentQuestion(); const question = this.currentQuestion();
if (!question || question.type !== "select") return []; if (!question || question.type !== "select") return [];
const options: RenderOption[] = question.options.map((option) => ({ ...option, kind: "option" })); const options: RenderOption[] = question.options.map((option) => ({ ...option, kind: "option" }));
if (question.allowCustom) options.push({ value: "", label: "Type something.", kind: "custom" }); options.push({ value: "", label: "Type something.", kind: "custom" });
if (!question.required) options.push({ value: "", label: "Skip this question.", kind: "skip" }); if (!question.required) options.push({ value: "", label: "Skip this question.", kind: "skip" });
return options; return options;
} }
+15 -3
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { formatAnswers, normalizeQuestions, orderedAnswers } from "../src/normalize.ts"; import { formatAnswers, normalizeQuestions, orderedAnswers } from "../src/normalize.ts";
import type { UserAnswer } from "../src/types.ts"; import type { QuestionInput, UserAnswer } from "../src/types.ts";
test("normalizes select and text questions with bounded defaults", () => { test("normalizes select and text questions with bounded defaults", () => {
const questions = normalizeQuestions([ const questions = normalizeQuestions([
@@ -27,14 +27,26 @@ test("normalizes select and text questions with bounded defaults", () => {
prompt: "Choose a language", prompt: "Choose a language",
type: "select", type: "select",
options: [{ value: "ts", label: "TypeScript" }], options: [{ value: "ts", label: "TypeScript" }],
allowCustom: true,
required: true, required: true,
}); });
assert.equal(questions[1]?.label, "Q2"); assert.equal(questions[1]?.label, "Q2");
assert.equal(questions[1]?.allowCustom, false);
assert.equal(questions[1]?.required, false); assert.equal(questions[1]?.required, false);
}); });
test("ignores the removed allowCustom input from legacy callers", () => {
const legacyQuestion = {
id: "language",
prompt: "Choose a language",
type: "select",
options: [{ value: "ts", label: "TypeScript" }],
allowCustom: false,
} satisfies QuestionInput & { allowCustom: boolean };
const [question] = normalizeQuestions([legacyQuestion]);
assert.ok(question);
assert.equal("allowCustom" in question, false);
});
test("rejects invalid ids, duplicate ids, invalid option combinations, and duplicate values", () => { 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([{ 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([null as never]), /must be an object/);