import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent"; import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui"; import { OTHER_OPTION, appendRecommendedTagToOptionLabels, buildMultiSelectionResult, buildSingleSelectionResult, type AskQuestion, type AskSelection, } from "./ask-logic"; import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./ask-inline-note"; interface PreparedQuestion { id: string; question: string; options: string[]; tabLabel: string; multi: boolean; otherOptionIndex: number; } interface TabsUIState { cancelled: boolean; selectedOptionIndexesByQuestion: number[][]; noteByQuestionByOption: string[][]; } export function formatSelectionForSubmitReview(selection: AskSelection, isMulti: boolean): string { const hasSelectedOptions = selection.selectedOptions.length > 0; const hasCustomInput = Boolean(selection.customInput); if (hasSelectedOptions && hasCustomInput) { const selectedPart = isMulti ? `[${selection.selectedOptions.join(", ")}]` : selection.selectedOptions[0]; return `${selectedPart} + Other: ${selection.customInput}`; } if (hasCustomInput) { return `Other: ${selection.customInput}`; } if (hasSelectedOptions) { return isMulti ? `[${selection.selectedOptions.join(", ")}]` : selection.selectedOptions[0]; } return "(not answered)"; } function clampIndex(index: number | undefined, maxExclusive: number): number { if (index == null || Number.isNaN(index) || maxExclusive <= 0) return 0; if (index < 0) return 0; if (index >= maxExclusive) return maxExclusive - 1; return index; } function normalizeTabLabel(id: string, fallback: string): string { const normalized = id.trim().replace(/[_-]+/g, " "); return normalized.length > 0 ? normalized : fallback; } function buildSelectionForQuestion( question: PreparedQuestion, selectedOptionIndexes: number[], noteByOptionIndex: string[], ): AskSelection { if (selectedOptionIndexes.length === 0) { return { selectedOptions: [] }; } if (question.multi) { return buildMultiSelectionResult(question.options, selectedOptionIndexes, noteByOptionIndex, question.otherOptionIndex); } const selectedOptionIndex = selectedOptionIndexes[0]; const selectedOptionLabel = question.options[selectedOptionIndex] ?? OTHER_OPTION; const note = noteByOptionIndex[selectedOptionIndex] ?? ""; return buildSingleSelectionResult(selectedOptionLabel, note); } function isQuestionSelectionValid( question: PreparedQuestion, selectedOptionIndexes: number[], noteByOptionIndex: string[], ): boolean { if (selectedOptionIndexes.length === 0) return false; if (!selectedOptionIndexes.includes(question.otherOptionIndex)) return true; const otherNote = noteByOptionIndex[question.otherOptionIndex]?.trim() ?? ""; return otherNote.length > 0; } function createTabsUiStateSnapshot( cancelled: boolean, selectedOptionIndexesByQuestion: number[][], noteByQuestionByOption: string[][], ): TabsUIState { return { cancelled, selectedOptionIndexesByQuestion: selectedOptionIndexesByQuestion.map((indexes) => [...indexes]), noteByQuestionByOption: noteByQuestionByOption.map((notes) => [...notes]), }; } function addIndexToSelection(selectedOptionIndexes: number[], optionIndex: number): number[] { if (selectedOptionIndexes.includes(optionIndex)) return selectedOptionIndexes; return [...selectedOptionIndexes, optionIndex].sort((a, b) => a - b); } function removeIndexFromSelection(selectedOptionIndexes: number[], optionIndex: number): number[] { return selectedOptionIndexes.filter((index) => index !== optionIndex); } export async function askQuestionsWithTabs( ui: ExtensionUIContext, questions: AskQuestion[], ): Promise<{ cancelled: boolean; selections: AskSelection[] }> { const preparedQuestions: PreparedQuestion[] = questions.map((question, questionIndex) => { const baseOptionLabels = question.options.map((option) => option.label); const optionLabels = [...appendRecommendedTagToOptionLabels(baseOptionLabels, question.recommended), OTHER_OPTION]; return { id: question.id, question: question.question, options: optionLabels, tabLabel: normalizeTabLabel(question.id, `Q${questionIndex + 1}`), multi: question.multi === true, otherOptionIndex: optionLabels.length - 1, }; }); const initialCursorOptionIndexByQuestion = preparedQuestions.map((preparedQuestion, questionIndex) => clampIndex(questions[questionIndex].recommended, preparedQuestion.options.length), ); const result = await ui.custom((tui, theme, _keybindings, done) => { let activeTabIndex = 0; let isNoteEditorOpen = false; let cachedRenderedLines: string[] | undefined; const cursorOptionIndexByQuestion = [...initialCursorOptionIndexByQuestion]; const selectedOptionIndexesByQuestion = preparedQuestions.map(() => [] as number[]); const noteByQuestionByOption = preparedQuestions.map((preparedQuestion) => Array(preparedQuestion.options.length).fill("") as string[], ); 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 noteEditor = new Editor(tui, editorTheme); const submitTabIndex = preparedQuestions.length; const requestUiRerender = () => { cachedRenderedLines = undefined; tui.requestRender(); }; const getActiveQuestionIndex = (): number | null => { if (activeTabIndex >= preparedQuestions.length) return null; return activeTabIndex; }; const getQuestionNote = (questionIndex: number, optionIndex: number): string => noteByQuestionByOption[questionIndex]?.[optionIndex] ?? ""; const getTrimmedQuestionNote = (questionIndex: number, optionIndex: number): string => getQuestionNote(questionIndex, optionIndex).trim(); const isAllQuestionSelectionsValid = (): boolean => preparedQuestions.every((preparedQuestion, questionIndex) => isQuestionSelectionValid( preparedQuestion, selectedOptionIndexesByQuestion[questionIndex], noteByQuestionByOption[questionIndex], ), ); const openNoteEditorForActiveOption = () => { const questionIndex = getActiveQuestionIndex(); if (questionIndex == null) return; isNoteEditorOpen = true; const optionIndex = cursorOptionIndexByQuestion[questionIndex]; noteEditor.setText(getQuestionNote(questionIndex, optionIndex)); requestUiRerender(); }; const advanceToNextTabOrSubmit = () => { activeTabIndex = Math.min(submitTabIndex, activeTabIndex + 1); }; noteEditor.onChange = (value) => { const questionIndex = getActiveQuestionIndex(); if (questionIndex == null) return; const optionIndex = cursorOptionIndexByQuestion[questionIndex]; noteByQuestionByOption[questionIndex][optionIndex] = value; requestUiRerender(); }; noteEditor.onSubmit = (value) => { const questionIndex = getActiveQuestionIndex(); if (questionIndex == null) return; const preparedQuestion = preparedQuestions[questionIndex]; const optionIndex = cursorOptionIndexByQuestion[questionIndex]; noteByQuestionByOption[questionIndex][optionIndex] = value; const trimmedNote = value.trim(); if (preparedQuestion.multi) { if (trimmedNote.length > 0) { selectedOptionIndexesByQuestion[questionIndex] = addIndexToSelection( selectedOptionIndexesByQuestion[questionIndex], optionIndex, ); } if (optionIndex === preparedQuestion.otherOptionIndex && trimmedNote.length === 0) { requestUiRerender(); return; } isNoteEditorOpen = false; requestUiRerender(); return; } selectedOptionIndexesByQuestion[questionIndex] = [optionIndex]; if (optionIndex === preparedQuestion.otherOptionIndex && trimmedNote.length === 0) { requestUiRerender(); return; } isNoteEditorOpen = false; advanceToNextTabOrSubmit(); requestUiRerender(); }; const renderTabs = (): string => { const tabParts: string[] = ["← "]; for (let questionIndex = 0; questionIndex < preparedQuestions.length; questionIndex++) { const preparedQuestion = preparedQuestions[questionIndex]; const isActiveTab = questionIndex === activeTabIndex; const isQuestionValid = isQuestionSelectionValid( preparedQuestion, selectedOptionIndexesByQuestion[questionIndex], noteByQuestionByOption[questionIndex], ); const statusIcon = isQuestionValid ? "■" : "□"; const tabLabel = ` ${statusIcon} ${preparedQuestion.tabLabel} `; const styledTabLabel = isActiveTab ? theme.bg("selectedBg", theme.fg("text", tabLabel)) : theme.fg(isQuestionValid ? "success" : "muted", tabLabel); tabParts.push(`${styledTabLabel} `); } const isSubmitTabActive = activeTabIndex === submitTabIndex; const canSubmit = isAllQuestionSelectionsValid(); const submitLabel = " ✓ Submit "; const styledSubmitLabel = isSubmitTabActive ? theme.bg("selectedBg", theme.fg("text", submitLabel)) : theme.fg(canSubmit ? "success" : "dim", submitLabel); tabParts.push(`${styledSubmitLabel} →`); return tabParts.join(""); }; const renderSubmitTab = (width: number, renderedLines: string[]): void => { const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width)); addLine(theme.fg("accent", theme.bold(" Review answers"))); renderedLines.push(""); for (let questionIndex = 0; questionIndex < preparedQuestions.length; questionIndex++) { const preparedQuestion = preparedQuestions[questionIndex]; const selection = buildSelectionForQuestion( preparedQuestion, selectedOptionIndexesByQuestion[questionIndex], noteByQuestionByOption[questionIndex], ); const value = formatSelectionForSubmitReview(selection, preparedQuestion.multi); const isValid = isQuestionSelectionValid( preparedQuestion, selectedOptionIndexesByQuestion[questionIndex], noteByQuestionByOption[questionIndex], ); const statusIcon = isValid ? theme.fg("success", "●") : theme.fg("warning", "○"); addLine(` ${statusIcon} ${theme.fg("muted", `${preparedQuestion.tabLabel}:`)} ${theme.fg("text", value)}`); } renderedLines.push(""); if (isAllQuestionSelectionsValid()) { addLine(theme.fg("success", " Press Enter to submit")); } else { const missingQuestions = preparedQuestions .filter((preparedQuestion, questionIndex) => !isQuestionSelectionValid( preparedQuestion, selectedOptionIndexesByQuestion[questionIndex], noteByQuestionByOption[questionIndex], ), ) .map((preparedQuestion) => preparedQuestion.tabLabel) .join(", "); addLine(theme.fg("warning", ` Complete required answers: ${missingQuestions}`)); } addLine(theme.fg("dim", " ←/→ switch tabs • Esc cancel")); }; const renderQuestionTab = (width: number, renderedLines: string[], questionIndex: number): void => { const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width)); const preparedQuestion = preparedQuestions[questionIndex]; const cursorOptionIndex = cursorOptionIndexByQuestion[questionIndex]; const selectedOptionIndexes = selectedOptionIndexesByQuestion[questionIndex]; for (const questionLine of wrapTextWithAnsi(preparedQuestion.question, Math.max(1, width - 1))) { addLine(` ${theme.fg("text", questionLine)}`); } renderedLines.push(""); for (let optionIndex = 0; optionIndex < preparedQuestion.options.length; optionIndex++) { const optionLabel = preparedQuestion.options[optionIndex]; const isCursorOption = optionIndex === cursorOptionIndex; const isOptionSelected = selectedOptionIndexes.includes(optionIndex); const isEditingThisOption = isNoteEditorOpen && isCursorOption; const cursorPrefixText = isCursorOption ? "→ " : " "; const cursorPrefix = isCursorOption ? theme.fg("accent", cursorPrefixText) : cursorPrefixText; const markerText = preparedQuestion.multi ? `${isOptionSelected ? "[x]" : "[ ]"} ` : `${isOptionSelected ? "●" : "○"} `; const optionColor = isCursorOption ? "accent" : isOptionSelected ? "success" : "text"; const prefixWidth = visibleWidth(cursorPrefixText) + visibleWidth(markerText); const wrappedInlineLabelLines = buildWrappedOptionLabelWithInlineNote( optionLabel, getQuestionNote(questionIndex, optionIndex), isEditingThisOption, Math.max(1, width - prefixWidth), INLINE_NOTE_WRAP_PADDING, ); const continuationPrefix = " ".repeat(prefixWidth); addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`); for (const wrappedLine of wrappedInlineLabelLines.slice(1)) { addLine(`${continuationPrefix}${theme.fg(optionColor, wrappedLine)}`); } } renderedLines.push(""); if (isNoteEditorOpen) { addLine(theme.fg("dim", " Typing note inline • Enter save note • Tab/Esc stop editing")); } else { if (preparedQuestion.multi) { addLine( theme.fg( "dim", " ↑↓ move • Enter toggle/select • Tab add note • ←/→ switch tabs • Esc cancel", ), ); } else { addLine( theme.fg("dim", " ↑↓ move • Enter select • Tab add note • ←/→ switch tabs • Esc cancel"), ); } } }; const render = (width: number): string[] => { if (cachedRenderedLines) return cachedRenderedLines; const renderedLines: string[] = []; const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width)); addLine(theme.fg("accent", "─".repeat(width))); addLine(` ${renderTabs()}`); renderedLines.push(""); if (activeTabIndex === submitTabIndex) { renderSubmitTab(width, renderedLines); } else { renderQuestionTab(width, renderedLines, activeTabIndex); } addLine(theme.fg("accent", "─".repeat(width))); cachedRenderedLines = renderedLines; return renderedLines; }; const handleInput = (data: string) => { if (isNoteEditorOpen) { if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) { isNoteEditorOpen = false; requestUiRerender(); return; } noteEditor.handleInput(data); requestUiRerender(); return; } if (matchesKey(data, Key.left)) { activeTabIndex = (activeTabIndex - 1 + preparedQuestions.length + 1) % (preparedQuestions.length + 1); requestUiRerender(); return; } if (matchesKey(data, Key.right)) { activeTabIndex = (activeTabIndex + 1) % (preparedQuestions.length + 1); requestUiRerender(); return; } if (activeTabIndex === submitTabIndex) { if (matchesKey(data, Key.enter) && isAllQuestionSelectionsValid()) { done(createTabsUiStateSnapshot(false, selectedOptionIndexesByQuestion, noteByQuestionByOption)); return; } if (matchesKey(data, Key.escape)) { done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption)); } return; } const questionIndex = activeTabIndex; const preparedQuestion = preparedQuestions[questionIndex]; if (matchesKey(data, Key.up)) { cursorOptionIndexByQuestion[questionIndex] = Math.max(0, cursorOptionIndexByQuestion[questionIndex] - 1); requestUiRerender(); return; } if (matchesKey(data, Key.down)) { cursorOptionIndexByQuestion[questionIndex] = Math.min( preparedQuestion.options.length - 1, cursorOptionIndexByQuestion[questionIndex] + 1, ); requestUiRerender(); return; } if (matchesKey(data, Key.tab)) { openNoteEditorForActiveOption(); return; } if (matchesKey(data, Key.enter)) { const cursorOptionIndex = cursorOptionIndexByQuestion[questionIndex]; if (preparedQuestion.multi) { const currentlySelected = selectedOptionIndexesByQuestion[questionIndex]; if (currentlySelected.includes(cursorOptionIndex)) { selectedOptionIndexesByQuestion[questionIndex] = removeIndexFromSelection(currentlySelected, cursorOptionIndex); } else { selectedOptionIndexesByQuestion[questionIndex] = addIndexToSelection(currentlySelected, cursorOptionIndex); } if ( cursorOptionIndex === preparedQuestion.otherOptionIndex && selectedOptionIndexesByQuestion[questionIndex].includes(cursorOptionIndex) && getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0 ) { openNoteEditorForActiveOption(); return; } requestUiRerender(); return; } selectedOptionIndexesByQuestion[questionIndex] = [cursorOptionIndex]; if ( cursorOptionIndex === preparedQuestion.otherOptionIndex && getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0 ) { openNoteEditorForActiveOption(); return; } advanceToNextTabOrSubmit(); requestUiRerender(); return; } if (matchesKey(data, Key.escape)) { done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption)); } }; return { render, invalidate: () => { cachedRenderedLines = undefined; }, handleInput, }; }); if (result.cancelled) { return { cancelled: true, selections: preparedQuestions.map(() => ({ selectedOptions: [] } satisfies AskSelection)), }; } const selections = preparedQuestions.map((preparedQuestion, questionIndex) => buildSelectionForQuestion( preparedQuestion, result.selectedOptionIndexesByQuestion[questionIndex] ?? [], result.noteByQuestionByOption[questionIndex] ?? Array(preparedQuestion.options.length).fill(""), ), ); return { cancelled: result.cancelled, selections }; }