import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent"; import { visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui"; import { applyPanelBackground, panelInnerWidth, renderKylinPanelBottom, renderKylinPanelRow, renderKylinPanelTop, } from "../../../vera-theme/src/public"; import type { Answer, Question } from "./types"; // ── Types ──────────────────────────────────────────────────────────────── type ItemKind = "option" | "confirm" | "write" | "edit" | "submit" | "back"; interface Item { kind: ItemKind; label: string; value?: string; checked?: boolean; } type PanelResult = | { action: "select"; value: string } | { action: "toggle"; value: string } | { action: "confirm" } | { action: "edit-comment" } | { action: "submit-comment" } | { action: "back" } | { action: "cancel" }; // ── Helpers ────────────────────────────────────────────────────────────── function fmtTitle(i: number, n: number, text: string): string { return n > 1 ? `[${i + 1}/${n}] ${text}` : text; } function trunc(s: string, max = 35): string { return s.length > max ? s.slice(0, max - 1) + "\u2026" : s; } // ── Build item list ────────────────────────────────────────────────────── function buildItems( q: Question, multi: boolean, hasComment: boolean, canGoBack: boolean, checked: Set, comment: string | undefined, ): Item[] { const items: Item[] = []; for (const o of q.options) { items.push({ kind: "option", label: o.label, value: o.value, checked: checked.has(o.value) }); } if (multi) { const n = checked.size; items.push({ kind: "confirm", label: n > 0 ? `Confirm (${n})` : "Confirm" }); } if (hasComment) { if (comment) { items.push({ kind: "submit", label: `\u201c${trunc(comment)}\u201d` }); items.push({ kind: "edit", label: "Edit answer\u2026" }); } else { items.push({ kind: "write", label: "Write your own answer\u2026" }); } } if (canGoBack) { items.push({ kind: "back", label: "Previous question" }); } return items; } // ── Render one item ────────────────────────────────────────────────────── function renderItem(item: Item, active: boolean, multi: boolean, t: any, maxWidth: number): string[] { const cur = active ? t.fg("accent", "\u25b8 ") : " "; switch (item.kind) { case "option": { let mark = ""; if (multi) { mark = item.checked ? t.fg("success", "\u25c6 ") : t.fg("dim", "\u25c7 "); } const prefix = ` ${cur}${mark}`; const continuationPrefix = " ".repeat(visibleWidth(prefix)); const labelWidth = Math.max(1, maxWidth - visibleWidth(prefix)); const labelLines = wrapTextWithAnsi(item.label, labelWidth); const labelColor = active ? "accent" : "text"; return (labelLines.length > 0 ? labelLines : [""]).map((line, idx) => { const linePrefix = idx === 0 ? prefix : continuationPrefix; return linePrefix + t.fg(labelColor, line); }); } case "confirm": { const text = active ? t.fg("success", item.label) : t.fg("muted", item.label); return [` ${cur}${text}`]; } case "submit": { const text = active ? t.fg("accent", item.label) : t.fg("muted", item.label); return [` ${cur}${text}`]; } case "write": case "edit": { const text = active ? t.fg("text", item.label) : t.fg("dim", item.label); return [` ${cur}${text}`]; } case "back": { const label = "\u2039 " + item.label; const text = active ? t.fg("text", label) : t.fg("dim", label); return [` ${cur}${text}`]; } default: return [""]; } } // ── The panel component ────────────────────────────────────────────────── const WIDGET_KEY = "ask_user"; function stripControlInput(data: string): string { return data // Bracketed paste wrappers. .replace(/^\x1b\[200~/, "") .replace(/\x1b\[201~$/, "") // Common CSI/SS3 escape sequences. .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") .replace(/\x1bO./g, "") // Remaining C0 controls and DEL. .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ""); } function showPanel( ui: ExtensionUIContext, title: string, q: Question, multi: boolean, hasComment: boolean, canGoBack: boolean, checked: Set, comment: string | undefined, initialCursor: number, signal?: AbortSignal, ): Promise<{ result: PanelResult; cursor: number; comment: string | undefined }> { return new Promise((resolve) => { let currentComment = comment; let items = buildItems(q, multi, hasComment, canGoBack, checked, currentComment); let cursor = Math.min(initialCursor, items.length - 1); let commentMode = false; let draft = currentComment ?? ""; let widgetTui: { requestRender(): void } | undefined; let unsubscribeInput: (() => void) | undefined; let closed = false; const requestRender = () => widgetTui?.requestRender(); function rebuild(): void { items = buildItems(q, multi, hasComment, canGoBack, checked, currentComment); if (cursor >= items.length) cursor = items.length - 1; } function close(result: PanelResult): void { if (closed) return; closed = true; signal?.removeEventListener("abort", onAbort); unsubscribeInput?.(); ui.setWidget(WIDGET_KEY, undefined); resolve({ result, cursor, comment: currentComment }); } function onAbort(): void { close({ action: "cancel" }); } if (signal?.aborted) { close({ action: "cancel" }); return; } signal?.addEventListener("abort", onAbort, { once: true }); function saveDraft(): void { currentComment = draft.trim() || undefined; commentMode = false; rebuild(); requestRender(); } function cancelDraft(): void { draft = currentComment ?? ""; commentMode = false; requestRender(); } function act(): void { const it = items[cursor]; if (!it) return; switch (it.kind) { case "option": if (multi) { if (it.value) { if (checked.has(it.value)) checked.delete(it.value); else checked.add(it.value); } rebuild(); requestRender(); return; } close({ action: "select", value: it.value! }); return; case "confirm": close({ action: "confirm" }); return; case "write": case "edit": draft = currentComment ?? ""; commentMode = true; requestRender(); return; case "submit": close({ action: "submit-comment" }); return; case "back": close({ action: "back" }); return; } } function handleCommentInput(data: string): boolean { if (data === "\x1b" || data === "\x1b\x1b") { cancelDraft(); return true; } if (data === "\r" || data === "\n") { saveDraft(); return true; } if (data === "\x7f" || data === "\b") { draft = Array.from(draft).slice(0, -1).join(""); requestRender(); return true; } if (data === "\x15") { draft = ""; requestRender(); return true; } if (data.startsWith("\x1b")) { return true; } const text = stripControlInput(data); if (text) { draft += text; requestRender(); } return true; } function handlePanelInput(data: string): boolean { if (commentMode) return handleCommentInput(data); if (data === "\x1b" || data === "\x1b\x1b") { close({ action: "cancel" }); return true; } if (data === "\x1b[A" || data === "\x1bOA" || data === "k") { // allow-ansi: keyboard-input cursor = cursor === 0 ? items.length - 1 : cursor - 1; requestRender(); return true; } if (data === "\x1b[B" || data === "\x1bOB" || data === "j") { // allow-ansi: keyboard-input cursor = cursor === items.length - 1 ? 0 : cursor + 1; requestRender(); return true; } if (data === " " && multi && cursor < q.options.length) { act(); return true; } if (data === "\r" || data === "\n") { act(); return true; } return false; } unsubscribeInput = ui.onTerminalInput((data) => { if (closed) return undefined; return handlePanelInput(data) ? { consume: true } : undefined; }); ui.setWidget(WIDGET_KEY, (tui, theme) => { widgetTui = tui; return { render(width: number): string[] { const innerW = panelInnerWidth(width); const panelWidth = innerW + 4; const out: string[] = []; const leftTitle = theme.fg("toolTitle", "Question"); const right = theme.fg("dim", commentMode ? "answer mode" : `${q.options.length} options`); out.push(renderKylinPanelTop(panelWidth, leftTitle, right, theme)); const titleLines = wrapTextWithAnsi(title, innerW); for (const tl of titleLines) { out.push(renderKylinPanelRow(theme.fg("mdHeading", tl), panelWidth, theme)); } out.push(renderKylinPanelRow("", panelWidth, theme)); if (commentMode) { const placeholder = q.commentPlaceholder ?? "Enter your answer"; const inputText = draft ? theme.fg("text", draft) + theme.fg("accent", "\u258c") : theme.fg("dim", placeholder) + theme.fg("accent", "\u258c"); out.push(renderKylinPanelRow(theme.fg("muted", "Custom answer"), panelWidth, theme)); for (const line of wrapTextWithAnsi(inputText, innerW)) { out.push(renderKylinPanelRow(line, panelWidth, theme)); } out.push(renderKylinPanelRow("", panelWidth, theme)); const dot = theme.fg("borderMuted", " \u00b7 "); const h = [ theme.fg("dim", "type") + theme.fg("muted", " answer"), theme.fg("dim", "\u232b") + theme.fg("muted", " delete"), theme.fg("dim", "\u21b5") + theme.fg("muted", " save"), theme.fg("dim", "esc") + theme.fg("muted", " back"), ]; out.push(renderKylinPanelRow(h.join(dot), panelWidth, theme)); } else { const optionCount = q.options.length; const itemW = innerW; for (let i = 0; i < items.length; i++) { if (i === optionCount && items.length > optionCount) { out.push(renderKylinPanelRow("", panelWidth, theme)); } for (const line of renderItem(items[i], i === cursor, multi, theme, itemW)) { out.push(renderKylinPanelRow(line, panelWidth, theme)); } } out.push(renderKylinPanelRow("", panelWidth, theme)); const dot = theme.fg("borderMuted", " \u00b7 "); const h: string[] = [ theme.fg("dim", "\u2191\u2193") + theme.fg("muted", " move"), ]; if (multi) h.push(theme.fg("dim", "\u2423") + theme.fg("muted", " toggle")); h.push(theme.fg("dim", "\u21b5") + theme.fg("muted", " select")); h.push(theme.fg("dim", "esc") + theme.fg("muted", " cancel")); out.push(renderKylinPanelRow(h.join(dot), panelWidth, theme)); } out.push(renderKylinPanelBottom(panelWidth, theme)); return applyPanelBackground(out, "pending", theme); }, invalidate(): void {}, }; }, { placement: "aboveEditor" }); }); } // ── Ask one question ───────────────────────────────────────────────────── async function askOne( ui: ExtensionUIContext, q: Question, idx: number, total: number, canGoBack: boolean, signal?: AbortSignal, ): Promise { const title = fmtTitle(idx, total, q.text); const multi = q.type === "multi"; const hasComment = q.allowComment !== false; const checked = new Set(); let comment: string | undefined; let cursor = 0; while (true) { if (signal?.aborted) return { questionId: q.id, selected: [], skipped: true }; const panel = await showPanel( ui, title, q, multi, hasComment, canGoBack, checked, comment, cursor, signal, ); const { result, cursor: lastCursor } = panel; cursor = lastCursor; comment = panel.comment; switch (result.action) { case "select": return { questionId: q.id, selected: [result.value], comment, skipped: false }; case "confirm": { const ordered = q.options.filter((o) => checked.has(o.value)).map((o) => o.value); return { questionId: q.id, selected: ordered, comment, skipped: ordered.length === 0 && !comment }; } case "submit-comment": return { questionId: q.id, selected: [], comment, skipped: false }; case "edit-comment": continue; case "back": return "back"; case "cancel": return { questionId: q.id, selected: [], skipped: true }; } } } // ── Public entry point ─────────────────────────────────────────────────── export async function promptQuestions( ui: ExtensionUIContext, questions: Question[], signal?: AbortSignal, ): Promise { const answers: Answer[] = []; let i = 0; while (i < questions.length) { if (signal?.aborted) break; const q = questions[i]; const result = await askOne(ui, q, i, questions.length, i > 0, signal); if (result === "back") { i--; answers.pop(); continue; } answers.push(result); i++; } return answers; }