This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / tools / pi / questionnaire.ts
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}