diff --git a/.pi/agent/extensions/question-core.mjs b/.pi/agent/extensions/question-core.mjs new file mode 100644 index 0000000..aa9a40f --- /dev/null +++ b/.pi/agent/extensions/question-core.mjs @@ -0,0 +1,78 @@ +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; +} diff --git a/.pi/agent/extensions/question-core.test.mjs b/.pi/agent/extensions/question-core.test.mjs new file mode 100644 index 0000000..2cb7acb --- /dev/null +++ b/.pi/agent/extensions/question-core.test.mjs @@ -0,0 +1,159 @@ +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, +} 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); +}); diff --git a/.pi/agent/extensions/question.ts b/.pi/agent/extensions/question.ts new file mode 100644 index 0000000..ea2de7a --- /dev/null +++ b/.pi/agent/extensions/question.ts @@ -0,0 +1,381 @@ +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, +} 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 { + return ctx.ui.custom((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(); + + 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(); + + 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) { + add(theme.fg("text", ` ${question.prompt}`)); + lines.push(""); + for (let index = 0; index < options.length; index += 1) { + const option = options[index]!; + const prefix = index === optionIndex ? theme.fg("accent", "> ") : " "; + add(prefix + theme.fg(index === optionIndex ? "accent" : "text", `${index + 1}. ${option.label}`)); + if (option.description) { + add(` ${theme.fg("muted", option.description)}`); + } + } + lines.push(""); + add(theme.fg("muted", " Your answer:")); + for (const line of editor.render(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}`; + add(`${theme.fg("muted", ` ${reviewQuestion.label}: `)}${theme.fg("text", 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) { + add(theme.fg("text", ` ${question.prompt}`)); + lines.push(""); + for (let index = 0; index < options.length; index += 1) { + const option = options[index]!; + const prefix = index === optionIndex ? theme.fg("accent", "> ") : " "; + add(prefix + theme.fg(index === optionIndex ? "accent" : "text", `${index + 1}. ${option.label}`)); + if (option.description) { + add(` ${theme.fg("muted", option.description)}`); + } + } + } + + 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); + }, + }); +}