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, buildSingleSelectionResult, type AskOption, type AskSelection, } from "./ask-logic"; import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./ask-inline-note"; interface SingleQuestionInput { question: string; options: AskOption[]; recommended?: number; } interface InlineSelectionResult { cancelled: boolean; selectedOption?: string; note?: string; } function resolveInitialCursorIndexFromRecommendedOption( recommendedOptionIndex: number | undefined, optionCount: number, ): number { if (recommendedOptionIndex == null) return 0; if (recommendedOptionIndex < 0 || recommendedOptionIndex >= optionCount) return 0; return recommendedOptionIndex; } export async function askSingleQuestionWithInlineNote( ui: ExtensionUIContext, questionInput: SingleQuestionInput, ): Promise { const baseOptionLabels = questionInput.options.map((option) => option.label); const optionLabelsWithRecommendedTag = appendRecommendedTagToOptionLabels( baseOptionLabels, questionInput.recommended, ); const selectableOptionLabels = [...optionLabelsWithRecommendedTag, OTHER_OPTION]; const initialCursorIndex = resolveInitialCursorIndexFromRecommendedOption( questionInput.recommended, optionLabelsWithRecommendedTag.length, ); const result = await ui.custom((tui, theme, _keybindings, done) => { let cursorOptionIndex = initialCursorIndex; let isNoteEditorOpen = false; let cachedRenderedLines: string[] | undefined; const noteByOptionIndex = new Map(); 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 requestUiRerender = () => { cachedRenderedLines = undefined; tui.requestRender(); }; const getRawNoteForOption = (optionIndex: number): string => noteByOptionIndex.get(optionIndex) ?? ""; const getTrimmedNoteForOption = (optionIndex: number): string => getRawNoteForOption(optionIndex).trim(); const loadCurrentNoteIntoEditor = () => { noteEditor.setText(getRawNoteForOption(cursorOptionIndex)); }; const saveCurrentNoteFromEditor = (value: string) => { noteByOptionIndex.set(cursorOptionIndex, value); }; const submitCurrentSelection = (selectedOptionLabel: string, note: string) => { done({ cancelled: false, selectedOption: selectedOptionLabel, note, }); }; noteEditor.onChange = (value) => { saveCurrentNoteFromEditor(value); requestUiRerender(); }; noteEditor.onSubmit = (value) => { saveCurrentNoteFromEditor(value); const selectedOptionLabel = selectableOptionLabels[cursorOptionIndex]; const trimmedNote = value.trim(); if (selectedOptionLabel === OTHER_OPTION && !trimmedNote) { requestUiRerender(); return; } submitCurrentSelection(selectedOptionLabel, trimmedNote); }; 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))); for (const questionLine of wrapTextWithAnsi(questionInput.question, Math.max(1, width - 1))) { addLine(` ${theme.fg("text", questionLine)}`); } renderedLines.push(""); for (let optionIndex = 0; optionIndex < selectableOptionLabels.length; optionIndex++) { const optionLabel = selectableOptionLabels[optionIndex]; const isCursorOption = optionIndex === cursorOptionIndex; const isEditingThisOption = isNoteEditorOpen && isCursorOption; const cursorPrefixText = isCursorOption ? "→ " : " "; const cursorPrefix = isCursorOption ? theme.fg("accent", cursorPrefixText) : cursorPrefixText; const bullet = isCursorOption ? "●" : "○"; const markerText = `${bullet} `; const optionColor = isCursorOption ? "accent" : "text"; const prefixWidth = visibleWidth(cursorPrefixText) + visibleWidth(markerText); const wrappedInlineLabelLines = buildWrappedOptionLabelWithInlineNote( optionLabel, getRawNoteForOption(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 submit • Tab/Esc stop editing")); } else if (getTrimmedNoteForOption(cursorOptionIndex).length > 0) { addLine(theme.fg("dim", " ↑↓ move • Enter submit • Tab edit note • Esc cancel")); } else { addLine(theme.fg("dim", " ↑↓ move • Enter submit • Tab add note • Esc cancel")); } 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.up)) { cursorOptionIndex = Math.max(0, cursorOptionIndex - 1); requestUiRerender(); return; } if (matchesKey(data, Key.down)) { cursorOptionIndex = Math.min(selectableOptionLabels.length - 1, cursorOptionIndex + 1); requestUiRerender(); return; } if (matchesKey(data, Key.tab)) { isNoteEditorOpen = true; loadCurrentNoteIntoEditor(); requestUiRerender(); return; } if (matchesKey(data, Key.enter)) { const selectedOptionLabel = selectableOptionLabels[cursorOptionIndex]; const trimmedNote = getTrimmedNoteForOption(cursorOptionIndex); if (selectedOptionLabel === OTHER_OPTION && !trimmedNote) { isNoteEditorOpen = true; loadCurrentNoteIntoEditor(); requestUiRerender(); return; } submitCurrentSelection(selectedOptionLabel, trimmedNote); return; } if (matchesKey(data, Key.escape)) { done({ cancelled: true }); } }; return { render, invalidate: () => { cachedRenderedLines = undefined; }, handleInput, }; }); if (result.cancelled || !result.selectedOption) { return { selectedOptions: [] }; } return buildSingleSelectionResult(result.selectedOption, result.note); }