From 1c58db5e23e812ef218fdf810611761ec3e8dcc7 Mon Sep 17 00:00:00 2001 From: Fero Date: Tue, 13 Jan 2026 17:53:39 +0100 Subject: [PATCH] feat(question): enhanced question tool with custom UI (#693) Changes from the original: - Full custom UI with options list instead of simple ctx.ui.select() - Option descriptions: support { label, description? } in addition to strings - Built-in 'Other...' option with inline editor for free-text input - Better UX: Escape in editor returns to options, Escape in options cancels - Numbered options display (1. Option, 2. Option, etc.) - Enhanced result rendering showing selection index --- .../examples/extensions/question.ts | 224 +++++++++++++++++- 1 file changed, 211 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/examples/extensions/question.ts b/packages/coding-agent/examples/extensions/question.ts index eb6694df..517329d4 100644 --- a/packages/coding-agent/examples/extensions/question.ts +++ b/packages/coding-agent/examples/extensions/question.ts @@ -1,23 +1,50 @@ /** - * Question Tool - Let the LLM ask the user a question with options + * Question Tool - Single question with options + * Full custom UI: options list + inline editor for "Type something..." + * Escape in editor returns to options, Escape in options cancels */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; +import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; +interface OptionWithDesc { + label: string; + description?: string; +} + +type DisplayOption = OptionWithDesc & { isOther?: boolean }; + interface QuestionDetails { question: string; options: string[]; answer: string | null; + wasCustom?: boolean; } +// Support both simple strings and objects with descriptions +const OptionSchema = Type.Union([ + Type.String(), + Type.Object({ + label: Type.String({ description: "Display label for the option" }), + description: Type.Optional(Type.String({ description: "Optional description shown below label" })), + }), +]); + const QuestionParams = Type.Object({ question: Type.String({ description: "The question to ask the user" }), - options: Type.Array(Type.String(), { description: "Options for the user to choose from" }), + options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }), }); -export default function (pi: ExtensionAPI) { +// Normalize option to { label, description? } +function normalizeOption(opt: string | { label: string; description?: string }): OptionWithDesc { + if (typeof opt === "string") { + return { label: opt }; + } + return opt; +} + +export default function question(pi: ExtensionAPI) { pi.registerTool({ name: "question", label: "Question", @@ -28,7 +55,11 @@ export default function (pi: ExtensionAPI) { if (!ctx.hasUI) { return { content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }], - details: { question: params.question, options: params.options, answer: null } as QuestionDetails, + details: { + question: params.question, + options: params.options.map((o) => (typeof o === "string" ? o : o.label)), + answer: null, + } as QuestionDetails, }; } @@ -39,25 +70,183 @@ export default function (pi: ExtensionAPI) { }; } - const answer = await ctx.ui.select(params.question, params.options); + // Normalize options + const normalizedOptions = params.options.map(normalizeOption); + const allOptions: DisplayOption[] = [...normalizedOptions, { label: "Type something.", isOther: true }]; - if (answer === undefined) { + const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>( + (tui, theme, _kb, done) => { + let optionIndex = 0; + let editMode = false; + let cachedLines: string[] | undefined; + + 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(editorTheme); + + editor.onSubmit = (value) => { + const trimmed = value.trim(); + if (trimmed) { + done({ answer: trimmed, wasCustom: true }); + } else { + editMode = false; + editor.setText(""); + refresh(); + } + }; + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function handleInput(data: string) { + if (editMode) { + if (matchesKey(data, Key.escape)) { + editMode = false; + editor.setText(""); + refresh(); + return; + } + editor.handleInput(data); + refresh(); + return; + } + + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1); + refresh(); + return; + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(allOptions.length - 1, optionIndex + 1); + refresh(); + return; + } + + if (matchesKey(data, Key.enter)) { + const selected = allOptions[optionIndex]; + if (selected.isOther) { + editMode = true; + refresh(); + } else { + done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 }); + } + return; + } + + if (matchesKey(data, Key.escape)) { + done(null); + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + + const lines: string[] = []; + const add = (s: string) => lines.push(truncateToWidth(s, width)); + + add(theme.fg("accent", "─".repeat(width))); + add(theme.fg("text", ` ${params.question}`)); + lines.push(""); + + for (let i = 0; i < allOptions.length; i++) { + const opt = allOptions[i]; + const selected = i === optionIndex; + const isOther = opt.isOther === true; + const prefix = selected ? theme.fg("accent", "> ") : " "; + + if (isOther && editMode) { + add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`)); + } else if (selected) { + add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`)); + } else { + add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`); + } + + // Show description if present + if (opt.description) { + add(` ${theme.fg("muted", opt.description)}`); + } + } + + if (editMode) { + lines.push(""); + add(theme.fg("muted", " Your answer:")); + for (const line of editor.render(width - 2)) { + add(` ${line}`); + } + } + + lines.push(""); + if (editMode) { + add(theme.fg("dim", " Enter to submit • Esc to go back")); + } else { + add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel")); + } + add(theme.fg("accent", "─".repeat(width))); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { + cachedLines = undefined; + }, + handleInput, + }; + }, + ); + + // Build simple options list for details + const simpleOptions = normalizedOptions.map((o) => o.label); + + if (!result) { return { content: [{ type: "text", text: "User cancelled the selection" }], - details: { question: params.question, options: params.options, answer: null } as QuestionDetails, + details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails, }; } + if (result.wasCustom) { + return { + content: [{ type: "text", text: `User wrote: ${result.answer}` }], + details: { + question: params.question, + options: simpleOptions, + answer: result.answer, + wasCustom: true, + } as QuestionDetails, + }; + } return { - content: [{ type: "text", text: `User selected: ${answer}` }], - details: { question: params.question, options: params.options, answer } as QuestionDetails, + content: [{ type: "text", text: `User selected: ${result.index}. ${result.answer}` }], + details: { + question: params.question, + options: simpleOptions, + answer: result.answer, + wasCustom: false, + } as QuestionDetails, }; }, renderCall(args, theme) { let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question); - if (args.options?.length) { - text += `\n${theme.fg("dim", ` Options: ${args.options.join(", ")}`)}`; + const opts = Array.isArray(args.options) ? args.options : []; + if (opts.length) { + const labels = opts.map((o: string | { label: string }) => (typeof o === "string" ? o : o.label)); + const numbered = [...labels, "Type something."].map((o, i) => `${i + 1}. ${o}`); + text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`; } return new Text(text, 0, 0); }, @@ -73,7 +262,16 @@ export default function (pi: ExtensionAPI) { return new Text(theme.fg("warning", "Cancelled"), 0, 0); } - return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.answer), 0, 0); + if (details.wasCustom) { + return new Text( + theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer), + 0, + 0, + ); + } + const idx = details.options.indexOf(details.answer) + 1; + const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer; + return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0); }, }); }