Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
7.8 kB · 264 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265/** * 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 "@earendil-works/pi-coding-agent";import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";import { Type } from "typebox";
interface OptionWithDesc { label: string; description?: string;}
type DisplayOption = OptionWithDesc & { isOther?: boolean };
interface QuestionDetails { question: string; options: string[]; answer: string | null; wasCustom?: boolean;}
// Options with labels and optional descriptionsconst OptionSchema = 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(OptionSchema, { description: "Options for the user to choose from" }),});
export default function question(pi: ExtensionAPI) { pi.registerTool({ name: "question", label: "Question", description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.", parameters: QuestionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { if (!ctx.hasUI) { return { content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }], details: { question: params.question, options: params.options.map((o) => o.label), answer: null, } as QuestionDetails, }; }
if (params.options.length === 0) { return { content: [{ type: "text", text: "Error: No options provided" }], details: { question: params.question, options: [], answer: null } as QuestionDetails, }; }
const allOptions: DisplayOption[] = [...params.options, { label: "Type something.", isOther: true }];
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(tui, 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 = params.options.map((o) => o.label);
if (!result) { return { content: [{ type: "text", text: "User cancelled the selection" }], 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: ${result.index}. ${result.answer}` }], details: { question: params.question, options: simpleOptions, answer: result.answer, wasCustom: false, } as QuestionDetails, }; },
renderCall(args, theme, _context) { let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question); const opts = Array.isArray(args.options) ? args.options : []; if (opts.length) { const labels = opts.map((o: OptionWithDesc) => 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); },
renderResult(result, _options, theme, _context) { const details = result.details as QuestionDetails | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); }
if (details.answer === null) { return new Text(theme.fg("warning", "Cancelled"), 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); }, });}