/** * Ask User Extension * * Registers the `ask_user` tool that lets the LLM ask the user an interactive * question with optional multiple-choice options (2-5), freeform text, and * cancellation support. * * Renders a custom TUI overlay when available; falls back to dialog-based * select/input in RPC or headless modes. */ import type { ExtensionAPI, ExtensionContext, Theme, } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { Container, type EditorTheme, Editor, Key, type KeybindingsManager, matchesKey, type OverlayHandle, Spacer, Text, type TUI, truncateToWidth, } from "@earendil-works/pi-tui"; import { promptGuidelines, promptSnippet } from "./prompt.ts"; // ── Types ────────────────────────────────────────────────────────────────── export interface QuestionOption { title: string; description?: string; } export interface AskParams { question: string; context?: string; options?: QuestionOption[]; allowMultiple?: boolean; allowFreeform?: boolean; timeout?: number; } export type AskResponse = | { kind: "selection"; selections: string[]; comment?: string } | { kind: "freeform"; text: string }; export interface AskToolDetails { question: string; context?: string; options: QuestionOption[]; response: AskResponse | null; cancelled: boolean; } type AskUIResult = AskResponse; // ── Constants ────────────────────────────────────────────────────────────── const FREEFORM_LABEL = "✏️ Type a custom response..."; const MAX_OVERLAY_HEIGHT_RATIO = 0.8; const DEFAULT_MAX_VISIBLE_ROWS = 12; // ── Helpers ──────────────────────────────────────────────────────────────── function coerceOption(raw: unknown): QuestionOption | null { if (typeof raw === "string") { const title = raw.trim(); return title ? { title } : null; } if (raw && typeof raw === "object") { const record = raw as Record; for (const key of ["title", "label", "text", "value", "name"]) { const val = record[key]; if (typeof val === "string" && val.trim()) { const desc = typeof record.description === "string" && record.description.trim() ? record.description : undefined; return desc ? { title: val.trim(), description: desc } : { title: val.trim() }; } } } return null; } function createSelectionResponse( selections: string[], comment?: string, ): AskResponse | null { const cleaned = selections.map((s) => s.trim()).filter(Boolean); if (cleaned.length === 0) return null; return comment?.trim() ? { kind: "selection", selections: cleaned, comment: comment.trim() } : { kind: "selection", selections: cleaned }; } function createFreeformResponse(text: string | undefined): AskResponse | null { const trimmed = text?.trim(); return trimmed ? { kind: "freeform", text: trimmed } : null; } function formatResponseSummary(response: AskResponse): string { if (response.kind === "freeform") return response.text; const sel = response.selections.join(", "); return response.comment ? `${sel} — ${response.comment}` : sel; } function isSelectionResponse( response: AskResponse, ): response is Extract { return response.kind === "selection"; } function createSelectListTheme(theme: Theme) { return { selectedPrefix: (t: string) => theme.fg("accent", t), selectedText: (t: string) => theme.fg("accent", t), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }; } function createEditorTheme(theme: Theme): EditorTheme { return { borderColor: (s: string) => theme.fg("accent", s), selectList: createSelectListTheme(theme), }; } // ── Dialog fallback (RPC / headless) ────────────────────────────────────── async function askViaDialogs( ui: ExtensionContext["ui"], question: string, context: string | undefined, options: QuestionOption[], allowMultiple: boolean, allowFreeform: boolean, timeout?: number, ): Promise { const opts = timeout ? { timeout } : undefined; const prompt = context ? `${question}\n\nContext:\n${context}` : question; if (allowMultiple && options.length > 0) { const list = options.map((o, i) => `${i + 1}. ${o.title}`).join("\n"); const raw = await ui.input( `${prompt}\n\nOptions:\n${list}\n\nType your selections (comma-separated)...`, "", opts, ); if (!raw) return null; const selections = raw .split(",") .map((s) => s.trim()) .filter(Boolean) .map((selection) => { const index = Number(selection) - 1; return Number.isInteger(index) && options[index] ? options[index]!.title : selection; }); return selections.length > 0 ? { kind: "selection", selections } : null; } if (options.length > 0) { const labels = [...options.map((o) => o.title)]; if (allowFreeform) labels.push(FREEFORM_LABEL); const selected = await ui.select(prompt, labels, opts); if (!selected) return null; if (selected === FREEFORM_LABEL) { const answer = await ui.input(prompt, "Type your answer...", opts); if (!answer) return null; return { kind: "freeform", text: answer.trim() }; } return { kind: "selection", selections: [selected] }; } // No options: pure freeform const answer = await ui.input(prompt, "Type your answer...", opts); if (!answer) return null; return { kind: "freeform", text: answer.trim() }; } // ── TUI Component ────────────────────────────────────────────────────────── interface SelectListCallbacks { onSubmit: (title: string) => void; onCancel: () => void; } /** Simple select list component for the ask_user overlay. */ class SimpleSelectList { private options: QuestionOption[]; private allowFreeform: boolean; private selectedIndex = 0; private filter = ""; callbacks: SelectListCallbacks = { onSubmit: () => {}, onCancel: () => {} }; constructor(options: QuestionOption[], allowFreeform: boolean) { this.options = options; this.allowFreeform = allowFreeform; } private getFilteredOptions(): Array { const results: Array = []; const query = this.filter.toLowerCase(); for (const opt of this.options) { if ( !query || opt.title.toLowerCase().includes(query) || opt.description?.toLowerCase().includes(query) ) { results.push(opt); } } if (this.allowFreeform && (!query || FREEFORM_LABEL.toLowerCase().includes(query))) { results.push("freeform"); } return results; } private count(): number { return this.getFilteredOptions().length; } handleInput(data: string): void { const filtered = this.getFilteredOptions(); const count = filtered.length; // Esc clears filter first, then cancels if (matchesKey(data, Key.escape)) { if (this.filter) { this.filter = ""; this.selectedIndex = 0; return; } this.callbacks.onCancel(); return; } if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("k"))) { if (count > 0) { this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1; } return; } if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("j"))) { if (count > 0) { this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1; } return; } // Number keys 1-9 for quick selection const numMatch = /^[1-9]$/.exec(data); if (numMatch && count > 0) { const idx = Number.parseInt(numMatch[0], 10) - 1; if (idx >= 0 && idx < count) { this.selectedIndex = idx; const item = filtered[this.selectedIndex]; if (item === "freeform") { this.callbacks.onSubmit(FREEFORM_LABEL); return; } } } if (matchesKey(data, Key.enter)) { if (count === 0) return; const item = filtered[this.selectedIndex]; if (item === "freeform") { this.callbacks.onSubmit(FREEFORM_LABEL); } else { this.callbacks.onSubmit(item.title); } return; } if (matchesKey(data, Key.backspace)) { this.filter = this.filter.slice(0, -1); this.selectedIndex = 0; return; } // Printable characters → add to filter if (data.length === 1 && data >= " ") { this.filter += data; this.selectedIndex = 0; } } render(width: number, theme: Theme): string[] { const filtered = this.getFilteredOptions(); // Clamp selected index if (filtered.length > 0) { this.selectedIndex = Math.max( 0, Math.min(this.selectedIndex, filtered.length - 1), ); } const lines: string[] = []; const innerWidth = Math.max(1, width - 4); // Filter bar if (this.filter) { lines.push( ` ${theme.fg("dim", "Filter: " + this.filter)}${theme.fg("dim", " (esc to clear)")}`, ); lines.push(""); } else if (filtered.length > 0) { lines.push(` ${theme.fg("dim", "Type to filter, ↑↓ to navigate, Enter to select")}`); lines.push(""); } // Options for (let i = 0; i < Math.min(filtered.length, DEFAULT_MAX_VISIBLE_ROWS); i++) { const item = filtered[i]; const isSelected = i === this.selectedIndex; const pointer = isSelected ? theme.fg("accent", "→") : " "; if (item === "freeform") { const label = truncateToWidth(FREEFORM_LABEL, innerWidth - 4, ""); lines.push(` ${pointer} ${label}`); } else { const num = `${i + 1}.`; const title = truncateToWidth(item.title, innerWidth - 6, ""); if (isSelected) { lines.push(` ${pointer} ${theme.fg("accent", num)} ${theme.fg("accent", title)}`); if (item.description) { const desc = truncateToWidth(item.description, innerWidth - 6, ""); lines.push(` ${theme.fg("muted", desc)}`); } } else { lines.push(` ${pointer} ${theme.fg("dim", num)} ${title}`); if (item.description) { const desc = truncateToWidth(item.description, innerWidth - 6, ""); lines.push(` ${theme.fg("dim", desc)}`); } } } } if (filtered.length === 0) { lines.push(` ${theme.fg("warning", "No matching options")}`); } return lines; } } // ── Top-level ask component (Container-based) ───────────────────────────── class AskComponent extends Container { private question: string; private context?: string; private options: QuestionOption[]; private allowFreeform: boolean; private selectList: SimpleSelectList; private theme: Theme; private tui: TUI; private keybindings: KeybindingsManager; private onDone: (result: AskUIResult | null) => void; // Freeform mode private mode: "select" | "freeform" = "select"; private editor?: Editor; private titleText: Text; private questionText: Text; private contextText?: Text; private footerText: Text; constructor( question: string, context: string | undefined, options: QuestionOption[], allowFreeform: boolean, tui: TUI, theme: Theme, keybindings: KeybindingsManager, onDone: (result: AskUIResult | null) => void, ) { super(); this.question = question; this.context = context; this.options = options; this.allowFreeform = allowFreeform; this.selectList = new SimpleSelectList(options, allowFreeform); this.theme = theme; this.tui = tui; this.keybindings = keybindings; this.onDone = onDone; // Wire select list callbacks this.selectList.callbacks.onSubmit = (title) => { if (title === FREEFORM_LABEL) { this.enterFreeform(); } else { onDone({ kind: "selection", selections: [title] }); } }; this.selectList.callbacks.onCancel = () => onDone(null); // Layout this.titleText = new Text("", 1, 0); this.addChild(this.titleText); this.addChild(new Spacer(1)); this.questionText = new Text("", 1, 0); this.addChild(this.questionText); if (this.context) { this.addChild(new Spacer(1)); this.contextText = new Text("", 1, 0); this.addChild(this.contextText); } this.addChild(new Spacer(1)); this.footerText = new Text("", 1, 0); this.addChild(this.footerText); this.updateStaticText(); } private updateStaticText(): void { const theme = this.theme; if (this.mode === "select") { this.titleText.setText(theme.fg("accent", theme.bold("Question"))); } else { this.titleText.setText(theme.fg("accent", theme.bold("Custom response"))); } this.questionText.setText(theme.fg("text", theme.bold(this.question))); if (this.contextText && this.context) { this.contextText.setText( `${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`, ); } const hints = this.mode === "select" ? theme.fg("dim", "esc: cancel • enter: select • type: filter") : theme.fg("dim", "esc: back • enter: submit"); this.footerText.setText(hints); } private enterFreeform(): void { this.mode = "freeform"; this.editor = new Editor(this.tui, createEditorTheme(this.theme)); this.editor.onSubmit = (text: string) => { const trimmed = text.trim(); if (trimmed) { this.onDone({ kind: "freeform", text: trimmed }); } else { this.onDone(null); } }; this.updateStaticText(); this.invalidate(); } handleInput(data: string): void { if (this.mode === "freeform" && this.editor) { if (matchesKey(data, Key.escape)) { this.mode = "select"; this.editor = undefined; this.updateStaticText(); this.invalidate(); this.tui.requestRender(); return; } if (this.keybindings.matches(data, "tui.select.cancel")) { this.onDone(null); return; } this.editor.handleInput(data); this.tui.requestRender(); return; } if (this.keybindings.matches(data, "tui.select.cancel")) { this.onDone(null); return; } this.selectList.handleInput(data); this.invalidate(); this.tui.requestRender(); } render(width: number): string[] { const innerWidth = Math.max(1, width - 4); // Build the list lines this.updateStaticText(); const topSection = super.render(innerWidth); const listLines = this.mode === "select" ? this.selectList.render(innerWidth, this.theme) : this.editor ? this.editor.render(innerWidth) : []; const lines = [...topSection.slice(0, -1), ...listLines, ...topSection.slice(-1)]; // Frame with a box border const borderColor = (s: string) => this.theme.fg("accent", s); const totalWidth = Math.max(width, 1); const boxWidth = Math.max(totalWidth - 2, 1); return [ borderColor(`╭${"─".repeat(boxWidth)}╮`), ...lines.map( (line) => `${borderColor("│")}${truncateToWidth(line, boxWidth, "", true)}${borderColor("│")}`, ), borderColor(`╰${"─".repeat(boxWidth)}╯`), ]; } } // ── Extension ────────────────────────────────────────────────────────────── export default function askUserExtension(pi: ExtensionAPI): void { pi.registerTool({ name: "ask_user", label: "Ask User", description: "Ask the user a focused question with optional multiple-choice answers (2-5 options). " + "Use this to gather decisions interactively when the user's preference is needed. " + "Ask exactly one question per call.", promptSnippet, promptGuidelines, // Sequential so the model can't batch ask_user with side-effecting tools executionMode: "sequential", parameters: Type.Object({ question: Type.String({ description: "The question to ask the user" }), context: Type.Optional( Type.String({ description: "Relevant context to show before the question (summary of findings)", }), ), options: Type.Optional( Type.Array( Type.Object({ title: Type.String({ description: "Short title for this option" }), description: Type.Optional( Type.String({ description: "Longer description explaining this option" }), ), }), { description: "2-5 multiple-choice options", minItems: 2, maxItems: 5, }, ), ), allowMultiple: Type.Optional( Type.Boolean({ description: "Allow selecting multiple options. Default: false", }), ), allowFreeform: Type.Optional( Type.Boolean({ description: "Add a freeform text option. Default: true", }), ), timeout: Type.Optional( Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired.", }), ), }), async execute( _toolCallId, params, signal, onUpdate, ctx, ) { // Check for early abort if (signal?.aborted) { return { content: [{ type: "text" as const, text: "Cancelled" }], details: { question: params.question, options: [], response: null, cancelled: true, } satisfies AskToolDetails, }; } const { question, context: rawContext, options: rawOptions = [], allowMultiple = false, allowFreeform = true, timeout, } = params; const context = rawContext?.trim() || undefined; const options = rawOptions .map((o: unknown) => coerceOption(o)) .filter((o): o is QuestionOption => o !== null); // Mode guard: no UI available if (!ctx.hasUI || !ctx.ui) { const optText = options.length > 0 ? `\n\nOptions:\n${options.map((o: QuestionOption) => o.title).join("\n")}` : ""; const ctxText = context ? `\n\nContext:\n${context}` : ""; return { content: [ { type: "text" as const, text: `Question requires interactive mode:\n\n${question}${ctxText}${optText}`, }, ], isError: true, details: { question, context, options, response: null, cancelled: true, } satisfies AskToolDetails, }; } // Pure freeform (no options) if (options.length === 0) { const prompt = context ? `${question}\n\nContext:\n${context}` : question; const answer = await ctx.ui.input(prompt, "Type your answer...", timeout ? { timeout } : undefined); const response = createFreeformResponse(answer); if (!response) { pi.events.emit("ask:cancelled", { question, context, options }); return { content: [{ type: "text" as const, text: "User cancelled the question" }], details: { question, context, options, response: null, cancelled: true, } satisfies AskToolDetails, }; } pi.events.emit("ask:answered", { question, context, response }); return { content: [ { type: "text" as const, text: `User answered: ${formatResponseSummary(response)}`, }, ], details: { question, context, options, response, cancelled: false, } satisfies AskToolDetails, }; } // Options-driven flow: try custom TUI first onUpdate?.({ content: [{ type: "text" as const, text: "Waiting for user input..." }], details: { question, context, options, response: null, cancelled: false }, }); let result: AskUIResult | null; if (allowMultiple) { // Pi's standard input dialog is the reliable cross-mode multi-select // fallback; the custom overlay intentionally handles single-select. result = await askViaDialogs( ctx.ui, question, context, options, true, allowFreeform, timeout, ); } else try { // Only use overlay in TUI mode; inline otherwise const isTui = ctx.mode === "tui"; const customResult = await ctx.ui.custom( (tui, theme, keybindings, done) => { let settled = false; let timer: ReturnType | undefined; const onAbort = () => finish(null); const finish = (value: AskUIResult | null) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); signal?.removeEventListener("abort", onAbort); done(value); }; signal?.addEventListener("abort", onAbort, { once: true }); if (timeout && timeout > 0) timer = setTimeout(() => finish(null), timeout); return new AskComponent( question, context, options, allowFreeform, tui, theme, keybindings, finish, ); }, isTui ? { overlay: true, overlayOptions: { anchor: "center", width: "88%", minWidth: 40, maxHeight: `${Math.floor(MAX_OVERLAY_HEIGHT_RATIO * 100)}%`, margin: 1, }, } : undefined, ); if (customResult !== undefined) { result = customResult; } else { // RPC/headless: ctx.ui.custom() returned undefined → degrade result = await askViaDialogs( ctx.ui, question, context, options, allowMultiple, allowFreeform, timeout, ); } } catch (error) { const msg = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error); return { content: [{ type: "text" as const, text: `Ask tool failed: ${msg}` }], isError: true, details: { error: msg }, }; } if (result === null) { pi.events.emit("ask:cancelled", { question, context, options }); return { content: [ { type: "text" as const, text: "User cancelled the question" }, ], details: { question, context, options, response: null, cancelled: true, } satisfies AskToolDetails, }; } pi.events.emit("ask:answered", { question, context, response: result }); return { content: [ { type: "text" as const, text: `User answered: ${formatResponseSummary(result)}`, }, ], details: { question, context, options, response: result, cancelled: false, } satisfies AskToolDetails, }; }, renderCall(args, theme) { const question = (args.question as string) || ""; const rawOptions: unknown[] = Array.isArray(args.options) ? args.options : []; let text = theme.fg("toolTitle", theme.bold("ask_user ")); text += theme.fg("muted", question); if (rawOptions.length > 0) { const labels = rawOptions .map((o) => coerceOption(o)?.title ?? "") .join(", "); text += "\n" + theme.fg("dim", ` ${rawOptions.length} option(s): ${labels}`); } if (args.allowMultiple) { text += theme.fg("dim", " [multi-select]"); } return new Text(text, 0, 0); }, renderResult(result, options, theme) { const details = result.details as | (AskToolDetails & { error?: string }) | undefined; if (details?.error) { return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0); } if (options.isPartial) { const waitText = result.content ?.map((p) => (p.type === "text" ? p.text : "")) .join("\n") .trim() || "Waiting for user input..."; return new Text(theme.fg("muted", waitText), 0, 0); } if (!details || details.cancelled || !details.response) { return new Text(theme.fg("warning", "Cancelled"), 0, 0); } const response = details.response; let text = theme.fg("success", "✓ "); if (response.kind === "freeform") { text += theme.fg("muted", "(wrote) "); } text += theme.fg("accent", formatResponseSummary(response)); if (options.expanded && isSelectionResponse(response)) { text += "\n" + theme.fg("dim", `Q: ${details.question}`); if (details.context) { Text; text += "\n" + theme.fg("dim", details.context); } if (details.options.length > 0) { const selected = new Set(response.selections); text += "\n" + theme.fg("dim", "Options:"); for (const opt of details.options) { const desc = opt.description ? ` — ${opt.description}` : ""; const marker = selected.has(opt.title) ? theme.fg("success", "●") : theme.fg("dim", "○"); text += `\n ${marker} ${theme.fg("dim", opt.title)}${theme.fg("dim", desc)}`; } if (response.comment) { text += `\n${theme.fg("dim", "Comment:")} ${theme.fg("dim", response.comment)}`; } } } return new Text(text, 0, 0); }, }); }