initial commit
This commit is contained in:
24
README.md
Normal file
24
README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# pi-question
|
||||||
|
|
||||||
|
`pi-question` is a Pi extension package that provides a multi-question interactive choice flow with built-in support for a final free-text “Something else…” answer.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Use it as a local package root today:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install /absolute/path/to/question
|
||||||
|
```
|
||||||
|
|
||||||
|
After this folder is moved into its own repository, the same package can be installed from git.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- Extension: `./index.ts`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
|
```
|
||||||
398
index.ts
Normal file
398
index.ts
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import {
|
||||||
|
allQuestionsAnswered,
|
||||||
|
createAnsweredResult,
|
||||||
|
createCancelledResult,
|
||||||
|
createCustomAnswer,
|
||||||
|
createPredefinedAnswer,
|
||||||
|
isSomethingElseOption,
|
||||||
|
nextTabAfterAnswer,
|
||||||
|
normalizeQuestions,
|
||||||
|
summarizeAnswers,
|
||||||
|
wrapPrefixedText,
|
||||||
|
} from "./question-core.mjs";
|
||||||
|
|
||||||
|
interface QuestionOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
prompt: string;
|
||||||
|
options: QuestionOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Answer {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
wasCustom: boolean;
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuestionResult {
|
||||||
|
questions: Question[];
|
||||||
|
answers: Answer[];
|
||||||
|
cancelled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OptionSchema = Type.Object({
|
||||||
|
value: Type.String({ description: "Machine-friendly value returned to the model" }),
|
||||||
|
label: Type.String({ description: "Human-friendly label shown in the UI" }),
|
||||||
|
description: Type.Optional(Type.String({ description: "Optional help text shown under the label" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const QuestionSchema = Type.Object({
|
||||||
|
id: Type.String({ description: "Stable identifier for the answer" }),
|
||||||
|
label: Type.Optional(Type.String({ description: "Short label for summaries and tabs" })),
|
||||||
|
prompt: Type.String({ description: "Full question text shown to the user" }),
|
||||||
|
options: Type.Array(OptionSchema, { description: "Predefined options for the user to choose from" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const QuestionParams = Type.Object({
|
||||||
|
questions: Type.Array(QuestionSchema, { description: "One or more questions to ask the user" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function errorResult(message: string, questions: Question[] = []) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: message }],
|
||||||
|
details: createCancelledResult(questions) as QuestionResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQuestionFlow(ctx: any, questions: Question[]): Promise<QuestionResult> {
|
||||||
|
return ctx.ui.custom<QuestionResult>((tui, theme, _kb, done) => {
|
||||||
|
const isMulti = questions.length > 1;
|
||||||
|
let currentTab = 0;
|
||||||
|
let optionIndex = 0;
|
||||||
|
let inputMode = false;
|
||||||
|
let cachedLines: string[] | undefined;
|
||||||
|
const answers = new Map<string, Answer>();
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = new Editor(tui, editorTheme);
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
cachedLines = undefined;
|
||||||
|
tui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentQuestion(): Question | undefined {
|
||||||
|
return questions[currentTab];
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentOptions(): QuestionOption[] {
|
||||||
|
return currentQuestion()?.options ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(cancelled: boolean) {
|
||||||
|
if (cancelled) {
|
||||||
|
done(createCancelledResult(questions) as QuestionResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
done(createAnsweredResult(questions, Array.from(answers.values())) as QuestionResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.onSubmit = (value) => {
|
||||||
|
const question = currentQuestion();
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!question || trimmed.length === 0) {
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
answers.set(question.id, createCustomAnswer(question.id, trimmed) as Answer);
|
||||||
|
inputMode = false;
|
||||||
|
editor.setText("");
|
||||||
|
|
||||||
|
if (!isMulti) {
|
||||||
|
finish(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTab = nextTabAfterAnswer(currentTab, questions.length);
|
||||||
|
optionIndex = 0;
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleInput(data: string) {
|
||||||
|
if (inputMode) {
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
inputMode = false;
|
||||||
|
editor.setText("");
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.handleInput(data);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMulti) {
|
||||||
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
||||||
|
currentTab = (currentTab + 1) % (questions.length + 1);
|
||||||
|
optionIndex = 0;
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
||||||
|
currentTab = (currentTab - 1 + questions.length + 1) % (questions.length + 1);
|
||||||
|
optionIndex = 0;
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentTab === questions.length) {
|
||||||
|
if (matchesKey(data, Key.enter) && allQuestionsAnswered(questions, answers)) {
|
||||||
|
finish(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
finish(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = currentQuestion();
|
||||||
|
const options = currentOptions();
|
||||||
|
if (!question || options.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.up)) {
|
||||||
|
optionIndex = Math.max(0, optionIndex - 1);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.down)) {
|
||||||
|
optionIndex = Math.min(options.length - 1, optionIndex + 1);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.enter)) {
|
||||||
|
const selected = options[optionIndex]!;
|
||||||
|
if (isSomethingElseOption(selected)) {
|
||||||
|
inputMode = true;
|
||||||
|
editor.setText("");
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
answers.set(question.id, createPredefinedAnswer(question.id, selected, optionIndex + 1) as Answer);
|
||||||
|
if (!isMulti) {
|
||||||
|
finish(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTab = nextTabAfterAnswer(currentTab, questions.length);
|
||||||
|
optionIndex = 0;
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
finish(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(width: number): string[] {
|
||||||
|
if (cachedLines) return cachedLines;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const add = (line: string) => lines.push(truncateToWidth(line, width));
|
||||||
|
const question = currentQuestion();
|
||||||
|
const options = currentOptions();
|
||||||
|
|
||||||
|
function addWrapped(text: string, color: string, firstPrefix = "", continuationPrefix = firstPrefix) {
|
||||||
|
for (const line of wrapPrefixedText(text, width, firstPrefix, continuationPrefix)) {
|
||||||
|
add(theme.fg(color, line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWrappedOption(option: QuestionOption, index: number, selected: boolean) {
|
||||||
|
const firstPrefix = `${selected ? "> " : " "}${index + 1}. `;
|
||||||
|
const continuationPrefix = " ".repeat(firstPrefix.length);
|
||||||
|
addWrapped(option.label, selected ? "accent" : "text", firstPrefix, continuationPrefix);
|
||||||
|
if (option.description) {
|
||||||
|
addWrapped(option.description, "muted", " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWrappedReviewAnswer(questionLabel: string, value: string) {
|
||||||
|
const firstPrefix = ` ${questionLabel}: `;
|
||||||
|
const continuationPrefix = " ".repeat(firstPrefix.length);
|
||||||
|
const wrapped = wrapPrefixedText(value, width, firstPrefix, continuationPrefix);
|
||||||
|
for (let index = 0; index < wrapped.length; index += 1) {
|
||||||
|
const prefix = index === 0 ? firstPrefix : continuationPrefix;
|
||||||
|
const line = wrapped[index]!;
|
||||||
|
add(theme.fg("muted", prefix) + theme.fg("text", line.slice(prefix.length)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(theme.fg("accent", "─".repeat(width)));
|
||||||
|
|
||||||
|
if (isMulti) {
|
||||||
|
const tabs: string[] = [];
|
||||||
|
for (let index = 0; index < questions.length; index += 1) {
|
||||||
|
const tabQuestion = questions[index]!;
|
||||||
|
const active = index === currentTab;
|
||||||
|
const answered = answers.has(tabQuestion.id);
|
||||||
|
const box = answered ? "■" : "□";
|
||||||
|
const text = ` ${box} ${tabQuestion.label} `;
|
||||||
|
tabs.push(active ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(answered ? "success" : "muted", text));
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitText = " ✓ Submit ";
|
||||||
|
const submitActive = currentTab === questions.length;
|
||||||
|
const submitReady = allQuestionsAnswered(questions, answers);
|
||||||
|
tabs.push(
|
||||||
|
submitActive
|
||||||
|
? theme.bg("selectedBg", theme.fg("text", submitText))
|
||||||
|
: theme.fg(submitReady ? "success" : "dim", submitText),
|
||||||
|
);
|
||||||
|
|
||||||
|
add(` ${tabs.join(" ")}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputMode && question) {
|
||||||
|
addWrapped(question.prompt, "text", " ");
|
||||||
|
lines.push("");
|
||||||
|
for (let index = 0; index < options.length; index += 1) {
|
||||||
|
addWrappedOption(options[index]!, index, index === optionIndex);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
add(theme.fg("muted", " Your answer:"));
|
||||||
|
for (const line of editor.render(Math.max(1, width - 2))) {
|
||||||
|
add(` ${line}`);
|
||||||
|
}
|
||||||
|
} else if (isMulti && currentTab === questions.length) {
|
||||||
|
add(theme.fg("accent", theme.bold(" Ready to submit")));
|
||||||
|
lines.push("");
|
||||||
|
for (const reviewQuestion of questions) {
|
||||||
|
const answer = answers.get(reviewQuestion.id);
|
||||||
|
if (!answer) continue;
|
||||||
|
const label = answer.wasCustom ? `(wrote) ${answer.label}` : `${answer.index}. ${answer.label}`;
|
||||||
|
addWrappedReviewAnswer(reviewQuestion.label, label);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
if (allQuestionsAnswered(questions, answers)) {
|
||||||
|
add(theme.fg("success", " Press Enter to submit"));
|
||||||
|
} else {
|
||||||
|
add(theme.fg("warning", " All questions must be answered before submit"));
|
||||||
|
}
|
||||||
|
} else if (question) {
|
||||||
|
addWrapped(question.prompt, "text", " ");
|
||||||
|
lines.push("");
|
||||||
|
for (let index = 0; index < options.length; index += 1) {
|
||||||
|
addWrappedOption(options[index]!, index, index === optionIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
if (inputMode) {
|
||||||
|
add(theme.fg("dim", " Enter to submit • Esc to go back"));
|
||||||
|
} else if (isMulti) {
|
||||||
|
add(theme.fg("dim", " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"));
|
||||||
|
} else {
|
||||||
|
add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
|
||||||
|
}
|
||||||
|
add(theme.fg("accent", "─".repeat(width)));
|
||||||
|
|
||||||
|
cachedLines = lines;
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
render,
|
||||||
|
invalidate: () => {
|
||||||
|
cachedLines = undefined;
|
||||||
|
},
|
||||||
|
handleInput,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function question(pi: ExtensionAPI) {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "question",
|
||||||
|
label: "Question",
|
||||||
|
description:
|
||||||
|
"Ask the user one or more multiple-choice questions. Every question automatically gets a final Something else… option for free-text answers.",
|
||||||
|
parameters: QuestionParams,
|
||||||
|
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
return errorResult("Error: UI not available (running in non-interactive mode)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.questions.length === 0) {
|
||||||
|
return errorResult("Error: No questions provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = normalizeQuestions(params.questions) as Question[];
|
||||||
|
const result = await runQuestionFlow(ctx, questions);
|
||||||
|
if (result.cancelled) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "User cancelled the question flow" }],
|
||||||
|
details: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: summarizeAnswers(result.questions, result.answers).join("\n") }],
|
||||||
|
details: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderCall(args, theme) {
|
||||||
|
const questions = Array.isArray(args.questions) ? args.questions : [];
|
||||||
|
const labels = questions
|
||||||
|
.map((question: { label?: string; id?: string }) => question.label || question.id)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
let text = theme.fg("toolTitle", theme.bold("question "));
|
||||||
|
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 QuestionResult | undefined;
|
||||||
|
if (!details) {
|
||||||
|
const first = result.content[0];
|
||||||
|
return new Text(first?.type === "text" ? first.text : "", 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.cancelled) {
|
||||||
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = summarizeAnswers(details.questions, details.answers).map(
|
||||||
|
(line) => `${theme.fg("success", "✓ ")}${line}`,
|
||||||
|
);
|
||||||
|
return new Text(lines.join("\n"), 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
4368
package-lock.json
generated
Normal file
4368
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package-manifest.test.mjs
Normal file
28
package-manifest.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const packageRoot = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||||
|
|
||||||
|
test("package.json exposes pi-question as a standalone pi package", () => {
|
||||||
|
assert.equal(pkg.name, "pi-question");
|
||||||
|
assert.equal(pkg.type, "module");
|
||||||
|
assert.ok(Array.isArray(pkg.keywords));
|
||||||
|
assert.ok(pkg.keywords.includes("pi-package"));
|
||||||
|
assert.deepEqual(pkg.pi, {
|
||||||
|
extensions: ["./index.ts"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(pkg.peerDependencies["@mariozechner/pi-coding-agent"], "*");
|
||||||
|
assert.equal(pkg.peerDependencies["@mariozechner/pi-tui"], "*");
|
||||||
|
assert.equal(pkg.peerDependencies["@sinclair/typebox"], "*");
|
||||||
|
assert.deepEqual(pkg.dependencies ?? {}, {});
|
||||||
|
assert.equal(pkg.bundledDependencies, undefined);
|
||||||
|
assert.deepEqual(pkg.files, ["index.ts", "question-core.mjs"]);
|
||||||
|
|
||||||
|
assert.ok(existsSync(resolve(packageRoot, "index.ts")));
|
||||||
|
assert.ok(existsSync(resolve(packageRoot, "question-core.mjs")));
|
||||||
|
});
|
||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-question",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"keywords": ["pi-package"],
|
||||||
|
"scripts": {
|
||||||
|
"test": "tsx --test *.test.mjs"
|
||||||
|
},
|
||||||
|
"files": ["index.ts", "question-core.mjs"],
|
||||||
|
"pi": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mariozechner/pi-coding-agent": "*",
|
||||||
|
"@mariozechner/pi-tui": "*",
|
||||||
|
"@sinclair/typebox": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mariozechner/pi-coding-agent": "^0.66.1",
|
||||||
|
"@mariozechner/pi-tui": "^0.66.1",
|
||||||
|
"@sinclair/typebox": "^0.34.49",
|
||||||
|
"@types/node": "^25.5.2",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
134
question-core.mjs
Normal file
134
question-core.mjs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
export const SOMETHING_ELSE_VALUE = "__something_else__";
|
||||||
|
export const SOMETHING_ELSE_LABEL = "Something else…";
|
||||||
|
|
||||||
|
export function normalizeQuestions(inputQuestions) {
|
||||||
|
return inputQuestions.map((question, index) => ({
|
||||||
|
...question,
|
||||||
|
label: question.label?.trim() ? question.label : `Q${index + 1}`,
|
||||||
|
options: [
|
||||||
|
...question.options,
|
||||||
|
{
|
||||||
|
value: SOMETHING_ELSE_VALUE,
|
||||||
|
label: SOMETHING_ELSE_LABEL,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSomethingElseOption(option) {
|
||||||
|
return option?.value === SOMETHING_ELSE_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPredefinedAnswer(questionId, option, index) {
|
||||||
|
return {
|
||||||
|
id: questionId,
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
wasCustom: false,
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCustomAnswer(questionId, text) {
|
||||||
|
return {
|
||||||
|
id: questionId,
|
||||||
|
value: text,
|
||||||
|
label: text,
|
||||||
|
wasCustom: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeAnswers(questions, answers) {
|
||||||
|
const answerById = new Map(answers.map((answer) => [answer.id, answer]));
|
||||||
|
return questions.flatMap((question) => {
|
||||||
|
const answer = answerById.get(question.id);
|
||||||
|
if (!answer) return [];
|
||||||
|
if (answer.wasCustom) {
|
||||||
|
return [`${question.label}: user wrote: ${answer.label}`];
|
||||||
|
}
|
||||||
|
return [`${question.label}: user selected: ${answer.index}. ${answer.label}`];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCancelledResult(questions = []) {
|
||||||
|
return {
|
||||||
|
questions,
|
||||||
|
answers: [],
|
||||||
|
cancelled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAnsweredResult(questions, answers) {
|
||||||
|
const order = new Map(questions.map((question, index) => [question.id, index]));
|
||||||
|
return {
|
||||||
|
questions,
|
||||||
|
answers: [...answers].sort(
|
||||||
|
(left, right) => (order.get(left.id) ?? Number.POSITIVE_INFINITY) - (order.get(right.id) ?? Number.POSITIVE_INFINITY),
|
||||||
|
),
|
||||||
|
cancelled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allQuestionsAnswered(questions, answers) {
|
||||||
|
return questions.every((question) => answers.has(question.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextTabAfterAnswer(currentTab, questionCount) {
|
||||||
|
return currentTab < questionCount - 1 ? currentTab + 1 : questionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeWrappedSegment(text, maxWidth) {
|
||||||
|
if (text.length <= maxWidth) {
|
||||||
|
return { line: text, rest: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let breakpoint = -1;
|
||||||
|
for (let index = 0; index < maxWidth; index += 1) {
|
||||||
|
if (/\s/.test(text[index])) {
|
||||||
|
breakpoint = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breakpoint > 0) {
|
||||||
|
return {
|
||||||
|
line: text.slice(0, breakpoint).trimEnd(),
|
||||||
|
rest: text.slice(breakpoint + 1).trimStart(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
line: text.slice(0, maxWidth),
|
||||||
|
rest: text.slice(maxWidth),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapPrefixedText(text, width, firstPrefix = "", continuationPrefix = firstPrefix) {
|
||||||
|
const source = String(text ?? "");
|
||||||
|
if (source.length === 0) {
|
||||||
|
return [firstPrefix];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
const blocks = source.split(/\r?\n/);
|
||||||
|
let isFirstLine = true;
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
let remaining = block.trim();
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
lines.push(isFirstLine ? firstPrefix : continuationPrefix);
|
||||||
|
isFirstLine = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
const prefix = isFirstLine ? firstPrefix : continuationPrefix;
|
||||||
|
const maxTextWidth = Math.max(1, width - prefix.length);
|
||||||
|
const { line, rest } = takeWrappedSegment(remaining, maxTextWidth);
|
||||||
|
lines.push(prefix + line);
|
||||||
|
remaining = rest;
|
||||||
|
isFirstLine = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
180
question-core.test.mjs
Normal file
180
question-core.test.mjs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
SOMETHING_ELSE_LABEL,
|
||||||
|
SOMETHING_ELSE_VALUE,
|
||||||
|
allQuestionsAnswered,
|
||||||
|
createAnsweredResult,
|
||||||
|
createCancelledResult,
|
||||||
|
createCustomAnswer,
|
||||||
|
createPredefinedAnswer,
|
||||||
|
nextTabAfterAnswer,
|
||||||
|
normalizeQuestions,
|
||||||
|
summarizeAnswers,
|
||||||
|
wrapPrefixedText,
|
||||||
|
} from "./question-core.mjs";
|
||||||
|
|
||||||
|
test("normalizeQuestions adds default labels and appends the Something else option", () => {
|
||||||
|
const [question] = normalizeQuestions([
|
||||||
|
{
|
||||||
|
id: "scope",
|
||||||
|
prompt: "Which scope fits best?",
|
||||||
|
options: [{ value: "small", label: "Small change" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(question.label, "Q1");
|
||||||
|
assert.deepEqual(question.options[0], { value: "small", label: "Small change" });
|
||||||
|
assert.deepEqual(question.options.at(-1), {
|
||||||
|
value: SOMETHING_ELSE_VALUE,
|
||||||
|
label: SOMETHING_ELSE_LABEL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeQuestions keeps provided labels and descriptions intact before the synthetic option", () => {
|
||||||
|
const [question] = normalizeQuestions([
|
||||||
|
{
|
||||||
|
id: "priority",
|
||||||
|
label: "Priority",
|
||||||
|
prompt: "Which priority?",
|
||||||
|
options: [
|
||||||
|
{ value: "p0", label: "P0", description: "Need this now" },
|
||||||
|
{ value: "p1", label: "P1" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(question.label, "Priority");
|
||||||
|
assert.deepEqual(question.options.slice(0, 2), [
|
||||||
|
{ value: "p0", label: "P0", description: "Need this now" },
|
||||||
|
{ value: "p1", label: "P1" },
|
||||||
|
]);
|
||||||
|
assert.equal(question.options[2].label, SOMETHING_ELSE_LABEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("answer helpers preserve machine values and summary lines distinguish predefined vs custom answers", () => {
|
||||||
|
const questions = normalizeQuestions([
|
||||||
|
{
|
||||||
|
id: "scope",
|
||||||
|
label: "Scope",
|
||||||
|
prompt: "Which scope fits best?",
|
||||||
|
options: [{ value: "small", label: "Small change" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notes",
|
||||||
|
label: "Notes",
|
||||||
|
prompt: "Anything else?",
|
||||||
|
options: [{ value: "none", label: "No extra notes" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const predefined = createPredefinedAnswer("scope", questions[0].options[0], 1);
|
||||||
|
const custom = createCustomAnswer("notes", "Needs to work with tmux");
|
||||||
|
|
||||||
|
assert.deepEqual(predefined, {
|
||||||
|
id: "scope",
|
||||||
|
value: "small",
|
||||||
|
label: "Small change",
|
||||||
|
wasCustom: false,
|
||||||
|
index: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(custom, {
|
||||||
|
id: "notes",
|
||||||
|
value: "Needs to work with tmux",
|
||||||
|
label: "Needs to work with tmux",
|
||||||
|
wasCustom: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(summarizeAnswers(questions, [predefined, custom]), [
|
||||||
|
"Scope: user selected: 1. Small change",
|
||||||
|
"Notes: user wrote: Needs to work with tmux",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createCancelledResult returns a structured cancelled payload", () => {
|
||||||
|
const questions = normalizeQuestions([
|
||||||
|
{
|
||||||
|
id: "scope",
|
||||||
|
prompt: "Which scope fits best?",
|
||||||
|
options: [{ value: "small", label: "Small change" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(createCancelledResult(questions), {
|
||||||
|
questions,
|
||||||
|
answers: [],
|
||||||
|
cancelled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createAnsweredResult keeps answers in question order", () => {
|
||||||
|
const questions = normalizeQuestions([
|
||||||
|
{
|
||||||
|
id: "scope",
|
||||||
|
label: "Scope",
|
||||||
|
prompt: "Which scope fits best?",
|
||||||
|
options: [{ value: "small", label: "Small change" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notes",
|
||||||
|
label: "Notes",
|
||||||
|
prompt: "Anything else?",
|
||||||
|
options: [{ value: "none", label: "No extra notes" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const second = createCustomAnswer("notes", "Custom note");
|
||||||
|
const first = createPredefinedAnswer("scope", questions[0].options[0], 1);
|
||||||
|
const result = createAnsweredResult(questions, [second, first]);
|
||||||
|
|
||||||
|
assert.equal(result.cancelled, false);
|
||||||
|
assert.deepEqual(result.answers.map((answer) => answer.id), ["scope", "notes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allQuestionsAnswered only returns true when every question has an answer", () => {
|
||||||
|
const questions = normalizeQuestions([
|
||||||
|
{
|
||||||
|
id: "scope",
|
||||||
|
prompt: "Scope?",
|
||||||
|
options: [{ value: "small", label: "Small" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "priority",
|
||||||
|
prompt: "Priority?",
|
||||||
|
options: [{ value: "p1", label: "P1" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const answers = new Map([
|
||||||
|
["scope", createPredefinedAnswer("scope", questions[0].options[0], 1)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(allQuestionsAnswered(questions, answers), false);
|
||||||
|
answers.set("priority", createCustomAnswer("priority", "Ship this week"));
|
||||||
|
assert.equal(allQuestionsAnswered(questions, answers), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextTabAfterAnswer advances through questions and then to the submit tab", () => {
|
||||||
|
assert.equal(nextTabAfterAnswer(0, 3), 1);
|
||||||
|
assert.equal(nextTabAfterAnswer(1, 3), 2);
|
||||||
|
assert.equal(nextTabAfterAnswer(2, 3), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wrapPrefixedText wraps long prompts and keeps the prefix on continuation lines", () => {
|
||||||
|
assert.deepEqual(wrapPrefixedText("Pick the best rollout strategy for this change", 18, " "), [
|
||||||
|
" Pick the best",
|
||||||
|
" rollout strategy",
|
||||||
|
" for this change",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wrapPrefixedText supports a different continuation prefix for wrapped option labels", () => {
|
||||||
|
assert.deepEqual(wrapPrefixedText("Very long option label", 16, "> 1. ", " "), [
|
||||||
|
"> 1. Very long",
|
||||||
|
" option",
|
||||||
|
" label",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wrapPrefixedText breaks oversized words when there is no whitespace boundary", () => {
|
||||||
|
assert.deepEqual(wrapPrefixedText("supercalifragilistic", 8), ["supercal", "ifragili", "stic"]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user