import { Box, Text, useInput } from "ink"; import { Fragment, memo, useMemo, useState } from "react"; import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTextInputCursor } from "../hooks/useTextInputCursor"; import { colors } from "./colors"; interface QuestionOption { label: string; description: string; } interface Question { question: string; header: string; options: QuestionOption[]; multiSelect: boolean; } type Props = { questions: Question[]; onSubmit: (answers: Record) => void; onCancel?: () => void; isFocused?: boolean; }; // Horizontal line character for Claude Code style const SOLID_LINE = "─"; export const InlineQuestionApproval = memo( ({ questions, onSubmit, onCancel, isFocused = true }: Props) => { const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [answers, setAnswers] = useState>({}); const [selectedOption, setSelectedOption] = useState(0); const { text: customText, setText: setCustomText, cursorPos, setCursorPos, handleKey, clear: clearCustomText, } = useTextInputCursor(); const [selectedMulti, setSelectedMulti] = useState>(new Set()); const columns = useTerminalWidth(); useProgressIndicator(); const currentQuestion = questions[currentQuestionIndex]; // Build options list: regular options + "Type something" // For multi-select, we also track a separate "Submit" action const baseOptions = currentQuestion ? [ ...currentQuestion.options, { label: "Type something.", description: "" }, ] : []; // For multi-select, add Submit as a separate selectable item const optionsWithOther = currentQuestion?.multiSelect ? [...baseOptions, { label: "Submit", description: "" }] : baseOptions; const customOptionIndex = baseOptions.length - 1; // "Type something" index const submitOptionIndex = currentQuestion?.multiSelect ? optionsWithOther.length - 1 : -1; // Submit index (only for multi-select) const isOnCustomOption = selectedOption === customOptionIndex; const isOnSubmitOption = selectedOption === submitOptionIndex; const handleSubmitAnswer = (answer: string) => { if (!currentQuestion) return; const newAnswers = { ...answers, [currentQuestion.question]: answer, }; setAnswers(newAnswers); if (currentQuestionIndex < questions.length - 1) { setCurrentQuestionIndex(currentQuestionIndex + 1); setSelectedOption(0); clearCustomText(); setSelectedMulti(new Set()); } else { onSubmit(newAnswers); } }; useInput( (input, key) => { if (!isFocused || !currentQuestion) return; // CTRL-C: cancel if (key.ctrl && input === "c") { onCancel?.(); return; } // Arrow navigation always works if (key.upArrow) { setSelectedOption((prev) => Math.max(0, prev - 1)); return; } if (key.downArrow || key.tab) { setSelectedOption((prev) => Math.min(optionsWithOther.length - 1, prev + 1), ); return; } // When on custom input option ("Type something") if (isOnCustomOption) { if (key.return) { // Enter toggles the checkbox (same as other options) if (currentQuestion.multiSelect) { setSelectedMulti((prev) => { const newSet = new Set(prev); if (newSet.has(customOptionIndex)) { newSet.delete(customOptionIndex); } else { newSet.add(customOptionIndex); } return newSet; }); } else { // Single-select: submit the custom text if any if (customText.trim()) { handleSubmitAnswer(customText.trim()); } } return; } if (input === " " && currentQuestion.multiSelect) { // Space in multi-select: toggle checkbox if not checked, then insert space if (!selectedMulti.has(customOptionIndex)) { setSelectedMulti((prev) => { const newSet = new Set(prev); newSet.add(customOptionIndex); return newSet; }); } // Insert space at cursor position setCustomText( (prev) => `${prev.slice(0, cursorPos)} ${prev.slice(cursorPos)}`, ); setCursorPos((prev) => prev + 1); return; } if (key.escape) { if (customText) { clearCustomText(); } else { onCancel?.(); } return; } // Handle text input (arrows, backspace, typing) if (handleKey(input, key)) return; } // When on Submit option (multi-select only) if (isOnSubmitOption) { if (key.return) { // Submit the selected options + custom text if "Type something" is checked const selectedLabels: string[] = []; for (const i of selectedMulti) { if (i === customOptionIndex) { // Include custom text if checkbox is checked and text was entered if (customText.trim()) { selectedLabels.push(customText.trim()); } } else { const label = baseOptions[i]?.label; if (label) { selectedLabels.push(label); } } } if (selectedLabels.length > 0) { handleSubmitAnswer(selectedLabels.join(", ")); } return; } if (key.escape) { onCancel?.(); return; } return; } // ESC on regular options: cancel if (key.escape) { onCancel?.(); return; } // Enter behavior depends on single vs multi-select if (key.return) { if (currentQuestion.multiSelect) { // Multi-select: Enter toggles the checkbox (only for regular options, not custom) if (selectedOption < customOptionIndex) { setSelectedMulti((prev) => { const newSet = new Set(prev); if (newSet.has(selectedOption)) { newSet.delete(selectedOption); } else { newSet.add(selectedOption); } return newSet; }); } } else { // Single-select: Enter selects and submits handleSubmitAnswer(optionsWithOther[selectedOption]?.label || ""); } return; } // Space also toggles for multi-select (like Claude Code) - only regular options if (input === " " && currentQuestion.multiSelect) { if (selectedOption < customOptionIndex) { setSelectedMulti((prev) => { const newSet = new Set(prev); if (newSet.has(selectedOption)) { newSet.delete(selectedOption); } else { newSet.add(selectedOption); } return newSet; }); } return; } // Number keys for quick selection if (input >= "1" && input <= "9") { const optionIndex = Number.parseInt(input, 10) - 1; if (optionIndex < optionsWithOther.length - 1) { if (currentQuestion.multiSelect) { setSelectedMulti((prev) => { const newSet = new Set(prev); if (newSet.has(optionIndex)) { newSet.delete(optionIndex); } else { newSet.add(optionIndex); } return newSet; }); } else { handleSubmitAnswer(optionsWithOther[optionIndex]?.label || ""); } } } }, { isActive: isFocused }, ); // Generate horizontal line const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10)); // Memoize the static header content so it doesn't re-render on keystroke // This prevents flicker when typing in the custom input field const memoizedHeaderContent = useMemo( () => ( <> {/* Top solid line */} {solidLine} {/* Header label */} {currentQuestion?.header} {/* Question */} {currentQuestion?.question} {/* Progress indicator for multiple questions */} {questions.length > 1 && ( Question {currentQuestionIndex + 1} of {questions.length} )} ), [ currentQuestion?.header, currentQuestion?.question, currentQuestionIndex, questions.length, solidLine, ], ); // Hint text based on state - keep consistent to avoid jarring changes const hintText = currentQuestion?.multiSelect ? "Enter to toggle · Arrow to navigate · Esc to cancel" : "Enter to select · Arrow to navigate · Esc to cancel"; if (!currentQuestion) return null; return ( {/* Static header content - memoized to prevent re-render on keystroke */} {memoizedHeaderContent} {/* Options - Format: ❯ N. [ ] Label (selector, number, checkbox, label) */} {optionsWithOther.map((option, index) => { const isSelected = index === selectedOption; const isChecked = selectedMulti.has(index); const color = isSelected ? colors.approval.header : undefined; const isCustomOption = index === customOptionIndex; const isSubmitOption = index === submitOptionIndex; // Calculate prefix width: "❯ N. " = 5 chars, "[ ] " = 4 chars for multi-select const selectorAndNumber = 5; // "❯ N. " or " N. " const checkboxWidth = currentQuestion.multiSelect ? 4 : 0; // "[ ] " or nothing const prefixWidth = selectorAndNumber + checkboxWidth; // Submit option renders differently (selector + always bold "Submit") if (isSubmitOption) { return ( {/* Extra newline above Submit */} {isSelected ? "❯" : " "} {" "} Submit ); } const hasDescription = option.description && !isCustomOption; // Use Fragment to avoid column Box wrapper - render row and description as siblings // Note: Can't use <> shorthand with key, so we import Fragment return ( {/* Selector and number */} {isSelected ? "❯" : " "} {index + 1}. {/* Checkbox (for multi-select) - single Text element to avoid re-mount */} {currentQuestion.multiSelect && ( [{isChecked ? "✓" : " "}]{" "} )} {/* Label */} {isCustomOption ? ( // Custom input option ("Type something") customText ? ( {customText.slice(0, cursorPos)} {isSelected && "█"} {customText.slice(cursorPos)} ) : ( {option.label} {isSelected && "█"} ) ) : ( {option.label} )} {/* Description - rendered as sibling row */} {hasDescription && ( {option.description} )} ); })} {hintText} ); }, ); InlineQuestionApproval.displayName = "InlineQuestionApproval";