/** * Questionnaire Tool - Unified tool for asking single or multiple questions * * Single question: simple options list * Multiple questions: tab bar navigation between questions * * Entirely model-invoked: the LLM calls this tool instead of listing questions * in plain text. There is no slash command or keybinding. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Editor, type EditorTheme, Key, matchesKey, Text, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import { type Answer, type Question, QuestionnaireParams, type QuestionnaireResult, type RenderOption, } from "./types.js"; function errorResult( message: string, questions: Question[] = [], ): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } { return { content: [{ type: "text", text: message }], details: { questions, answers: [], cancelled: true }, }; } export default function questionnaire(pi: ExtensionAPI) { pi.registerTool({ name: "questionnaire", label: "Questionnaire", description: "Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface. If this tool errors (e.g. non-interactive session), fall back to asking the questions in plain text.", promptSnippet: "Ask the user clarifying questions with selectable options", promptGuidelines: [ "Use questionnaire when you need to ask the user one or more clarifying questions with selectable options, instead of listing questions in plain text and waiting for the user to answer by number.", "Prefer questionnaire whenever a decision has a small set of discrete choices (scope, approach, yes/no confirmation); it lets the user pick options directly and returns structured answers.", ], parameters: QuestionnaireParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { if (ctx.mode !== "tui") { return errorResult("Error: UI not available (running in non-interactive mode)"); } if (params.questions.length === 0) { return errorResult("Error: No questions provided"); } // Reject duplicate ids — answers are keyed by id and would silently collide. const seenIds = new Set(); for (const q of params.questions) { if (seenIds.has(q.id)) { return errorResult(`Error: Duplicate question id "${q.id}" — question ids must be unique`); } seenIds.add(q.id); } // Reject unselectable questions (no options and no free-text fallback). for (const q of params.questions) { const allowOther = q.allowOther !== false; if (q.options.length === 0 && !allowOther) { return errorResult( `Error: Question "${q.id}" has no options and allowOther is false — nothing is selectable`, ); } } // Normalize questions with defaults const questions: Question[] = params.questions.map((q, i) => ({ ...q, label: q.label || `Q${i + 1}`, allowOther: q.allowOther !== false, })); const isMulti = questions.length > 1; const totalTabs = questions.length + 1; // questions + Submit const result = await ctx.ui.custom((tui, theme, _kb, done) => { // State let currentTab = 0; let optionIndex = 0; let inputMode = false; let inputQuestionId: string | null = null; let cachedLines: string[] | undefined; const answers = new Map(); // Editor for "Type something" option const editorTheme: EditorTheme = { borderColor: (s) => theme.fg("accent", s), selectList: { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }, }; const editor = new Editor(tui, editorTheme); // Helpers function refresh() { cachedLines = undefined; tui.requestRender(); } function submit(cancelled: boolean) { done({ questions, answers: Array.from(answers.values()), cancelled }); } function currentQuestion(): Question | undefined { return questions[currentTab]; } function currentOptions(): RenderOption[] { const q = currentQuestion(); if (!q) return []; const opts: RenderOption[] = [...q.options]; if (q.allowOther) { opts.push({ value: "__other__", label: "Type something.", isOther: true }); } return opts; } function allAnswered(): boolean { return questions.every((q) => answers.has(q.id)); } function advanceAfterAnswer() { if (!isMulti) { submit(false); return; } if (currentTab < questions.length - 1) { currentTab++; } else { currentTab = questions.length; // Submit tab } optionIndex = 0; refresh(); } function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) { answers.set(questionId, { id: questionId, value, label, wasCustom, index }); } /** Select the option at the given index — enters input mode for "other", else saves + advances. */ function selectOption(q: Question, opts: RenderOption[], index: number) { const opt = opts[index]; if (!opt) return; if (opt.isOther) { inputMode = true; inputQuestionId = q.id; editor.setText(""); refresh(); return; } saveAnswer(q.id, opt.value, opt.label, false, index + 1); advanceAfterAnswer(); } // Editor submit callback editor.onSubmit = (value) => { if (!inputQuestionId) return; const trimmed = value.trim() || "(no response)"; saveAnswer(inputQuestionId, trimmed, trimmed, true); inputMode = false; inputQuestionId = null; editor.setText(""); advanceAfterAnswer(); }; function handleInput(data: string) { // Input mode: route to editor if (inputMode) { if (matchesKey(data, Key.escape)) { inputMode = false; inputQuestionId = null; editor.setText(""); refresh(); return; } editor.handleInput(data); refresh(); return; } const q = currentQuestion(); const opts = currentOptions(); // Tab navigation (multi-question only) if (isMulti) { if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { currentTab = (currentTab + 1) % totalTabs; optionIndex = 0; refresh(); return; } if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { currentTab = (currentTab - 1 + totalTabs) % totalTabs; optionIndex = 0; refresh(); return; } } // Submit tab if (currentTab === questions.length) { if (matchesKey(data, Key.enter) && allAnswered()) { submit(false); } else if (matchesKey(data, Key.escape)) { submit(true); } return; } // Digit-key direct selection: 1-9 select that numbered option, 0 selects the 10th. if (q && data.length === 1 && data >= "0" && data <= "9") { const n = data === "0" ? 10 : Number(data); const target = n - 1; if (target < opts.length) { optionIndex = target; selectOption(q, opts, target); } return; } // Option navigation if (matchesKey(data, Key.up)) { optionIndex = Math.max(0, optionIndex - 1); refresh(); return; } if (matchesKey(data, Key.down)) { optionIndex = Math.min(opts.length - 1, optionIndex + 1); refresh(); return; } // Select option if (matchesKey(data, Key.enter) && q) { selectOption(q, opts, optionIndex); return; } // Cancel if (matchesKey(data, Key.escape)) { submit(true); } } function render(width: number): string[] { if (cachedLines) return cachedLines; const lines: string[] = []; const renderWidth = Math.max(1, width); const q = currentQuestion(); const opts = currentOptions(); function addWrapped(text: string) { lines.push(...wrapTextWithAnsi(text, renderWidth)); } function addWrappedWithPrefix(prefix: string, text: string) { const prefixWidth = visibleWidth(prefix); if (prefixWidth >= renderWidth) { addWrapped(prefix + text); return; } const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth); const continuationPrefix = " ".repeat(prefixWidth); for (let i = 0; i < wrapped.length; i++) { lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`); } } lines.push(theme.fg("accent", "─".repeat(renderWidth))); // Tab bar (multi-question only) if (isMulti) { const tabs: string[] = ["← "]; for (let i = 0; i < questions.length; i++) { const isActive = i === currentTab; const isAnswered = answers.has(questions[i].id); const lbl = questions[i].label; const box = isAnswered ? "■" : "□"; const color = isAnswered ? "success" : "muted"; const text = ` ${box} ${lbl} `; const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text); tabs.push(`${styled} `); } const canSubmit = allAnswered(); const isSubmitTab = currentTab === questions.length; const submitText = " ✓ Submit "; const submitStyled = isSubmitTab ? theme.bg("selectedBg", theme.fg("text", submitText)) : theme.fg(canSubmit ? "success" : "dim", submitText); tabs.push(`${submitStyled} →`); addWrappedWithPrefix(" ", tabs.join("")); lines.push(""); } // Helper to render options list function renderOptions() { for (let i = 0; i < opts.length; i++) { const opt = opts[i]; const selected = i === optionIndex; const isOther = opt.isOther === true; const prefix = selected ? theme.fg("accent", "> ") : " "; const label = `${i + 1}. ${opt.label}${isOther && inputMode ? " ✎" : ""}`; const color = selected || (isOther && inputMode) ? "accent" : "text"; addWrappedWithPrefix(prefix, theme.fg(color, label)); if (opt.description) { addWrappedWithPrefix(" ", theme.fg("muted", opt.description)); } } } // Content if (inputMode && q) { addWrappedWithPrefix(" ", theme.fg("text", q.prompt)); lines.push(""); // Show options for reference renderOptions(); lines.push(""); addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:")); for (const line of editor.render(Math.max(1, renderWidth - 2))) { lines.push(` ${line}`); } lines.push(""); addWrappedWithPrefix(" ", theme.fg("dim", "Enter to submit • Esc to cancel")); } else if (currentTab === questions.length) { addWrappedWithPrefix(" ", theme.fg("accent", theme.bold("Ready to submit"))); lines.push(""); for (const question of questions) { const answer = answers.get(question.id); if (answer) { const prefix = answer.wasCustom ? "(wrote) " : ""; const summary = `${theme.fg("muted", `${question.label}: `)}${theme.fg("text", prefix + answer.label)}`; addWrappedWithPrefix(" ", summary); } } lines.push(""); if (allAnswered()) { addWrappedWithPrefix(" ", theme.fg("success", "Press Enter to submit")); } else { const missing = questions .filter((q) => !answers.has(q.id)) .map((q) => q.label) .join(", "); addWrappedWithPrefix(" ", theme.fg("warning", `Unanswered: ${missing}`)); } } else if (q) { addWrappedWithPrefix(" ", theme.fg("text", q.prompt)); lines.push(""); renderOptions(); } lines.push(""); if (!inputMode) { const help = isMulti ? "Tab/←→ navigate • ↑↓ or 1-9 select • Enter confirm • Esc cancel" : "↑↓ or 1-9 select • Enter confirm • Esc cancel"; addWrappedWithPrefix(" ", theme.fg("dim", help)); } lines.push(theme.fg("accent", "─".repeat(renderWidth))); cachedLines = lines; return lines; } return { render, invalidate: () => { cachedLines = undefined; }, handleInput, }; }); if (result.cancelled) { return { content: [{ type: "text", text: "User cancelled the questionnaire" }], details: result, }; } const answerLines = result.answers.map((a) => { const qLabel = questions.find((q) => q.id === a.id)?.label || a.id; if (a.wasCustom) { return `${qLabel}: user wrote: ${a.label}`; } return `${qLabel}: user selected: ${a.index}. ${a.label}`; }); return { content: [{ type: "text", text: answerLines.join("\n") }], details: result, }; }, renderCall(args, theme, _context) { const qs = (args.questions as Question[]) || []; const count = qs.length; const labels = qs.map((q) => q.label || q.id).join(", "); let text = theme.fg("toolTitle", theme.bold("questionnaire ")); text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`); if (labels) { text += theme.fg("dim", ` (${labels})`); } return new Text(text, 0, 0); }, renderResult(result, _options, theme, _context) { const details = result.details as QuestionnaireResult | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } if (details.cancelled) { return new Text(theme.fg("warning", "Cancelled"), 0, 0); } const lines = details.answers.map((a) => { if (a.wasCustom) { return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`; } const display = a.index ? `${a.index}. ${a.label}` : a.label; return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`; }); return new Text(lines.join("\n"), 0, 0); }, }); }