forked from
lass.pds.lassul.us/superconfig
This repository has no description
15 kB
463 lines
1/**
2 * Questionnaire Tool - Ask the user multiple choice questions
3 *
4 * Registers a "questionnaire" tool the LLM can call to ask single or multiple
5 * questions with an interactive TUI. Based on mic92's pi-agent-extensions.
6 */
7
8import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9import {
10 Editor,
11 type EditorTheme,
12 Key,
13 matchesKey,
14 Text,
15 truncateToWidth,
16} from "@mariozechner/pi-tui";
17import { Type } from "@sinclair/typebox";
18
19interface QuestionOption {
20 value: string;
21 label: string;
22 description?: string;
23}
24
25type RenderOption = QuestionOption & { isOther?: boolean };
26
27interface Question {
28 id: string;
29 label: string;
30 prompt: string;
31 options: QuestionOption[];
32 allowOther: boolean;
33}
34
35interface Answer {
36 id: string;
37 value: string;
38 label: string;
39 wasCustom: boolean;
40 index?: number;
41}
42
43interface QuestionnaireResult {
44 questions: Question[];
45 answers: Answer[];
46 cancelled: boolean;
47}
48
49const QuestionOptionSchema = Type.Object({
50 value: Type.String({ description: "The value returned when selected" }),
51 label: Type.String({ description: "Display label for the option" }),
52 description: Type.Optional(
53 Type.String({ description: "Optional description shown below label" }),
54 ),
55});
56
57const QuestionSchema = Type.Object({
58 id: Type.String({ description: "Unique identifier for this question" }),
59 label: Type.Optional(
60 Type.String({
61 description:
62 "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)",
63 }),
64 ),
65 prompt: Type.String({ description: "The full question text to display" }),
66 options: Type.Array(QuestionOptionSchema, {
67 description: "Available options to choose from",
68 }),
69 allowOther: Type.Optional(
70 Type.Boolean({
71 description: "Allow 'Type something' option (default: true)",
72 }),
73 ),
74});
75
76const QuestionnaireParams = Type.Object({
77 questions: Type.Array(QuestionSchema, {
78 description: "Questions to ask the user",
79 }),
80});
81
82function errorResult(
83 message: string,
84 questions: Question[] = [],
85): {
86 content: { type: "text"; text: string }[];
87 details: QuestionnaireResult;
88} {
89 return {
90 content: [{ type: "text", text: message }],
91 details: { questions, answers: [], cancelled: true },
92 };
93}
94
95export default function questionnaire(pi: ExtensionAPI) {
96 pi.registerTool({
97 name: "questionnaire",
98 label: "Questionnaire",
99 description:
100 "Ask the user one or more multiple choice questions. Use for clarifying requirements, getting preferences, confirming decisions, or quizzing. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.",
101 parameters: QuestionnaireParams,
102
103 async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
104 if (!ctx.hasUI) {
105 return errorResult(
106 "Error: UI not available (running in non-interactive mode)",
107 );
108 }
109 if (params.questions.length === 0) {
110 return errorResult("Error: No questions provided");
111 }
112
113 const questions: Question[] = params.questions.map((q, i) => ({
114 ...q,
115 label: q.label || `Q${i + 1}`,
116 allowOther: q.allowOther !== false,
117 }));
118
119 const isMulti = questions.length > 1;
120 const totalTabs = questions.length + 1;
121
122 const result = await ctx.ui.custom<QuestionnaireResult>(
123 (tui, theme, _kb, done) => {
124 let currentTab = 0;
125 let optionIndex = 0;
126 let inputMode = false;
127 let inputQuestionId: string | null = null;
128 let cachedLines: string[] | undefined;
129 const answers = new Map<string, Answer>();
130
131 const editorTheme: EditorTheme = {
132 borderColor: (s) => theme.fg("accent", s),
133 selectList: {
134 selectedPrefix: (t) => theme.fg("accent", t),
135 selectedText: (t) => theme.fg("accent", t),
136 description: (t) => theme.fg("muted", t),
137 scrollInfo: (t) => theme.fg("dim", t),
138 noMatch: (t) => theme.fg("warning", t),
139 },
140 };
141 const editor = new Editor(tui, editorTheme);
142
143 function refresh() {
144 cachedLines = undefined;
145 tui.requestRender();
146 }
147
148 function submit(cancelled: boolean) {
149 done({
150 questions,
151 answers: Array.from(answers.values()),
152 cancelled,
153 });
154 }
155
156 function currentQuestion(): Question | undefined {
157 return questions[currentTab];
158 }
159
160 function currentOptions(): RenderOption[] {
161 const q = currentQuestion();
162 if (!q) return [];
163 const opts: RenderOption[] = [...q.options];
164 if (q.allowOther) {
165 opts.push({
166 value: "__other__",
167 label: "Type something.",
168 isOther: true,
169 });
170 }
171 return opts;
172 }
173
174 function allAnswered(): boolean {
175 return questions.every((q) => answers.has(q.id));
176 }
177
178 function advanceAfterAnswer() {
179 if (!isMulti) {
180 submit(false);
181 return;
182 }
183 if (currentTab < questions.length - 1) {
184 currentTab++;
185 } else {
186 currentTab = questions.length;
187 }
188 optionIndex = 0;
189 refresh();
190 }
191
192 function saveAnswer(
193 questionId: string,
194 value: string,
195 label: string,
196 wasCustom: boolean,
197 index?: number,
198 ) {
199 answers.set(questionId, {
200 id: questionId,
201 value,
202 label,
203 wasCustom,
204 index,
205 });
206 }
207
208 editor.onSubmit = (value) => {
209 if (!inputQuestionId) return;
210 const trimmed = value.trim() || "(no response)";
211 saveAnswer(inputQuestionId, trimmed, trimmed, true);
212 inputMode = false;
213 inputQuestionId = null;
214 editor.setText("");
215 advanceAfterAnswer();
216 };
217
218 function handleInput(data: string) {
219 if (inputMode) {
220 if (matchesKey(data, Key.escape)) {
221 inputMode = false;
222 inputQuestionId = null;
223 editor.setText("");
224 refresh();
225 return;
226 }
227 editor.handleInput(data);
228 refresh();
229 return;
230 }
231
232 const q = currentQuestion();
233 const opts = currentOptions();
234
235 if (isMulti) {
236 if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
237 currentTab = (currentTab + 1) % totalTabs;
238 optionIndex = 0;
239 refresh();
240 return;
241 }
242 if (
243 matchesKey(data, Key.shift("tab")) ||
244 matchesKey(data, Key.left)
245 ) {
246 currentTab = (currentTab - 1 + totalTabs) % totalTabs;
247 optionIndex = 0;
248 refresh();
249 return;
250 }
251 }
252
253 if (currentTab === questions.length) {
254 if (matchesKey(data, Key.enter) && allAnswered()) {
255 submit(false);
256 } else if (matchesKey(data, Key.escape)) {
257 submit(true);
258 }
259 return;
260 }
261
262 if (matchesKey(data, Key.up)) {
263 optionIndex = Math.max(0, optionIndex - 1);
264 refresh();
265 return;
266 }
267 if (matchesKey(data, Key.down)) {
268 optionIndex = Math.min(opts.length - 1, optionIndex + 1);
269 refresh();
270 return;
271 }
272
273 if (matchesKey(data, Key.enter) && q) {
274 const opt = opts[optionIndex];
275 if (opt.isOther) {
276 inputMode = true;
277 inputQuestionId = q.id;
278 editor.setText("");
279 refresh();
280 return;
281 }
282 saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
283 advanceAfterAnswer();
284 return;
285 }
286
287 if (matchesKey(data, Key.escape)) {
288 submit(true);
289 }
290 }
291
292 function render(width: number): string[] {
293 if (cachedLines) return cachedLines;
294
295 const lines: string[] = [];
296 const q = currentQuestion();
297 const opts = currentOptions();
298
299 const add = (s: string) => lines.push(truncateToWidth(s, width));
300
301 add(theme.fg("accent", "─".repeat(width)));
302
303 if (isMulti) {
304 const tabs: string[] = ["← "];
305 for (let i = 0; i < questions.length; i++) {
306 const isActive = i === currentTab;
307 const isAnswered = answers.has(questions[i].id);
308 const lbl = questions[i].label;
309 const box = isAnswered ? "■" : "□";
310 const color = isAnswered ? "success" : "muted";
311 const text = ` ${box} ${lbl} `;
312 const styled = isActive
313 ? theme.bg("selectedBg", theme.fg("text", text))
314 : theme.fg(color, text);
315 tabs.push(`${styled} `);
316 }
317 const canSubmit = allAnswered();
318 const isSubmitTab = currentTab === questions.length;
319 const submitText = " ✓ Submit ";
320 const submitStyled = isSubmitTab
321 ? theme.bg("selectedBg", theme.fg("text", submitText))
322 : theme.fg(canSubmit ? "success" : "dim", submitText);
323 tabs.push(`${submitStyled} →`);
324 add(` ${tabs.join("")}`);
325 lines.push("");
326 }
327
328 function renderOptions() {
329 for (let i = 0; i < opts.length; i++) {
330 const opt = opts[i];
331 const selected = i === optionIndex;
332 const isOther = opt.isOther === true;
333 const prefix = selected ? theme.fg("accent", "> ") : " ";
334 const color = selected ? "accent" : "text";
335 if (isOther && inputMode) {
336 add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
337 } else {
338 add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
339 }
340 if (opt.description) {
341 add(` ${theme.fg("muted", opt.description)}`);
342 }
343 }
344 }
345
346 if (inputMode && q) {
347 add(theme.fg("text", ` ${q.prompt}`));
348 lines.push("");
349 renderOptions();
350 lines.push("");
351 add(theme.fg("muted", " Your answer:"));
352 for (const line of editor.render(width - 2)) {
353 add(` ${line}`);
354 }
355 lines.push("");
356 add(theme.fg("dim", " Enter to submit • Esc to cancel"));
357 } else if (currentTab === questions.length) {
358 add(theme.fg("accent", theme.bold(" Ready to submit")));
359 lines.push("");
360 for (const question of questions) {
361 const answer = answers.get(question.id);
362 if (answer) {
363 const prefix = answer.wasCustom ? "(wrote) " : "";
364 add(
365 `${theme.fg("muted", ` ${question.label}: `)}${theme.fg(
366 "text",
367 prefix + answer.label,
368 )}`,
369 );
370 }
371 }
372 lines.push("");
373 if (allAnswered()) {
374 add(theme.fg("success", " Press Enter to submit"));
375 } else {
376 const missing = questions
377 .filter((q) => !answers.has(q.id))
378 .map((q) => q.label)
379 .join(", ");
380 add(theme.fg("warning", ` Unanswered: ${missing}`));
381 }
382 } else if (q) {
383 add(theme.fg("text", ` ${q.prompt}`));
384 lines.push("");
385 renderOptions();
386 }
387
388 lines.push("");
389 if (!inputMode) {
390 const help = isMulti
391 ? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
392 : " ↑↓ navigate • Enter select • Esc cancel";
393 add(theme.fg("dim", help));
394 }
395 add(theme.fg("accent", "─".repeat(width)));
396
397 cachedLines = lines;
398 return lines;
399 }
400
401 return {
402 render,
403 invalidate: () => {
404 cachedLines = undefined;
405 },
406 handleInput,
407 };
408 },
409 );
410
411 if (result.cancelled) {
412 return {
413 content: [{ type: "text", text: "User cancelled the questionnaire" }],
414 details: result,
415 };
416 }
417
418 const answerLines = result.answers.map((a) => {
419 const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
420 if (a.wasCustom) {
421 return `${qLabel}: user wrote: ${a.label}`;
422 }
423 return `${qLabel}: user selected: ${a.index}. ${a.label}`;
424 });
425
426 return {
427 content: [{ type: "text", text: answerLines.join("\n") }],
428 details: result,
429 };
430 },
431
432 renderCall(args, theme) {
433 const qs = (args.questions as Question[]) || [];
434 const count = qs.length;
435 const labels = qs.map((q) => q.label || q.id).join(", ");
436 let text = theme.fg("toolTitle", theme.bold("questionnaire "));
437 text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
438 if (labels) {
439 text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
440 }
441 return new Text(text, 0, 0);
442 },
443
444 renderResult(result, _options, theme) {
445 const details = result.details as QuestionnaireResult | undefined;
446 if (!details) {
447 const text = result.content[0];
448 return new Text(text?.type === "text" ? text.text : "", 0, 0);
449 }
450 if (details.cancelled) {
451 return new Text(theme.fg("warning", "Cancelled"), 0, 0);
452 }
453 const lines = details.answers.map((a) => {
454 if (a.wasCustom) {
455 return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`;
456 }
457 const display = a.index ? `${a.index}. ${a.label}` : a.label;
458 return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`;
459 });
460 return new Text(lines.join("\n"), 0, 0);
461 },
462 });
463}