This repository has no description
0

Configure Feed

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

tools.pi: add questionnaire extension, remove a-nach-b skill

+469
+6
tools/pi/flake-module.nix
··· 45 45 extensions = [ "!extensions/resistance.ts" ]; 46 46 } 47 47 ]; 48 + extensions = [ 49 + ./questionnaire.ts 50 + ]; 48 51 defaultProvider = "anthropic"; 49 52 defaultModel = "claude-opus-4-6"; 50 53 defaultThinkingLevel = "medium"; ··· 61 64 # Fix keybinding conflicts in extension source 62 65 ${pkgs.gnused}/bin/sed -i 's/"ctrl+u"/"ctrl+shift+u"/' $out/lib/node_modules/shitty-extensions/extensions/ultrathink.ts 63 66 ${pkgs.gnused}/bin/sed -i 's/"ctrl+r"/"ctrl+shift+r"/' $out/lib/node_modules/shitty-extensions/extensions/speedreading.ts 67 + 68 + # Remove a-nach-b skill 69 + rm -rf $out/lib/node_modules/shitty-extensions/skills/a-nach-b 64 70 65 71 # Add hedgedoc skill 66 72 ln -s ${
+463
tools/pi/questionnaire.ts
··· 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 + 8 + import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; 9 + import { 10 + Editor, 11 + type EditorTheme, 12 + Key, 13 + matchesKey, 14 + Text, 15 + truncateToWidth, 16 + } from "@mariozechner/pi-tui"; 17 + import { Type } from "@sinclair/typebox"; 18 + 19 + interface QuestionOption { 20 + value: string; 21 + label: string; 22 + description?: string; 23 + } 24 + 25 + type RenderOption = QuestionOption & { isOther?: boolean }; 26 + 27 + interface Question { 28 + id: string; 29 + label: string; 30 + prompt: string; 31 + options: QuestionOption[]; 32 + allowOther: boolean; 33 + } 34 + 35 + interface Answer { 36 + id: string; 37 + value: string; 38 + label: string; 39 + wasCustom: boolean; 40 + index?: number; 41 + } 42 + 43 + interface QuestionnaireResult { 44 + questions: Question[]; 45 + answers: Answer[]; 46 + cancelled: boolean; 47 + } 48 + 49 + const 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 + 57 + const 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 + 76 + const QuestionnaireParams = Type.Object({ 77 + questions: Type.Array(QuestionSchema, { 78 + description: "Questions to ask the user", 79 + }), 80 + }); 81 + 82 + function 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 + 95 + export 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 + }