mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: add interactive ask-user extension
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
Reference in New Issue
Block a user