515 lines
18 KiB
TypeScript
515 lines
18 KiB
TypeScript
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<TabsUIState>((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 };
|
|
}
|