add ask-user-question extension from amosblomqvist/pi-config
This commit is contained in:
641
extensions/ask-user-question/index.ts
Normal file
641
extensions/ask-user-question/index.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Editor,
|
||||
type EditorTheme,
|
||||
Key,
|
||||
Text,
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { Type } from "typebox";
|
||||
|
||||
interface AskOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface DisplayOption extends AskOption {
|
||||
id: string;
|
||||
index?: number;
|
||||
isOther?: boolean;
|
||||
isSubmit?: boolean;
|
||||
}
|
||||
|
||||
interface TextAnswer {
|
||||
type: "text";
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface OptionAnswer {
|
||||
type: "option";
|
||||
label: string;
|
||||
value: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface OtherAnswer {
|
||||
type: "other";
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type AskAnswer = TextAnswer | OptionAnswer | OtherAnswer;
|
||||
type AskUserQuestionStatus = "answered" | "cancelled" | "unavailable";
|
||||
type AskUserQuestionMode = "text" | "single-select" | "multi-select";
|
||||
|
||||
interface AskUserQuestionResultDetails {
|
||||
status: AskUserQuestionStatus;
|
||||
question: string;
|
||||
context?: string;
|
||||
mode: AskUserQuestionMode;
|
||||
answers: AskAnswer[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const OptionSchema = Type.Object({
|
||||
label: Type.String({
|
||||
description:
|
||||
'Display label for the option. If you recommend an option, place it first and append "(Recommended)" to the label.',
|
||||
}),
|
||||
value: Type.Optional(
|
||||
Type.String({
|
||||
description: "Optional machine-readable value returned for the option. Defaults to the label.",
|
||||
}),
|
||||
),
|
||||
description: Type.Optional(Type.String({ description: "Optional extra detail shown below the option." })),
|
||||
});
|
||||
|
||||
const AskUserQuestionParams = Type.Object({
|
||||
question: Type.String({
|
||||
description: "The single question to ask the user. Ask exactly one question per tool call.",
|
||||
}),
|
||||
details: Type.Optional(
|
||||
Type.String({
|
||||
description: "Optional extra context or instructions shown under the question.",
|
||||
}),
|
||||
),
|
||||
options: Type.Optional(
|
||||
Type.Array(OptionSchema, {
|
||||
description:
|
||||
"Optional multiple-choice options. Omit or pass an empty array for free-form text input. Users will always be able to choose Other and type a custom answer when options are provided.",
|
||||
}),
|
||||
),
|
||||
multiSelect: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Set to true to allow multiple answers to be selected for a question.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
function normalizeOptions(options: Array<{ label: string; value?: string; description?: string }> | undefined): AskOption[] {
|
||||
return (options || [])
|
||||
.map((option) => ({
|
||||
label: option.label.trim(),
|
||||
value: option.value?.trim() || option.label.trim(),
|
||||
description: option.description?.trim() || undefined,
|
||||
}))
|
||||
.filter((option) => option.label.length > 0);
|
||||
}
|
||||
|
||||
function getOtherLabel(options: AskOption[]): string {
|
||||
return options.some((option) => option.label.toLowerCase() === "other") ? "Other (custom)" : "Other";
|
||||
}
|
||||
|
||||
function createEditorTheme(theme: any): EditorTheme {
|
||||
return {
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function addWrapped(lines: string[], text: string, width: number, indent = ""): void {
|
||||
const contentWidth = Math.max(1, width - indent.length);
|
||||
for (const line of wrapTextWithAnsi(text, contentWidth)) {
|
||||
lines.push(truncateToWidth(`${indent}${line}`, width));
|
||||
}
|
||||
}
|
||||
|
||||
function formatAnswerForModel(answer: AskAnswer): string {
|
||||
switch (answer.type) {
|
||||
case "text":
|
||||
return answer.label;
|
||||
case "other":
|
||||
return `Other: ${answer.label}`;
|
||||
case "option":
|
||||
return `${answer.index}. ${answer.label}`;
|
||||
}
|
||||
}
|
||||
|
||||
function answerSortRank(answer: AskAnswer): number {
|
||||
switch (answer.type) {
|
||||
case "option":
|
||||
return answer.index;
|
||||
case "other":
|
||||
return Number.MAX_SAFE_INTEGER - 1;
|
||||
case "text":
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
}
|
||||
|
||||
function sortAnswers(answers: AskAnswer[]): AskAnswer[] {
|
||||
return [...answers].sort((a, b) => answerSortRank(a) - answerSortRank(b));
|
||||
}
|
||||
|
||||
function buildStructuredResult(
|
||||
status: AskUserQuestionStatus,
|
||||
question: string,
|
||||
mode: AskUserQuestionMode,
|
||||
answers: AskAnswer[],
|
||||
context?: string,
|
||||
message?: string,
|
||||
) {
|
||||
return {
|
||||
status,
|
||||
question,
|
||||
context,
|
||||
mode,
|
||||
answers,
|
||||
message,
|
||||
} as AskUserQuestionResultDetails;
|
||||
}
|
||||
|
||||
function cancelledResult(question: string, mode: AskUserQuestionMode, context?: string) {
|
||||
const message = "User cancelled the question";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: buildStructuredResult("cancelled", question, mode, [], context, message),
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableResult(question: string, mode: AskUserQuestionMode, message: string, context?: string) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: buildStructuredResult("unavailable", question, mode, [], context, message),
|
||||
};
|
||||
}
|
||||
|
||||
function buildResult(question: string, context: string | undefined, mode: AskUserQuestionMode, answers: AskAnswer[]) {
|
||||
let text: string;
|
||||
if (mode === "text") {
|
||||
const answer = answers[0];
|
||||
text = answer.label.trim().length > 0 ? `User answered: ${answer.label}` : "User submitted an empty response";
|
||||
} else if (mode === "single-select") {
|
||||
text = `User selected: ${formatAnswerForModel(answers[0])}`;
|
||||
} else {
|
||||
text = `User selected:\n${answers.map((answer) => `- ${formatAnswerForModel(answer)}`).join("\n")}`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text }],
|
||||
details: buildStructuredResult("answered", question, mode, answers, context),
|
||||
};
|
||||
}
|
||||
|
||||
async function askSingleChoice(
|
||||
ctx: any,
|
||||
question: string,
|
||||
context: string | undefined,
|
||||
options: AskOption[],
|
||||
): Promise<AskAnswer | null> {
|
||||
const otherLabel = getOtherLabel(options);
|
||||
const allOptions: DisplayOption[] = [
|
||||
...options.map((option, index) => ({ ...option, id: `option:${index}`, index: index + 1 })),
|
||||
{ id: "other", label: otherLabel, value: "__other__", isOther: true },
|
||||
];
|
||||
|
||||
return ctx.ui.custom<AskAnswer | null>((tui: any, theme: any, _kb: any, done: (result: AskAnswer | null) => void) => {
|
||||
let optionIndex = 0;
|
||||
let editMode = false;
|
||||
let cachedLines: string[] | undefined;
|
||||
const editor = new Editor(tui, createEditorTheme(theme));
|
||||
|
||||
editor.onSubmit = (value) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
done({ type: "other", label: trimmed, value: trimmed });
|
||||
};
|
||||
|
||||
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;
|
||||
editor.setText("");
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
done({
|
||||
type: "option",
|
||||
label: selected.label,
|
||||
value: selected.value,
|
||||
index: selected.index!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
done(null);
|
||||
}
|
||||
}
|
||||
|
||||
function render(width: number): string[] {
|
||||
if (cachedLines) return cachedLines;
|
||||
|
||||
const lines: string[] = [];
|
||||
const add = (text: string) => lines.push(truncateToWidth(text, width));
|
||||
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
addWrapped(lines, theme.fg("text", ` ${question}`), width);
|
||||
if (context) {
|
||||
lines.push("");
|
||||
addWrapped(lines, theme.fg("muted", ` ${context}`), width);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
for (let i = 0; i < allOptions.length; i++) {
|
||||
const option = allOptions[i];
|
||||
const selected = i === optionIndex;
|
||||
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
||||
const label = option.isOther ? option.label : `${option.index}. ${option.label}`;
|
||||
const styled = selected ? theme.fg("accent", label) : theme.fg("text", label);
|
||||
add(`${prefix}${styled}`);
|
||||
if (option.description) {
|
||||
addWrapped(lines, theme.fg("muted", option.description), width, " ");
|
||||
}
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
lines.push("");
|
||||
add(theme.fg("muted", " Write your custom answer:"));
|
||||
for (const line of editor.render(Math.max(1, width - 2))) {
|
||||
add(` ${line}`);
|
||||
}
|
||||
lines.push("");
|
||||
add(theme.fg("dim", " Enter to submit • Esc to go back"));
|
||||
} else {
|
||||
lines.push("");
|
||||
add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
|
||||
}
|
||||
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
return {
|
||||
render,
|
||||
invalidate: () => {
|
||||
cachedLines = undefined;
|
||||
},
|
||||
handleInput,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function askMultiChoice(
|
||||
ctx: any,
|
||||
question: string,
|
||||
context: string | undefined,
|
||||
options: AskOption[],
|
||||
): Promise<AskAnswer[] | null> {
|
||||
const otherLabel = getOtherLabel(options);
|
||||
const choiceItems: DisplayOption[] = options.map((option, index) => ({
|
||||
...option,
|
||||
id: `option:${index}`,
|
||||
index: index + 1,
|
||||
}));
|
||||
const submitItem: DisplayOption = { id: "submit", label: "Submit", value: "__submit__", isSubmit: true };
|
||||
const allItems: DisplayOption[] = [
|
||||
...choiceItems,
|
||||
{ id: "other", label: otherLabel, value: "__other__", isOther: true },
|
||||
submitItem,
|
||||
];
|
||||
|
||||
return ctx.ui.custom<AskAnswer[] | null>((tui: any, theme: any, _kb: any, done: (result: AskAnswer[] | null) => void) => {
|
||||
let optionIndex = 0;
|
||||
let editMode = false;
|
||||
let cachedLines: string[] | undefined;
|
||||
const selected = new Map<string, AskAnswer>();
|
||||
const editor = new Editor(tui, createEditorTheme(theme));
|
||||
|
||||
editor.onSubmit = (value) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
selected.set("other", { type: "other", label: trimmed, value: trimmed });
|
||||
editMode = false;
|
||||
refresh();
|
||||
};
|
||||
|
||||
function refresh() {
|
||||
cachedLines = undefined;
|
||||
tui.requestRender();
|
||||
}
|
||||
|
||||
function toggleOption(item: DisplayOption) {
|
||||
if (selected.has(item.id)) {
|
||||
selected.delete(item.id);
|
||||
} else {
|
||||
selected.set(item.id, {
|
||||
type: "option",
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
index: item.index!,
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
function handleInput(data: string) {
|
||||
if (editMode) {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
editMode = false;
|
||||
editor.setText(selected.get("other")?.label || "");
|
||||
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(allItems.length - 1, optionIndex + 1);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const current = allItems[optionIndex];
|
||||
if (matchesKey(data, Key.space)) {
|
||||
if (current.isSubmit) return;
|
||||
if (current.isOther) {
|
||||
if (selected.has("other")) {
|
||||
selected.delete("other");
|
||||
refresh();
|
||||
} else {
|
||||
editMode = true;
|
||||
editor.setText("");
|
||||
refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
toggleOption(current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
if (current.isSubmit) {
|
||||
if (selected.size > 0) {
|
||||
done(sortAnswers(Array.from(selected.values())));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current.isOther) {
|
||||
editMode = true;
|
||||
editor.setText(selected.get("other")?.label || "");
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
toggleOption(current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
done(null);
|
||||
}
|
||||
}
|
||||
|
||||
function render(width: number): string[] {
|
||||
if (cachedLines) return cachedLines;
|
||||
|
||||
const lines: string[] = [];
|
||||
const add = (text: string) => lines.push(truncateToWidth(text, width));
|
||||
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
addWrapped(lines, theme.fg("text", ` ${question}`), width);
|
||||
if (context) {
|
||||
lines.push("");
|
||||
addWrapped(lines, theme.fg("muted", ` ${context}`), width);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
for (let i = 0; i < allItems.length; i++) {
|
||||
const item = allItems[i];
|
||||
const isFocused = i === optionIndex;
|
||||
const prefix = isFocused ? theme.fg("accent", "> ") : " ";
|
||||
|
||||
if (item.isSubmit) {
|
||||
const label = selected.size > 0 ? `✓ ${item.label} (${selected.size} selected)` : `○ ${item.label}`;
|
||||
const styled = isFocused
|
||||
? theme.fg("accent", label)
|
||||
: theme.fg(selected.size > 0 ? "success" : "dim", label);
|
||||
add(`${prefix}${styled}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.isOther) {
|
||||
const other = selected.get("other");
|
||||
const marker = other ? "[x]" : "[ ]";
|
||||
const suffix = other ? ` — ${other.label}` : "";
|
||||
const styled = isFocused
|
||||
? theme.fg("accent", `${marker} ${item.label}${suffix}`)
|
||||
: theme.fg(other ? "success" : "text", `${marker} ${item.label}${suffix}`);
|
||||
add(`${prefix}${styled}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const checked = selected.has(item.id);
|
||||
const marker = checked ? "[x]" : "[ ]";
|
||||
const label = `${marker} ${item.index}. ${item.label}`;
|
||||
const styled = isFocused
|
||||
? theme.fg("accent", label)
|
||||
: theme.fg(checked ? "success" : "text", label);
|
||||
add(`${prefix}${styled}`);
|
||||
if (item.description) {
|
||||
addWrapped(lines, theme.fg("muted", item.description), width, " ");
|
||||
}
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
lines.push("");
|
||||
add(theme.fg("muted", " Write your custom answer:"));
|
||||
for (const line of editor.render(Math.max(1, width - 2))) {
|
||||
add(` ${line}`);
|
||||
}
|
||||
lines.push("");
|
||||
add(theme.fg("dim", " Enter to save • Esc to go back"));
|
||||
} else {
|
||||
lines.push("");
|
||||
if (selected.size === 0) {
|
||||
add(theme.fg("warning", " Select at least one answer before submitting."));
|
||||
}
|
||||
add(theme.fg("dim", " ↑↓ navigate • Space toggle • Enter edit/submit • Esc cancel"));
|
||||
}
|
||||
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
return {
|
||||
render,
|
||||
invalidate: () => {
|
||||
cachedLines = undefined;
|
||||
},
|
||||
handleInput,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Mutex to serialize concurrent UI interactions.
|
||||
// showExtensionCustom/editor can only handle one active call at a time.
|
||||
let uiLock: Promise<void> = Promise.resolve();
|
||||
|
||||
function withUILock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const prev = uiLock;
|
||||
let release: () => void;
|
||||
uiLock = new Promise<void>((r) => { release = r; });
|
||||
return prev.then(fn).finally(() => release!());
|
||||
}
|
||||
|
||||
export default function askUserQuestion(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "ask_user_question",
|
||||
label: "ask_user_question",
|
||||
description:
|
||||
"Ask the user a single question and pause execution until they answer. Use this when requirements are ambiguous, user preferences are needed, a decision would materially affect implementation, or you need confirmation before proceeding. Ask exactly one question per tool call, and prefer multiple separate tool calls over bundling unrelated questions together.",
|
||||
promptSnippet:
|
||||
"Use this tool to ask exactly one clarifying question, missing-requirement question, preference question, or decision question before continuing.",
|
||||
promptGuidelines: [
|
||||
"Ask exactly one question per tool call.",
|
||||
"If you need answers to multiple questions, make multiple separate ask_user_question tool calls instead of combining them into one prompt.",
|
||||
'Users will always be able to select "Other" to provide custom text input when options are provided.',
|
||||
"Use multiSelect: true only when you need multiple answers to the same question.",
|
||||
'If you recommend a specific option, make it the first option in the list and add "(Recommended)" at the end of the label.',
|
||||
"Prefer this tool over guessing when requirements, preferences, or implementation choices are unclear.",
|
||||
"Use this tool when multiple valid implementation paths exist and the preferred path depends on user choice.",
|
||||
],
|
||||
parameters: AskUserQuestionParams,
|
||||
|
||||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||
const options = normalizeOptions(params.options);
|
||||
const context = params.details?.trim() || undefined;
|
||||
const mode: AskUserQuestionMode = options.length === 0 ? "text" : params.multiSelect ? "multi-select" : "single-select";
|
||||
|
||||
if (signal?.aborted) {
|
||||
return cancelledResult(params.question, mode, context);
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
return unavailableResult(params.question, mode, "ask_user_question requires interactive mode UI", context);
|
||||
}
|
||||
|
||||
return withUILock(async () => {
|
||||
if (mode === "text") {
|
||||
const editorTitle = context ? `${params.question}\n\n${context}` : params.question;
|
||||
const answer = await ctx.ui.editor(editorTitle);
|
||||
if (answer === undefined) {
|
||||
return cancelledResult(params.question, mode, context);
|
||||
}
|
||||
return buildResult(params.question, context, mode, [
|
||||
{ type: "text", label: answer.trim(), value: answer.trim() },
|
||||
]);
|
||||
}
|
||||
|
||||
if (mode === "single-select") {
|
||||
const answer = await askSingleChoice(ctx, params.question, context, options);
|
||||
if (!answer) {
|
||||
return cancelledResult(params.question, mode, context);
|
||||
}
|
||||
return buildResult(params.question, context, mode, [answer]);
|
||||
}
|
||||
|
||||
const answers = await askMultiChoice(ctx, params.question, context, options);
|
||||
if (!answers) {
|
||||
return cancelledResult(params.question, mode, context);
|
||||
}
|
||||
return buildResult(params.question, context, mode, answers);
|
||||
});
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
const options = normalizeOptions(args.options as Array<{ label: string; value?: string; description?: string }> | undefined);
|
||||
let text = theme.fg("toolTitle", theme.bold("ask_user_question ")) + theme.fg("muted", args.question);
|
||||
if (args.multiSelect) {
|
||||
text += theme.fg("dim", " [multi-select]");
|
||||
}
|
||||
if (options.length > 0) {
|
||||
const labels = [...options.map((option) => option.label), getOtherLabel(options)].join(", ");
|
||||
text += `\n${theme.fg("dim", ` Options: ${labels}`)}`;
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme) {
|
||||
const details = result.details as AskUserQuestionResultDetails | undefined;
|
||||
if (!details) {
|
||||
const first = result.content[0];
|
||||
return new Text(first?.type === "text" ? first.text : "", 0, 0);
|
||||
}
|
||||
|
||||
if (details.status === "cancelled") {
|
||||
return new Text(theme.fg("warning", details.message || "Cancelled"), 0, 0);
|
||||
}
|
||||
|
||||
if (details.status === "unavailable") {
|
||||
return new Text(theme.fg("warning", details.message || "ask_user_question unavailable"), 0, 0);
|
||||
}
|
||||
|
||||
const lines = details.answers.map((answer) => {
|
||||
switch (answer.type) {
|
||||
case "text":
|
||||
return `${theme.fg("success", "✓ ")}${theme.fg("accent", answer.label || "(empty response)")}`;
|
||||
case "other":
|
||||
return `${theme.fg("success", "✓ ")}${theme.fg("muted", "Other: ")}${theme.fg("accent", answer.label)}`;
|
||||
case "option":
|
||||
return `${theme.fg("success", "✓ ")}${theme.fg("accent", `${answer.index}. ${answer.label}`)}`;
|
||||
}
|
||||
});
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user