import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import { CONTINUE_LABEL, OTHER_LABEL, type NormalizedQuestion, type QuestionAnswer, } from "./schema.ts"; import { activateFocusedRow, buildAnswers, canSubmit, cancelCustomAnswer, createQuestionnaireState, isQuestionAnswered, isReviewTab, moveFocus, moveTab, returnFromReview, rowsForQuestion, submitCustomAnswer, toggleFocusedOption, type QuestionnaireState, } from "./state.ts"; export type DialogOutcome = | { kind: "submitted"; answers: QuestionAnswer[] } | { kind: "dismissed" } | { kind: "aborted" }; function selectedOption( state: QuestionnaireState, questionIndex: number, value: string, ): boolean { return (state.selectionsByQuestion[questionIndex] ?? []).some( (selection) => !selection.custom && selection.value === value, ); } function customSelection( state: QuestionnaireState, questionIndex: number, ): string | undefined { return (state.selectionsByQuestion[questionIndex] ?? []).find( (selection) => selection.custom, )?.label; } export async function openQuestionnaireDialog( ctx: ExtensionContext, questions: readonly NormalizedQuestion[], signal?: AbortSignal, ): Promise { let removeAbortListener: (() => void) | undefined; try { return await ctx.ui.custom( (tui, theme, _keybindings, done) => { let state = createQuestionnaireState(questions); let cachedWidth: number | undefined; let cachedLines: string[] | undefined; let componentFocused = false; let finished = false; const editorTheme: EditorTheme = { borderColor: (text) => theme.fg("accent", text), selectList: { selectedPrefix: (text) => theme.fg("accent", text), selectedText: (text) => theme.fg("accent", text), description: (text) => theme.fg("muted", text), scrollInfo: (text) => theme.fg("dim", text), noMatch: (text) => theme.fg("warning", text), }, }; const editor = new Editor(tui, editorTheme); const finish = (outcome: DialogOutcome) => { if (finished) return; finished = true; removeAbortListener?.(); done(outcome); }; const abort = () => finish({ kind: "aborted" }); signal?.addEventListener("abort", abort, { once: true }); removeAbortListener = () => signal?.removeEventListener("abort", abort); function syncEditorFocus() { editor.focused = componentFocused && state.editorQuestionIndex !== null; } function refresh() { cachedWidth = undefined; cachedLines = undefined; syncEditorFocus(); tui.requestRender(); } editor.onSubmit = (value) => { state = submitCustomAnswer(state, value); editor.setText(""); refresh(); }; function activateCurrentRow() { const questionIndex = state.activeTab; const existingCustom = customSelection(state, questionIndex); const wasEditing = state.editorQuestionIndex !== null; state = activateFocusedRow(state); if (!wasEditing && state.editorQuestionIndex !== null) { editor.setText(existingCustom ?? ""); } refresh(); } function handleReviewInput(data: string) { if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) { state = moveFocus(state, 1); refresh(); return; } if (!matchesKey(data, Key.enter)) return; if (state.reviewFocus === 0) { if (canSubmit(state)) { finish({ kind: "submitted", answers: buildAnswers(state) }); } return; } state = returnFromReview(state); refresh(); } function handleInput(data: string) { if (state.editorQuestionIndex !== null) { if (matchesKey(data, Key.escape)) { state = cancelCustomAnswer(state); editor.setText(""); refresh(); return; } editor.handleInput(data); refresh(); return; } if (matchesKey(data, Key.escape)) { finish({ kind: "dismissed" }); return; } if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { state = moveTab(state, 1); refresh(); return; } if ( matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left) ) { state = moveTab(state, -1); refresh(); return; } if (isReviewTab(state)) { handleReviewInput(data); return; } if (matchesKey(data, Key.up)) { state = moveFocus(state, -1); refresh(); return; } if (matchesKey(data, Key.down)) { state = moveFocus(state, 1); refresh(); return; } if (matchesKey(data, Key.space)) { const question = questions[state.activeTab]; const row = rowsForQuestion(question)[ state.focusByQuestion[state.activeTab] ?? 0 ]; if (row?.kind === "option") { state = toggleFocusedOption(state); refresh(); } else if (row?.kind === "other") { activateCurrentRow(); } return; } if (matchesKey(data, Key.enter)) { activateCurrentRow(); } } function render(width: number): string[] { const renderWidth = Math.max(1, width); if (cachedLines && cachedWidth === renderWidth) return cachedLines; const lines: string[] = []; const addLine = (line = "") => lines.push(truncateToWidth(line, renderWidth, "")); const addWrapped = (text: string) => { const wrapped = wrapTextWithAnsi(text, renderWidth); if (wrapped.length === 0) addLine(); else for (const line of wrapped) addLine(line); }; const addWrappedWithPrefix = (prefix: string, text: string) => { const prefixWidth = visibleWidth(prefix); if (prefixWidth >= renderWidth) { addWrapped(prefix + text); return; } const wrapped = wrapTextWithAnsi( text, Math.max(1, renderWidth - prefixWidth), ); const continuation = " ".repeat(prefixWidth); for (let index = 0; index < wrapped.length; index++) { addLine( `${index === 0 ? prefix : continuation}${wrapped[index]}`, ); } }; addLine(theme.fg("accent", "─".repeat(renderWidth))); const tabParts = questions.map((question, index) => { const complete = isQuestionAnswered(state, index); const label = ` ${question.header}${complete ? " ✓" : ""} `; return index === state.activeTab ? theme.fg("accent", theme.bold(`[${label}]`)) : theme.fg(complete ? "success" : "muted", label); }); const reviewLabel = " Review "; tabParts.push( isReviewTab(state) ? theme.fg("accent", theme.bold(`[${reviewLabel}]`)) : theme.fg(canSubmit(state) ? "success" : "muted", reviewLabel), ); addWrappedWithPrefix(" ", tabParts.join(theme.fg("dim", " • "))); addLine(); if (isReviewTab(state)) { addWrappedWithPrefix( " ", theme.fg("text", theme.bold("Review your answers")), ); addLine(); questions.forEach((question, questionIndex) => { addWrappedWithPrefix( " ", theme.fg("accent", `${question.header}:`), ); const selections = state.selectionsByQuestion[questionIndex] ?? []; if (selections.length === 0) { addWrappedWithPrefix(" ", theme.fg("warning", "Unanswered")); } else { for (const selection of selections) { const valueSuffix = selection.value === selection.label ? "" : theme.fg("dim", ` (${selection.value})`); const customSuffix = selection.custom ? theme.fg("muted", " (custom)") : ""; addWrappedWithPrefix( " • ", theme.fg("text", selection.label) + valueSuffix + customSuffix, ); } } }); addLine(); const submitFocused = state.reviewFocus === 0; const submitPrefix = submitFocused ? theme.fg("accent", " ❯ ") : " "; addWrappedWithPrefix( submitPrefix, theme.fg( canSubmit(state) ? "success" : "dim", canSubmit(state) ? "Submit answers" : "Submit (answer all questions)", ), ); const backPrefix = !submitFocused ? theme.fg("accent", " ❯ ") : " "; addWrappedWithPrefix( backPrefix, theme.fg("text", "Back to questions"), ); } else { const questionIndex = state.activeTab; const question = questions[questionIndex]; addWrappedWithPrefix( " ", theme.fg("text", theme.bold(question.question)), ); addLine(); const rows = rowsForQuestion(question); const focusedRow = state.focusByQuestion[questionIndex] ?? 0; rows.forEach((row, rowIndex) => { const focused = rowIndex === focusedRow; const prefix = focused ? theme.fg("accent", " ❯ ") : " "; if (row.kind === "option") { const option = question.options[row.optionIndex]; const selected = selectedOption( state, questionIndex, option.value, ); const marker = question.multiSelect ? selected ? "[x]" : "[ ]" : selected ? "(●)" : "( )"; addWrappedWithPrefix( prefix, theme.fg( focused ? "accent" : "text", `${marker} ${option.label}`, ), ); if (option.description) { addWrappedWithPrefix( " ", theme.fg("muted", option.description), ); } return; } if (row.kind === "other") { const custom = customSelection(state, questionIndex); const marker = question.multiSelect ? custom ? "[x]" : "[ ]" : custom ? "(●)" : "( )"; const suffix = custom ? ` — ${custom}` : ""; addWrappedWithPrefix( prefix, theme.fg( focused ? "accent" : "muted", `${marker} ${OTHER_LABEL}${suffix}`, ), ); return; } addWrappedWithPrefix( prefix, theme.fg(focused ? "accent" : "text", CONTINUE_LABEL), ); }); if (state.editorQuestionIndex !== null) { addLine(); addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:")); for (const editorLine of editor.render( Math.max(1, renderWidth - 2), )) { addLine(` ${editorLine}`); } } } addLine(); if (state.editorQuestionIndex !== null) { const hasExistingCustom = customSelection(state, state.editorQuestionIndex) !== undefined; addWrappedWithPrefix( " ", theme.fg( "dim", hasExistingCustom ? "Enter save • Blank removes • Esc keep current" : "Enter submit • Esc back to options", ), ); } else if (isReviewTab(state)) { addWrappedWithPrefix( " ", theme.fg( "dim", "↑↓ choose • Enter confirm • Tab switch • Esc dismiss", ), ); } else if (questions[state.activeTab].multiSelect) { addWrappedWithPrefix( " ", theme.fg( "dim", "↑↓ move • Space/Enter toggle • Continue advances • Tab switch • Esc dismiss", ), ); } else { addWrappedWithPrefix( " ", theme.fg( "dim", "↑↓ move • Enter select • Tab switch • Esc dismiss", ), ); } addLine(theme.fg("accent", "─".repeat(renderWidth))); cachedWidth = renderWidth; cachedLines = lines; return lines; } const component = { get focused() { return componentFocused; }, set focused(value: boolean) { if (componentFocused === value) return; componentFocused = value; syncEditorFocus(); cachedWidth = undefined; cachedLines = undefined; tui.requestRender(); }, render, invalidate() { cachedWidth = undefined; cachedLines = undefined; editor.invalidate(); }, handleInput, }; if (signal?.aborted) abort(); return component; }, ); } finally { removeAbortListener?.(); } }