diff --git a/pi-ask-user/README.md b/pi-ask-user/README.md index fa64524..cc8b4cb 100644 --- a/pi-ask-user/README.md +++ b/pi-ask-user/README.md @@ -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: -- `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; - `required: false`: lets the user explicitly skip the question; - multiple questions: progress tabs plus a final review page. diff --git a/pi-ask-user/UPSTREAM.md b/pi-ask-user/UPSTREAM.md index 2157dd5..2d0179d 100644 --- a/pi-ask-user/UPSTREAM.md +++ b/pi-ask-user/UPSTREAM.md @@ -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, 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`. diff --git a/pi-ask-user/src/normalize.ts b/pi-ask-user/src/normalize.ts index 05ebdcf..5ea6cb0 100644 --- a/pi-ask-user/src/normalize.ts +++ b/pi-ask-user/src/normalize.ts @@ -45,9 +45,6 @@ export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQ 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`); } @@ -77,7 +74,6 @@ export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQ prompt, type: question.type, options, - allowCustom: question.type === "select" && question.allowCustom !== false, required: question.required !== false, }; }); diff --git a/pi-ask-user/src/schema.ts b/pi-ask-user/src/schema.ts index 0e9801d..a4a1981 100644 --- a/pi-ask-user/src/schema.ts +++ b/pi-ask-user/src/schema.ts @@ -20,9 +20,8 @@ const QuestionSchema = Type.Object({ 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", + 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" })), }); diff --git a/pi-ask-user/src/types.ts b/pi-ask-user/src/types.ts index 3f2543f..25f7c75 100644 --- a/pi-ask-user/src/types.ts +++ b/pi-ask-user/src/types.ts @@ -12,7 +12,6 @@ export interface QuestionInput { prompt: string; type: QuestionType; options?: QuestionOptionInput[]; - allowCustom?: boolean; required?: boolean; } @@ -22,7 +21,6 @@ export interface NormalizedQuestion { prompt: string; type: QuestionType; options: QuestionOptionInput[]; - allowCustom: boolean; required: boolean; } diff --git a/pi-ask-user/src/view.ts b/pi-ask-user/src/view.ts index f7a9b35..13c7afa 100644 --- a/pi-ask-user/src/view.ts +++ b/pi-ask-user/src/view.ts @@ -82,7 +82,7 @@ export class AskUserView implements Component, Focusable { 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" }); + options.push({ value: "", label: "Type something.", kind: "custom" }); if (!question.required) options.push({ value: "", label: "Skip this question.", kind: "skip" }); return options; } diff --git a/pi-ask-user/test/normalize.test.ts b/pi-ask-user/test/normalize.test.ts index 74128d9..ea6ff35 100644 --- a/pi-ask-user/test/normalize.test.ts +++ b/pi-ask-user/test/normalize.test.ts @@ -2,7 +2,7 @@ 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"; +import type { QuestionInput, UserAnswer } from "../src/types.ts"; test("normalizes select and text questions with bounded defaults", () => { const questions = normalizeQuestions([ @@ -27,14 +27,26 @@ test("normalizes select and text questions with bounded defaults", () => { 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("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", () => { 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/);