/** * pi-forms — A reusable "show_form" tool for pi agents. * * Agents call `show_form` with a declarative form schema. The extension pops up * an interactive TUI overlay where the user fills in fields, then returns the * collected values as JSON to the agent. * * Supported field types: * - input — single-line text (uses Input component) * - select — pick one option from a list * - multiselect — pick multiple options from a list * - editor — multi-line text (uses Editor component) * - toggle — two-state toggle (defaults to Yes/No, or provide options: ["On", "Off"]) * * Usage: pi -e ./pi-forms * or: symlink / copy into ~/.pi/agent/extensions/pi-forms/ * * Example tool call from an agent: * * show_form({ * title: "Create Bug", * fields: [ * { name: "summary", type: "input", label: "Summary", required: true }, * { name: "severity", type: "select", label: "Severity", options: ["High","Medium","Low"] }, * { name: "description", type: "editor", label: "Description" } * ] * }) * * Every form automatically includes Cancel and Submit buttons at the bottom. */ import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent"; import { Editor, type EditorTheme, Input, Key, matchesKey, Text, truncateToWidth, type TUI, } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; // ── Types ──────────────────────────────────────────────────────────────────── interface FieldDef { name: string; type: string; // "input" | "select" | "multiselect" | "editor" | "toggle" label: string; required?: boolean; placeholder?: string; defaultValue?: string; options?: string[]; } interface FormResult { [key: string]: string | string[]; } // ── Extension entry point ──────────────────────────────────────────────────── export default function piFormsExtension(pi: ExtensionAPI) { pi.registerTool({ name: "show_form", label: "Show Form", description: "Show an interactive form overlay to collect structured input from the user. " + "Supports field types: input (single-line text), select (pick one option), " + "multiselect (pick multiple options), editor (multi-line text), toggle (two-state, defaults to Yes/No or provide custom options). " + "Returns a JSON object keyed by field name.", promptSnippet: "Show an interactive form to collect structured input from the user", promptGuidelines: [ "Use show_form when you need to collect multiple pieces of information from the user at once, instead of asking questions one by one.", "Each field needs a unique `name` (used as the JSON key), a `type`, and a `label`.", "Supported types: input (single-line), select (pick one from list, requires `options`), multiselect (pick multiple from list, requires `options`), editor (multi-line), toggle (two-state: defaults to Yes/No, or pass `options: [\"On\", \"Off\"]` for custom labels).", "Set `required: true` on fields that must be filled before the user can submit.", "Every form automatically includes Cancel and Submit buttons — do not add a toggle field for form submission.", ], parameters: Type.Object({ title: Type.String({ description: "Form title shown at the top" }), description: Type.Optional( Type.String({ description: "Optional description shown below the title" }), ), fields: Type.Array( Type.Object({ name: Type.String({ description: "Unique field key (used in returned JSON)" }), type: Type.String({ description: "Field type: input, select, multiselect, editor, or toggle" }), label: Type.String({ description: "Label displayed to the user" }), required: Type.Optional(Type.Boolean({ description: "Whether the field must be filled (default: false)" })), placeholder: Type.Optional(Type.String({ description: "Placeholder text for input fields" })), defaultValue: Type.Optional(Type.String({ description: "Default value" })), options: Type.Optional(Type.Array(Type.String(), { description: "Options for select fields" })), }), { description: "Form fields" }, ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { if (!ctx.hasUI) { return { content: [{ type: "text", text: "Error: UI not available (non-interactive mode)" }], details: {}, }; } if (!params.fields || params.fields.length === 0) { return { content: [{ type: "text", text: "Error: No fields provided" }], details: {}, }; } const fields: FieldDef[] = params.fields.map((f) => ({ ...f, required: f.required ?? false, })); const result = await ctx.ui.custom( (tui, theme, _kb, done) => new FormOverlay(tui, theme, params.title, params.description ?? "", fields, done), { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 50, maxHeight: "85%", }, }, ); if (!result) { return { content: [{ type: "text", text: "User cancelled the form." }], details: { cancelled: true }, }; } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: { formData: result }, }; }, renderCall(args, theme, _context) { const fields = (args.fields as FieldDef[]) || []; const title = (args.title as string) || "Form"; let text = theme.fg("toolTitle", theme.bold("show_form ")); text += theme.fg("accent", title); text += theme.fg("muted", ` (${fields.length} field${fields.length !== 1 ? "s" : ""})`); return new Text(text, 0, 0); }, renderResult(result, _options, theme, _context) { const details = result.details as { cancelled?: boolean; formData?: FormResult } | undefined; if (details?.cancelled) { return new Text(theme.fg("warning", "Form cancelled by user"), 0, 0); } if (!details?.formData) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } const lines = Object.entries(details.formData).map(([key, val]) => { const display = Array.isArray(val) ? val.join(", ") : String(val); const truncated = display.length > 60 ? display.slice(0, 57) + "..." : display; return `${theme.fg("success", "✓ ")}${theme.fg("accent", key)}: ${truncated}`; }); return new Text(lines.join("\n"), 0, 0); }, }); } // ── Form Overlay Component ─────────────────────────────────────────────────── class FormOverlay { private tui: TUI; private theme: Theme; private title: string; private description: string; private fields: FieldDef[]; private done: (result: FormResult | null) => void; // Field state private activeField = 0; // 0..fields.length-1 = fields, fields.length = button bar private values: Map = new Map(); private inputs: Map = new Map(); private editors: Map = new Map(); private selectIndices: Map = new Map(); private multiSelectChecked: Map> = new Map(); private multiSelectCursor: Map = new Map(); private scrollOffset = 0; private focusedButton: "cancel" | "submit" = "submit"; // which button is focused in the button bar // Render cache private cachedLines?: string[]; private cachedWidth?: number; constructor( tui: TUI, theme: Theme, title: string, description: string, fields: FieldDef[], done: (result: FormResult | null) => void, ) { this.tui = tui; this.theme = theme; this.title = title; this.description = description; this.fields = fields; this.done = done; // Initialise field state for (let i = 0; i < fields.length; i++) { const f = fields[i]; switch (f.type) { case "input": { const input = new Input(); if (f.defaultValue) input.setValue(f.defaultValue); this.inputs.set(i, input); this.values.set(f.name, f.defaultValue ?? ""); break; } case "editor": { 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(tui, editorTheme, { paddingX: 0 }); editor.disableSubmit = true; // Enter adds newline, not submit if (f.defaultValue) editor.setText(f.defaultValue); this.editors.set(i, editor); this.values.set(f.name, f.defaultValue ?? ""); break; } case "select": { this.selectIndices.set(i, 0); const defaultOpt = f.options?.[0] ?? ""; this.values.set(f.name, f.defaultValue ?? defaultOpt); // If defaultValue matches an option, set the index if (f.defaultValue && f.options) { const idx = f.options.indexOf(f.defaultValue); if (idx >= 0) this.selectIndices.set(i, idx); } break; } case "multiselect": { this.multiSelectCursor.set(i, 0); const checked = new Set(); // Parse defaultValue as comma-separated list of pre-selected options if (f.defaultValue && f.options) { const defaults = f.defaultValue.split(",").map((s) => s.trim()); for (const d of defaults) { const idx = f.options.indexOf(d); if (idx >= 0) checked.add(idx); } } this.multiSelectChecked.set(i, checked); this.values.set(f.name, this.getMultiSelectValue(i)); break; } case "toggle": { const toggleOpts = f.options && f.options.length === 2 ? f.options : ["Yes", "No"]; const defaultVal = f.defaultValue ?? toggleOpts[0]; this.values.set(f.name, defaultVal); break; } } } } /** Get the string[] value for a multiselect field */ private getMultiSelectValue(fieldIndex: number): string[] { const field = this.fields[fieldIndex]; const options = field.options ?? []; const checked = this.multiSelectChecked.get(fieldIndex) ?? new Set(); return options.filter((_, j) => checked.has(j)); } /** Total number of focusable slots: all fields + the button bar */ private get totalSlots(): number { return this.fields.length + 1; } /** Whether the button bar is currently focused */ private get isButtonBarActive(): boolean { return this.activeField === this.fields.length; } // ── Input handling ───────────────────────────────────────────────────── handleInput(data: string): void { // Global: Escape cancels if (matchesKey(data, Key.escape)) { this.done(null); return; } // Global: Ctrl+S submits if (matchesKey(data, Key.ctrl("s"))) { this.trySubmit(); return; } // Tab / Shift+Tab to cycle fields + button bar if (matchesKey(data, Key.tab)) { this.syncCurrentFieldValue(); this.activeField = (this.activeField + 1) % this.totalSlots; if (this.isButtonBarActive) this.focusedButton = "submit"; this.invalidate(); this.tui.requestRender(); return; } if (matchesKey(data, Key.shift("tab"))) { this.syncCurrentFieldValue(); this.activeField = (this.activeField - 1 + this.totalSlots) % this.totalSlots; if (this.isButtonBarActive) this.focusedButton = "submit"; this.invalidate(); this.tui.requestRender(); return; } // Handle button bar input if (this.isButtonBarActive) { if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) { this.focusedButton = this.focusedButton === "cancel" ? "submit" : "cancel"; } else if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { if (this.focusedButton === "cancel") { this.done(null); return; } else { this.trySubmit(); return; } } this.invalidate(); this.tui.requestRender(); return; } const field = this.fields[this.activeField]; // Route to active field switch (field.type) { case "input": { const input = this.inputs.get(this.activeField); if (input) { if (matchesKey(data, Key.enter)) { // Enter on input field = advance to next field or button bar this.syncCurrentFieldValue(); this.activeField++; if (this.isButtonBarActive) this.focusedButton = "submit"; } else { input.handleInput(data); this.values.set(field.name, input.getValue()); } } break; } case "editor": { const editor = this.editors.get(this.activeField); if (editor) { editor.handleInput(data); this.values.set(field.name, editor.getText()); } break; } case "select": { const options = field.options ?? []; let idx = this.selectIndices.get(this.activeField) ?? 0; if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) { idx = (idx - 1 + options.length) % options.length; this.selectIndices.set(this.activeField, idx); this.values.set(field.name, options[idx]); } else if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) { idx = (idx + 1) % options.length; this.selectIndices.set(this.activeField, idx); this.values.set(field.name, options[idx]); } else if (matchesKey(data, Key.enter)) { this.syncCurrentFieldValue(); this.activeField++; if (this.isButtonBarActive) this.focusedButton = "submit"; } break; } case "multiselect": { const msOptions = field.options ?? []; let msCursor = this.multiSelectCursor.get(this.activeField) ?? 0; const msChecked = this.multiSelectChecked.get(this.activeField) ?? new Set(); if (matchesKey(data, Key.up)) { msCursor = (msCursor - 1 + msOptions.length) % msOptions.length; this.multiSelectCursor.set(this.activeField, msCursor); } else if (matchesKey(data, Key.down)) { msCursor = (msCursor + 1) % msOptions.length; this.multiSelectCursor.set(this.activeField, msCursor); } else if (matchesKey(data, Key.space)) { // Toggle the option under cursor if (msChecked.has(msCursor)) { msChecked.delete(msCursor); } else { msChecked.add(msCursor); } this.multiSelectChecked.set(this.activeField, msChecked); this.values.set(field.name, this.getMultiSelectValue(this.activeField)); } else if (matchesKey(data, Key.enter)) { this.syncCurrentFieldValue(); this.activeField++; if (this.isButtonBarActive) this.focusedButton = "submit"; } break; } case "toggle": { const toggleOpts = field.options && field.options.length === 2 ? field.options : ["Yes", "No"]; const currentVal = this.values.get(field.name) as string; if ( matchesKey(data, Key.left) || matchesKey(data, Key.right) || matchesKey(data, Key.space) ) { this.values.set(field.name, currentVal === toggleOpts[0] ? toggleOpts[1] : toggleOpts[0]); } else if (matchesKey(data, Key.enter)) { this.activeField++; if (this.isButtonBarActive) this.focusedButton = "submit"; } break; } } this.invalidate(); this.tui.requestRender(); } private syncCurrentFieldValue(): void { if (this.isButtonBarActive) return; const field = this.fields[this.activeField]; if (field.type === "input") { const input = this.inputs.get(this.activeField); if (input) this.values.set(field.name, input.getValue()); } else if (field.type === "editor") { const editor = this.editors.get(this.activeField); if (editor) this.values.set(field.name, editor.getText()); } } private trySubmit(): void { // Validate required fields for (const field of this.fields) { if (!field.required) continue; const val = this.values.get(field.name); if (val === undefined || val === "" || val === null || (Array.isArray(val) && val.length === 0)) { // Focus the first empty required field const idx = this.fields.indexOf(field); this.activeField = idx; this.invalidate(); this.tui.requestRender(); return; } } // Build result const result: FormResult = {}; for (const field of this.fields) { result[field.name] = this.values.get(field.name) ?? ""; } this.done(result); } // ── Rendering ────────────────────────────────────────────────────────── render(width: number): string[] { if (this.cachedLines && this.cachedWidth === width) { return this.cachedLines; } const th = this.theme; const innerW = Math.max(1, width - 4); // padding inside borders const lines: string[] = []; const add = (s: string) => lines.push(truncateToWidth(` ${s}`, width)); const addRaw = (s: string) => lines.push(truncateToWidth(s, width)); // Top border addRaw(th.fg("accent", "╭" + "─".repeat(width - 2) + "╮")); // Title add(th.fg("accent", th.bold(this.title))); // Description if (this.description) { add(th.fg("muted", this.description)); } // Separator addRaw(th.fg("accent", "├" + "─".repeat(width - 2) + "┤")); // Fields for (let i = 0; i < this.fields.length; i++) { const field = this.fields[i]; const isActive = i === this.activeField; const reqMark = field.required ? th.fg("error", " *") : ""; // Field label const focusIndicator = isActive ? th.fg("accent", "▶ ") : " "; const labelColor = isActive ? "accent" : "text"; add(`${focusIndicator}${th.fg(labelColor, field.label)}${reqMark}`); // Field value / widget switch (field.type) { case "input": { const input = this.inputs.get(i); if (input && isActive) { // Render the Input component inline const inputLines = input.render(innerW - 4); for (const line of inputLines) { add(` ${line}`); } } else { const val = this.values.get(field.name) as string; const display = val || th.fg("dim", field.placeholder || "(empty)"); add(` ${display}`); } break; } case "editor": { const editor = this.editors.get(i); if (editor && isActive) { const editorLines = editor.render(innerW - 2); for (const line of editorLines) { add(` ${line}`); } } else { const val = this.values.get(field.name) as string; if (val) { const preview = val.split("\n").slice(0, 2); for (const line of preview) { add(` ${th.fg("muted", truncateToWidth(line, innerW - 6))}`); } if (val.split("\n").length > 2) { add(` ${th.fg("dim", "...")}`); } } else { add(` ${th.fg("dim", "(empty — Tab here and type)")}`); } } break; } case "select": { const options = field.options ?? []; const selectedIdx = this.selectIndices.get(i) ?? 0; if (isActive) { // Show all options with cursor for (let j = 0; j < options.length; j++) { const prefix = j === selectedIdx ? th.fg("accent", " ● ") : th.fg("dim", " ○ "); const color = j === selectedIdx ? "accent" : "text"; add(` ${prefix}${th.fg(color, options[j])}`); } } else { const val = this.values.get(field.name) as string; add(` ${val || th.fg("dim", "(none)")}`); } break; } case "multiselect": { const msOptions = field.options ?? []; const msChecked = this.multiSelectChecked.get(i) ?? new Set(); const msCursor = this.multiSelectCursor.get(i) ?? 0; if (isActive) { for (let j = 0; j < msOptions.length; j++) { const isChecked = msChecked.has(j); const isCursor = j === msCursor; const checkbox = isChecked ? th.fg("success", "☑") : th.fg("dim", "☐"); const pointer = isCursor ? th.fg("accent", "▸ ") : " "; const color = isCursor ? "accent" : isChecked ? "success" : "text"; add(` ${pointer}${checkbox} ${th.fg(color, msOptions[j])}`); } add(` ${th.fg("dim", "Space = toggle • Enter = done")}`); } else { const selected = this.getMultiSelectValue(i); const display = selected.length > 0 ? selected.join(", ") : th.fg("dim", "(none selected)"); add(` ${display}`); } break; } case "toggle": { const toggleOpts = field.options && field.options.length === 2 ? field.options : ["Yes", "No"]; const val = this.values.get(field.name) as string; const isFirst = val === toggleOpts[0]; if (isActive) { const first = isFirst ? th.bg("selectedBg", th.fg("success", ` ${toggleOpts[0]} `)) : th.fg("dim", ` ${toggleOpts[0]} `); const second = !isFirst ? th.bg("selectedBg", th.fg("error", ` ${toggleOpts[1]} `)) : th.fg("dim", ` ${toggleOpts[1]} `); add(` ${first} ${second} ${th.fg("dim", "← → or Space to toggle")}`); } else { add(` ${isFirst ? th.fg("success", toggleOpts[0]) : th.fg("error", toggleOpts[1])}`); } break; } } // Spacing between fields if (i < this.fields.length - 1) { lines.push(""); } } // Validation message const missingRequired = this.fields.filter( (f) => f.required && (this.values.get(f.name) === "" || this.values.get(f.name) === undefined), ); if (missingRequired.length > 0) { lines.push(""); add( th.fg("warning", `⚠ Required: ${missingRequired.map((f) => f.label).join(", ")}`), ); } // Separator addRaw(th.fg("accent", "├" + "─".repeat(width - 2) + "┤")); // Button bar { const isActive = this.isButtonBarActive; const cancelFocused = isActive && this.focusedButton === "cancel"; const submitFocused = isActive && this.focusedButton === "submit"; const cancelLabel = cancelFocused ? th.bg("selectedBg", th.fg("error", " Cancel ")) : th.fg(isActive ? "text" : "dim", " Cancel "); const submitLabel = submitFocused ? th.bg("selectedBg", th.fg("success", " Submit ")) : th.fg(isActive ? "text" : "dim", " Submit "); const focusIndicator = isActive ? th.fg("accent", "▶ ") : " "; add(`${focusIndicator}${cancelLabel} ${submitLabel}`); } // Help lines.push(""); add(th.fg("dim", "Tab/Shift+Tab = cycle fields • Ctrl+S = submit • Esc = cancel")); // Bottom border addRaw(th.fg("accent", "╰" + "─".repeat(width - 2) + "╯")); this.cachedLines = lines; this.cachedWidth = width; return lines; } invalidate(): void { this.cachedLines = undefined; this.cachedWidth = undefined; } }