mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 21:03:19 +00:00
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
This commit is contained in:
parent
e8f1322eee
commit
1c58db5e23
1 changed files with 211 additions and 13 deletions
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue