Download packages/cli/src/ui/components/AskUserDialog.tsx from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 37.6 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/components/AskUserDialog.tsx
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/ui/components/AskUserDialog.tsx
-
curl -L -o AskUserDialog.tsx https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/components/AskUserDialog.tsx
37.6 kB
| /** | |
| * @license | |
| * Copyright 2026 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import type React from 'react'; | |
| import { | |
| useCallback, | |
| useMemo, | |
| useRef, | |
| useEffect, | |
| useReducer, | |
| useContext, | |
| } from 'react'; | |
| import { Box, Text, type DOMElement } from 'ink'; | |
| import { useMouseClick } from '../hooks/useMouseClick.js'; | |
| import { theme } from '../semantic-colors.js'; | |
| import { checkExhaustive, type Question } from '@google/gemini-cli-core'; | |
| import { BaseSelectionList } from './shared/BaseSelectionList.js'; | |
| import type { SelectionListItem } from '../hooks/useSelectionList.js'; | |
| import { TabHeader, type Tab } from './shared/TabHeader.js'; | |
| import { useKeypress, type Key } from '../hooks/useKeypress.js'; | |
| import { Command } from '../key/keyMatchers.js'; | |
| import { TextInput } from './shared/TextInput.js'; | |
| import { formatCommand } from '../key/keybindingUtils.js'; | |
| import { | |
| useTextBuffer, | |
| expandPastePlaceholders, | |
| } from './shared/text-buffer.js'; | |
| import { getCachedStringWidth } from '../utils/textUtils.js'; | |
| import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; | |
| import { DialogFooter } from './shared/DialogFooter.js'; | |
| import { MarkdownDisplay } from '../utils/MarkdownDisplay.js'; | |
| import { RenderInline } from '../utils/InlineMarkdownRenderer.js'; | |
| import { MaxSizedBox } from './shared/MaxSizedBox.js'; | |
| import { UIStateContext } from '../contexts/UIStateContext.js'; | |
| import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; | |
| import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; | |
| /** Padding for dialog content to prevent text from touching edges. */ | |
| const DIALOG_PADDING = 4; | |
| /** | |
| * Checks if text is a single line without markdown identifiers. | |
| */ | |
| function isPlainSingleLine(text: string): boolean { | |
| // Must be a single line (no newlines) | |
| if (text.includes('\n') || text.includes('\r')) { | |
| return false; | |
| } | |
| // Check for common markdown identifiers | |
| const markdownPatterns = [ | |
| /^#{1,6}\s/, // Headers | |
| /^[`~]{3,}/, // Code fences | |
| /^[-*+]\s/, // Unordered lists | |
| /^\d+\.\s/, // Ordered lists | |
| /^[-*_]{3,}$/, // Horizontal rules | |
| /\|/, // Tables | |
| /\*\*|__/, // Bold | |
| /(?<!\*)\*(?!\*)/, // Italic (single asterisk not part of bold) | |
| /(?<!_)_(?!_)/, // Italic (single underscore not part of bold) | |
| /`[^`]+`/, // Inline code | |
| /\[.*?\]\(.*?\)/, // Links | |
| /!\[/, // Images | |
| ]; | |
| for (const pattern of markdownPatterns) { | |
| if (pattern.test(text)) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| /** | |
| * Auto-bolds plain single-line text by wrapping in **. | |
| * Returns the text unchanged if it already contains markdown. | |
| */ | |
| function autoBoldIfPlain(text: string): string { | |
| if (isPlainSingleLine(text)) { | |
| return `**${text}**`; | |
| } | |
| return text; | |
| } | |
| const ClickableCheckbox: React.FC<{ | |
| isChecked: boolean; | |
| onClick: () => void; | |
| }> = ({ isChecked, onClick }) => { | |
| const ref = useRef<DOMElement>(null); | |
| useMouseClick(ref, () => { | |
| onClick(); | |
| }); | |
| return ( | |
| <Box ref={ref}> | |
| <Text color={isChecked ? theme.status.success : theme.text.secondary}> | |
| [{isChecked ? 'x' : ' '}] | |
| </Text> | |
| </Box> | |
| ); | |
| }; | |
| interface AskUserDialogState { | |
| answers: { [key: string]: string }; | |
| isEditingCustomOption: boolean; | |
| submitted: boolean; | |
| } | |
| type AskUserDialogAction = | |
| | { | |
| type: 'SET_ANSWER'; | |
| payload: { | |
| index: number; | |
| answer: string; | |
| submit?: boolean; | |
| }; | |
| } | |
| | { type: 'SET_EDITING_CUSTOM'; payload: { isEditing: boolean } } | |
| | { type: 'SUBMIT' }; | |
| const initialState: AskUserDialogState = { | |
| answers: {}, | |
| isEditingCustomOption: false, | |
| submitted: false, | |
| }; | |
| function askUserDialogReducerLogic( | |
| state: AskUserDialogState, | |
| action: AskUserDialogAction, | |
| ): AskUserDialogState { | |
| if (state.submitted) { | |
| return state; | |
| } | |
| switch (action.type) { | |
| case 'SET_ANSWER': { | |
| const { index, answer, submit } = action.payload; | |
| const hasAnswer = | |
| answer !== undefined && answer !== null && answer.trim() !== ''; | |
| const newAnswers = { ...state.answers }; | |
| if (hasAnswer) { | |
| newAnswers[index] = answer; | |
| } else { | |
| delete newAnswers[index]; | |
| } | |
| return { | |
| ...state, | |
| answers: newAnswers, | |
| submitted: submit ? true : state.submitted, | |
| }; | |
| } | |
| case 'SET_EDITING_CUSTOM': { | |
| if (state.isEditingCustomOption === action.payload.isEditing) { | |
| return state; | |
| } | |
| return { | |
| ...state, | |
| isEditingCustomOption: action.payload.isEditing, | |
| }; | |
| } | |
| case 'SUBMIT': { | |
| return { | |
| ...state, | |
| submitted: true, | |
| }; | |
| } | |
| default: | |
| checkExhaustive(action); | |
| return state; | |
| } | |
| } | |
| /** | |
| * Props for the AskUserDialog component. | |
| */ | |
| interface AskUserDialogProps { | |
| /** | |
| * The list of questions to ask the user. | |
| */ | |
| questions: Question[]; | |
| /** | |
| * Callback fired when the user submits their answers. | |
| * Returns a map of question index to answer string. | |
| */ | |
| onSubmit: (answers: { [questionIndex: string]: string }) => void; | |
| /** | |
| * Callback fired when the user cancels the dialog (e.g. via Escape). | |
| */ | |
| onCancel: () => void; | |
| /** | |
| * Optional callback to notify parent when text input is active. | |
| * Useful for managing global keypress handlers. | |
| */ | |
| onActiveTextInputChange?: (active: boolean) => void; | |
| /** | |
| * Width of the dialog. | |
| */ | |
| width: number; | |
| /** | |
| * Height constraint for scrollable content. | |
| */ | |
| availableHeight?: number; | |
| /** | |
| * Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) | |
| */ | |
| extraParts?: string[]; | |
| } | |
| interface ReviewViewProps { | |
| questions: Question[]; | |
| answers: { [key: string]: string }; | |
| onSubmit: () => void; | |
| progressHeader?: React.ReactNode; | |
| extraParts?: string[]; | |
| } | |
| const ReviewView: React.FC<ReviewViewProps> = ({ | |
| questions, | |
| answers, | |
| onSubmit, | |
| progressHeader, | |
| extraParts, | |
| }) => { | |
| const keyMatchers = useKeyMatchers(); | |
| const unansweredCount = questions.length - Object.keys(answers).length; | |
| const hasUnanswered = unansweredCount > 0; | |
| // Handle Enter to submit | |
| useKeypress( | |
| (key: Key) => { | |
| if (keyMatchers[Command.RETURN](key)) { | |
| onSubmit(); | |
| return true; | |
| } | |
| return false; | |
| }, | |
| { isActive: true }, | |
| ); | |
| return ( | |
| <Box flexDirection="column"> | |
| {progressHeader} | |
| <Box marginBottom={1}> | |
| <Text bold color={theme.text.primary}> | |
| Review your answers: | |
| </Text> | |
| </Box> | |
| {hasUnanswered && ( | |
| <Box marginBottom={1}> | |
| <Text color={theme.status.warning}> | |
| ⚠ You have {unansweredCount} unanswered question | |
| {unansweredCount > 1 ? 's' : ''} | |
| </Text> | |
| </Box> | |
| )} | |
| <Box flexDirection="column"> | |
| {questions.map((q, i) => ( | |
| <Box key={i} marginBottom={0}> | |
| <Text color={theme.text.secondary}>{q.header}</Text> | |
| <Text color={theme.text.secondary}> → </Text> | |
| <Text | |
| color={answers[i] ? theme.text.primary : theme.status.warning} | |
| > | |
| {answers[i] || '(not answered)'} | |
| </Text> | |
| </Box> | |
| ))} | |
| </Box> | |
| <DialogFooter | |
| primaryAction="Enter to submit" | |
| navigationActions={`${formatCommand(Command.DIALOG_NEXT)}/${formatCommand(Command.DIALOG_PREV)} to edit answers`} | |
| extraParts={extraParts} | |
| /> | |
| </Box> | |
| ); | |
| }; | |
| // ============== Text Question View ============== | |
| interface TextQuestionViewProps { | |
| question: Question; | |
| onAnswer: (answer: string) => void; | |
| onSelectionChange?: (answer: string) => void; | |
| onEditingCustomOption?: (editing: boolean) => void; | |
| availableWidth: number; | |
| availableHeight?: number; | |
| initialAnswer?: string; | |
| progressHeader?: React.ReactNode; | |
| keyboardHints?: React.ReactNode; | |
| } | |
| const TextQuestionView: React.FC<TextQuestionViewProps> = ({ | |
| question, | |
| onAnswer, | |
| onSelectionChange, | |
| onEditingCustomOption, | |
| availableWidth, | |
| availableHeight, | |
| initialAnswer, | |
| progressHeader, | |
| keyboardHints, | |
| }) => { | |
| const keyMatchers = useKeyMatchers(); | |
| const isAlternateBuffer = useAlternateBuffer(); | |
| const prefix = '> '; | |
| const horizontalPadding = 1; // 1 for cursor | |
| const bufferWidth = | |
| availableWidth - getCachedStringWidth(prefix) - horizontalPadding; | |
| const buffer = useTextBuffer({ | |
| initialText: initialAnswer, | |
| viewport: { width: Math.max(1, bufferWidth), height: 3 }, | |
| singleLine: false, | |
| }); | |
| const { text: textValue } = buffer; | |
| // Sync state change with parent - only when it actually changes | |
| const lastTextValueRef = useRef(textValue); | |
| useEffect(() => { | |
| if (textValue !== lastTextValueRef.current) { | |
| onSelectionChange?.( | |
| expandPastePlaceholders(textValue, buffer.pastedContent), | |
| ); | |
| lastTextValueRef.current = textValue; | |
| } | |
| }, [textValue, onSelectionChange, buffer.pastedContent]); | |
| // Handle Ctrl+C to clear all text | |
| const handleExtraKeys = useCallback( | |
| (key: Key) => { | |
| if (keyMatchers[Command.QUIT](key)) { | |
| if (textValue === '') { | |
| return false; | |
| } | |
| buffer.setText(''); | |
| return true; | |
| } | |
| return false; | |
| }, | |
| [buffer, textValue, keyMatchers], | |
| ); | |
| useKeypress(handleExtraKeys, { isActive: true, priority: true }); | |
| const handleSubmit = useCallback( | |
| (val: string) => { | |
| onAnswer(val.trim()); | |
| }, | |
| [onAnswer], | |
| ); | |
| // Notify parent that we're in text input mode (for Ctrl+C handling) | |
| useEffect(() => { | |
| onEditingCustomOption?.(true); | |
| return () => { | |
| onEditingCustomOption?.(false); | |
| }; | |
| }, [onEditingCustomOption]); | |
| const placeholder = question.placeholder || 'Enter your response'; | |
| const HEADER_HEIGHT = progressHeader ? 2 : 0; | |
| const INPUT_HEIGHT = 2; // TextInput + margin | |
| const FOOTER_HEIGHT = 2; // DialogFooter + margin | |
| const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT; | |
| const questionHeight = | |
| availableHeight && !isAlternateBuffer | |
| ? Math.max(1, availableHeight - overhead) | |
| : undefined; | |
| return ( | |
| <Box flexDirection="column"> | |
| {progressHeader} | |
| <Box marginBottom={1}> | |
| <MaxSizedBox | |
| maxHeight={questionHeight} | |
| maxWidth={availableWidth} | |
| overflowDirection="bottom" | |
| > | |
| <MarkdownDisplay | |
| text={autoBoldIfPlain(question.question)} | |
| terminalWidth={availableWidth - DIALOG_PADDING} | |
| isPending={false} | |
| /> | |
| </MaxSizedBox> | |
| </Box> | |
| <Box flexDirection="row" marginBottom={1}> | |
| <Text color={theme.status.success}>{'> '}</Text> | |
| <TextInput | |
| buffer={buffer} | |
| placeholder={placeholder} | |
| onSubmit={handleSubmit} | |
| /> | |
| </Box> | |
| {keyboardHints} | |
| </Box> | |
| ); | |
| }; | |
| // ============== Choice Question View ============== | |
| interface OptionItem { | |
| key: string; | |
| label: string; | |
| description: string; | |
| type: 'option' | 'other' | 'done' | 'all'; | |
| index: number; | |
| } | |
| interface ChoiceQuestionState { | |
| selectedIndices: Set<number>; | |
| isCustomOptionSelected: boolean; | |
| isCustomOptionFocused: boolean; | |
| } | |
| type ChoiceQuestionAction = | |
| | { type: 'TOGGLE_INDEX'; payload: { index: number; multiSelect: boolean } } | |
| | { type: 'TOGGLE_ALL'; payload: { totalOptions: number } } | |
| | { | |
| type: 'SET_CUSTOM_SELECTED'; | |
| payload: { selected: boolean; multiSelect: boolean }; | |
| } | |
| | { type: 'TOGGLE_CUSTOM_SELECTED'; payload: { multiSelect: boolean } } | |
| | { type: 'SET_CUSTOM_FOCUSED'; payload: { focused: boolean } }; | |
| function choiceQuestionReducer( | |
| state: ChoiceQuestionState, | |
| action: ChoiceQuestionAction, | |
| ): ChoiceQuestionState { | |
| switch (action.type) { | |
| case 'TOGGLE_ALL': { | |
| const { totalOptions } = action.payload; | |
| const allSelected = state.selectedIndices.size === totalOptions; | |
| if (allSelected) { | |
| return { | |
| ...state, | |
| selectedIndices: new Set(), | |
| }; | |
| } else { | |
| const newIndices = new Set<number>(); | |
| for (let i = 0; i < totalOptions; i++) { | |
| newIndices.add(i); | |
| } | |
| return { | |
| ...state, | |
| selectedIndices: newIndices, | |
| }; | |
| } | |
| } | |
| case 'TOGGLE_INDEX': { | |
| const { index, multiSelect } = action.payload; | |
| const newIndices = new Set(multiSelect ? state.selectedIndices : []); | |
| if (newIndices.has(index)) { | |
| newIndices.delete(index); | |
| } else { | |
| newIndices.add(index); | |
| } | |
| return { | |
| ...state, | |
| selectedIndices: newIndices, | |
| // In single select, selecting an option deselects custom | |
| isCustomOptionSelected: multiSelect | |
| ? state.isCustomOptionSelected | |
| : false, | |
| }; | |
| } | |
| case 'SET_CUSTOM_SELECTED': { | |
| const { selected, multiSelect } = action.payload; | |
| return { | |
| ...state, | |
| isCustomOptionSelected: selected, | |
| // In single-select, selecting custom deselects others | |
| selectedIndices: multiSelect ? state.selectedIndices : new Set(), | |
| }; | |
| } | |
| case 'TOGGLE_CUSTOM_SELECTED': { | |
| const { multiSelect } = action.payload; | |
| if (!multiSelect) return state; | |
| return { | |
| ...state, | |
| isCustomOptionSelected: !state.isCustomOptionSelected, | |
| }; | |
| } | |
| case 'SET_CUSTOM_FOCUSED': { | |
| return { | |
| ...state, | |
| isCustomOptionFocused: action.payload.focused, | |
| }; | |
| } | |
| default: | |
| checkExhaustive(action); | |
| return state; | |
| } | |
| } | |
| interface ChoiceQuestionViewProps { | |
| question: Question; | |
| onAnswer: (answer: string) => void; | |
| onSelectionChange?: (answer: string) => void; | |
| onEditingCustomOption?: (editing: boolean) => void; | |
| availableWidth: number; | |
| availableHeight?: number; | |
| initialAnswer?: string; | |
| progressHeader?: React.ReactNode; | |
| keyboardHints?: React.ReactNode; | |
| } | |
| const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({ | |
| question, | |
| onAnswer, | |
| onSelectionChange, | |
| onEditingCustomOption, | |
| availableWidth, | |
| availableHeight, | |
| initialAnswer, | |
| progressHeader, | |
| keyboardHints, | |
| }) => { | |
| const keyMatchers = useKeyMatchers(); | |
| const isAlternateBuffer = useAlternateBuffer(); | |
| const hasAll = question.multiSelect && (question.options?.length ?? 0) > 1; | |
| // Calculate total options including 'All' and 'Other' to ensure consistent numbering column width | |
| const numOptions = (question.options?.length ?? 0) + (hasAll ? 1 : 0) + 1; | |
| const numLen = String(numOptions).length; | |
| const radioWidth = 2; // "● " | |
| const numberWidth = numLen + 2; // e.g., "1. " | |
| const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " " | |
| const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓" | |
| const cursorPadding = 1; // Extra character for cursor at end of line | |
| const horizontalPadding = | |
| radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding; | |
| const bufferWidth = availableWidth - horizontalPadding; | |
| const questionOptions = useMemo( | |
| () => question.options ?? [], | |
| [question.options], | |
| ); | |
| // Initialize state from initialAnswer if returning to a previously answered question | |
| const initialReducerState = useMemo((): ChoiceQuestionState => { | |
| if (!initialAnswer) { | |
| return { | |
| selectedIndices: new Set<number>(), | |
| isCustomOptionSelected: false, | |
| isCustomOptionFocused: false, | |
| }; | |
| } | |
| // Check if initialAnswer matches any option labels | |
| const selectedIndices = new Set<number>(); | |
| let isCustomOptionSelected = false; | |
| if (question.multiSelect) { | |
| const answers = initialAnswer.split(', '); | |
| answers.forEach((answer) => { | |
| const index = questionOptions.findIndex((opt) => opt.label === answer); | |
| if (index !== -1) { | |
| selectedIndices.add(index); | |
| } else { | |
| isCustomOptionSelected = true; | |
| } | |
| }); | |
| } else { | |
| const index = questionOptions.findIndex( | |
| (opt) => opt.label === initialAnswer, | |
| ); | |
| if (index !== -1) { | |
| selectedIndices.add(index); | |
| } else { | |
| isCustomOptionSelected = true; | |
| } | |
| } | |
| return { | |
| selectedIndices, | |
| isCustomOptionSelected, | |
| isCustomOptionFocused: false, | |
| }; | |
| }, [initialAnswer, questionOptions, question.multiSelect]); | |
| const [state, dispatch] = useReducer( | |
| choiceQuestionReducer, | |
| initialReducerState, | |
| ); | |
| const { selectedIndices, isCustomOptionSelected, isCustomOptionFocused } = | |
| state; | |
| const initialCustomText = useMemo(() => { | |
| if (!initialAnswer) return ''; | |
| if (question.multiSelect) { | |
| const answers = initialAnswer.split(', '); | |
| const custom = answers.find( | |
| (a) => !questionOptions.some((opt) => opt.label === a), | |
| ); | |
| return custom || ''; | |
| } else { | |
| const isPredefined = questionOptions.some( | |
| (opt) => opt.label === initialAnswer, | |
| ); | |
| return isPredefined ? '' : initialAnswer; | |
| } | |
| }, [initialAnswer, questionOptions, question.multiSelect]); | |
| const customBuffer = useTextBuffer({ | |
| initialText: initialCustomText, | |
| viewport: { width: Math.max(1, bufferWidth), height: 3 }, | |
| singleLine: false, | |
| }); | |
| const customOptionText = customBuffer.text; | |
| // Helper to build answer string from selections | |
| const buildAnswerString = useCallback( | |
| ( | |
| indices: Set<number>, | |
| includeCustomOption: boolean, | |
| customOption: string, | |
| ) => { | |
| const answers: string[] = []; | |
| questionOptions.forEach((opt, i) => { | |
| if (indices.has(i)) { | |
| answers.push(opt.label); | |
| } | |
| }); | |
| if (includeCustomOption && customOption.trim()) { | |
| const expanded = expandPastePlaceholders( | |
| customOption, | |
| customBuffer.pastedContent, | |
| ); | |
| answers.push(expanded.trim()); | |
| } | |
| return answers.join(', '); | |
| }, | |
| [questionOptions, customBuffer.pastedContent], | |
| ); | |
| // Synchronize selection changes with parent - only when it actually changes | |
| const lastBuiltAnswerRef = useRef(''); | |
| useEffect(() => { | |
| const newAnswer = buildAnswerString( | |
| selectedIndices, | |
| isCustomOptionSelected, | |
| customOptionText, | |
| ); | |
| if (newAnswer !== lastBuiltAnswerRef.current) { | |
| onSelectionChange?.(newAnswer); | |
| lastBuiltAnswerRef.current = newAnswer; | |
| } | |
| }, [ | |
| selectedIndices, | |
| isCustomOptionSelected, | |
| customOptionText, | |
| buildAnswerString, | |
| onSelectionChange, | |
| ]); | |
| // Handle "Type-to-Jump" and Ctrl+C for custom buffer | |
| const handleExtraKeys = useCallback( | |
| (key: Key) => { | |
| // If focusing custom option, handle Ctrl+C | |
| if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) { | |
| if (customOptionText === '') { | |
| return false; | |
| } | |
| customBuffer.setText(''); | |
| return true; | |
| } | |
| // Don't jump if a navigation or selection key is pressed | |
| if ( | |
| keyMatchers[Command.DIALOG_NAVIGATION_UP](key) || | |
| keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) || | |
| keyMatchers[Command.DIALOG_NEXT](key) || | |
| keyMatchers[Command.DIALOG_PREV](key) || | |
| keyMatchers[Command.MOVE_LEFT](key) || | |
| keyMatchers[Command.MOVE_RIGHT](key) || | |
| keyMatchers[Command.RETURN](key) || | |
| keyMatchers[Command.ESCAPE](key) || | |
| keyMatchers[Command.QUIT](key) | |
| ) { | |
| return false; | |
| } | |
| // Check if it's a numeric quick selection key (if numbers are shown) | |
| const isNumeric = /^[0-9]$/.test(key.sequence); | |
| if (isNumeric) { | |
| return false; | |
| } | |
| // Type-to-jump: if printable characters are typed and not focused, jump to custom | |
| const isPrintable = | |
| key.sequence && | |
| !key.ctrl && | |
| !key.alt && | |
| (key.sequence.length > 1 || key.sequence.charCodeAt(0) >= 32); | |
| if (isPrintable && !isCustomOptionFocused) { | |
| dispatch({ type: 'SET_CUSTOM_FOCUSED', payload: { focused: true } }); | |
| onEditingCustomOption?.(true); | |
| // For IME or multi-char sequences, we want to capture the whole thing. | |
| // If it's a single char, we start the buffer with it. | |
| customBuffer.setText(key.sequence); | |
| return true; | |
| } | |
| return false; | |
| }, | |
| [ | |
| isCustomOptionFocused, | |
| customBuffer, | |
| onEditingCustomOption, | |
| customOptionText, | |
| keyMatchers, | |
| ], | |
| ); | |
| useKeypress(handleExtraKeys, { isActive: true, priority: true }); | |
| const selectionItems = useMemo((): Array<SelectionListItem<OptionItem>> => { | |
| const list: Array<SelectionListItem<OptionItem>> = questionOptions.map( | |
| (opt, i) => { | |
| const item: OptionItem = { | |
| key: `opt-${i}`, | |
| label: opt.label, | |
| description: opt.description, | |
| type: 'option', | |
| index: i, | |
| }; | |
| return { key: item.key, value: item }; | |
| }, | |
| ); | |
| // Add 'All of the above' for multi-select | |
| if (question.multiSelect && questionOptions.length > 1) { | |
| const allItem: OptionItem = { | |
| key: 'all', | |
| label: 'All of the above', | |
| description: 'Select all options', | |
| type: 'all', | |
| index: list.length, | |
| }; | |
| list.push({ key: 'all', value: allItem }); | |
| } | |
| // Add custom option for choice and yesno types | |
| const otherItem: OptionItem = { | |
| key: 'other', | |
| label: customOptionText || '', | |
| description: '', | |
| type: 'other', | |
| index: list.length, | |
| }; | |
| list.push({ key: 'other', value: otherItem }); | |
| if (question.multiSelect) { | |
| const doneItem: OptionItem = { | |
| key: 'done', | |
| label: 'Done', | |
| description: 'Finish selection', | |
| type: 'done', | |
| index: list.length, | |
| }; | |
| list.push({ key: doneItem.key, value: doneItem, hideNumber: true }); | |
| } | |
| return list; | |
| }, [questionOptions, question.multiSelect, customOptionText]); | |
| const handleHighlight = useCallback( | |
| (itemValue: OptionItem) => { | |
| const nowFocusingCustomOption = itemValue.type === 'other'; | |
| dispatch({ | |
| type: 'SET_CUSTOM_FOCUSED', | |
| payload: { focused: nowFocusingCustomOption }, | |
| }); | |
| // Notify parent when we start/stop focusing custom option (so navigation can resume) | |
| onEditingCustomOption?.(nowFocusingCustomOption); | |
| }, | |
| [onEditingCustomOption], | |
| ); | |
| const handleSelect = useCallback( | |
| (itemValue: OptionItem) => { | |
| if (question.multiSelect) { | |
| if (itemValue.type === 'option') { | |
| dispatch({ | |
| type: 'TOGGLE_INDEX', | |
| payload: { index: itemValue.index, multiSelect: true }, | |
| }); | |
| } else if (itemValue.type === 'other') { | |
| dispatch({ | |
| type: 'TOGGLE_CUSTOM_SELECTED', | |
| payload: { multiSelect: true }, | |
| }); | |
| } else if (itemValue.type === 'all') { | |
| dispatch({ | |
| type: 'TOGGLE_ALL', | |
| payload: { totalOptions: questionOptions.length }, | |
| }); | |
| } else if (itemValue.type === 'done') { | |
| // Done just triggers navigation, selections already saved via useEffect | |
| onAnswer( | |
| buildAnswerString( | |
| selectedIndices, | |
| isCustomOptionSelected, | |
| customOptionText, | |
| ), | |
| ); | |
| } | |
| } else { | |
| if (itemValue.type === 'option') { | |
| onAnswer(itemValue.label); | |
| } else if (itemValue.type === 'other') { | |
| // In single select, selecting other submits it if it has text | |
| if (customOptionText.trim()) { | |
| onAnswer( | |
| expandPastePlaceholders( | |
| customOptionText, | |
| customBuffer.pastedContent, | |
| ).trim(), | |
| ); | |
| } | |
| } | |
| } | |
| }, | |
| [ | |
| question.multiSelect, | |
| questionOptions.length, | |
| selectedIndices, | |
| isCustomOptionSelected, | |
| customOptionText, | |
| customBuffer.pastedContent, | |
| onAnswer, | |
| buildAnswerString, | |
| ], | |
| ); | |
| // Auto-select custom option when typing in it | |
| useEffect(() => { | |
| if (customOptionText.trim() && !isCustomOptionSelected) { | |
| dispatch({ | |
| type: 'SET_CUSTOM_SELECTED', | |
| payload: { selected: true, multiSelect: !!question.multiSelect }, | |
| }); | |
| } | |
| }, [customOptionText, isCustomOptionSelected, question.multiSelect]); | |
| const HEADER_HEIGHT = progressHeader ? 2 : 0; | |
| const TITLE_MARGIN = 1; | |
| const FOOTER_HEIGHT = 2; // DialogFooter + margin | |
| const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT; | |
| const listHeight = availableHeight | |
| ? Math.max(1, availableHeight - overhead) | |
| : undefined; | |
| // Reserve space for at least 3 items if more selectionItems available. | |
| const reservedListHeight = Math.min(selectionItems.length * 2, 6); | |
| const questionHeightLimit = | |
| listHeight && !isAlternateBuffer | |
| ? question.unconstrainedHeight | |
| ? Math.max(1, listHeight - selectionItems.length * 2) | |
| : Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight)) | |
| : undefined; | |
| const maxItemsToShow = | |
| listHeight && (!isAlternateBuffer || availableHeight !== undefined) | |
| ? Math.min( | |
| selectionItems.length, | |
| Math.max( | |
| 1, | |
| Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2), | |
| ), | |
| ) | |
| : selectionItems.length; | |
| return ( | |
| <Box flexDirection="column"> | |
| {progressHeader} | |
| <Box marginBottom={TITLE_MARGIN}> | |
| <MaxSizedBox | |
| maxHeight={questionHeightLimit} | |
| maxWidth={availableWidth} | |
| overflowDirection="bottom" | |
| > | |
| <Box flexDirection="column"> | |
| <MarkdownDisplay | |
| text={autoBoldIfPlain(question.question)} | |
| terminalWidth={availableWidth - DIALOG_PADDING} | |
| isPending={false} | |
| /> | |
| {question.multiSelect && ( | |
| <Text color={theme.text.secondary} italic> | |
| (Select all that apply) | |
| </Text> | |
| )} | |
| </Box> | |
| </MaxSizedBox> | |
| </Box> | |
| <BaseSelectionList<OptionItem> | |
| items={selectionItems} | |
| onSelect={handleSelect} | |
| onHighlight={handleHighlight} | |
| focusKey={isCustomOptionFocused ? 'other' : undefined} | |
| maxItemsToShow={maxItemsToShow} | |
| showScrollArrows={true} | |
| renderItem={(item, context) => { | |
| const optionItem = item.value; | |
| const isChecked = | |
| (optionItem.type === 'option' && | |
| selectedIndices.has(optionItem.index)) || | |
| (optionItem.type === 'other' && isCustomOptionSelected) || | |
| (optionItem.type === 'all' && | |
| selectedIndices.size === questionOptions.length); | |
| const showCheck = | |
| question.multiSelect && | |
| (optionItem.type === 'option' || | |
| optionItem.type === 'other' || | |
| optionItem.type === 'all'); | |
| // Render inline text input for custom option | |
| if (optionItem.type === 'other') { | |
| const placeholder = question.placeholder || 'Enter a custom value'; | |
| return ( | |
| <Box flexDirection="row"> | |
| {showCheck && ( | |
| <ClickableCheckbox | |
| isChecked={isChecked} | |
| onClick={() => { | |
| if (!context.isSelected) { | |
| handleSelect(optionItem); | |
| } | |
| }} | |
| /> | |
| )} | |
| <Text color={theme.text.primary}> </Text> | |
| <TextInput | |
| buffer={customBuffer} | |
| placeholder={placeholder} | |
| focus={context.isSelected} | |
| onSubmit={(val) => { | |
| if (question.multiSelect) { | |
| const fullAnswer = buildAnswerString( | |
| selectedIndices, | |
| true, | |
| val, | |
| ); | |
| if (fullAnswer) { | |
| onAnswer(fullAnswer); | |
| } | |
| } else if (val.trim()) { | |
| onAnswer(val.trim()); | |
| } | |
| }} | |
| /> | |
| {isChecked && !question.multiSelect && !context.isSelected && ( | |
| <Text color={theme.status.success}> ✓</Text> | |
| )} | |
| </Box> | |
| ); | |
| } | |
| // Determine label color: checked (previously answered) uses success, selected uses accent, else primary | |
| const labelColor = | |
| isChecked && !question.multiSelect | |
| ? theme.status.success | |
| : context.isSelected | |
| ? context.titleColor | |
| : theme.text.primary; | |
| return ( | |
| <Box flexDirection="column"> | |
| <Box flexDirection="row"> | |
| {showCheck && ( | |
| <ClickableCheckbox | |
| isChecked={isChecked} | |
| onClick={() => { | |
| if (!context.isSelected) { | |
| handleSelect(optionItem); | |
| } | |
| }} | |
| /> | |
| )} | |
| <Text color={labelColor} bold={optionItem.type === 'done'}> | |
| {' '} | |
| {optionItem.label} | |
| </Text> | |
| {isChecked && !question.multiSelect && ( | |
| <Text color={theme.status.success}> ✓</Text> | |
| )} | |
| </Box> | |
| {optionItem.description && ( | |
| // Padding aligns with option label: 4 for multi-select (checkbox + space), 1 for single-select | |
| <Box paddingLeft={showCheck ? 4 : 1}> | |
| <Text color={theme.text.secondary} wrap="wrap"> | |
| <RenderInline | |
| text={optionItem.description} | |
| defaultColor={theme.text.secondary} | |
| /> | |
| </Text> | |
| </Box> | |
| )} | |
| </Box> | |
| ); | |
| }} | |
| /> | |
| {keyboardHints} | |
| </Box> | |
| ); | |
| }; | |
| export const AskUserDialog: React.FC<AskUserDialogProps> = ({ | |
| questions, | |
| onSubmit, | |
| onCancel, | |
| onActiveTextInputChange, | |
| width, | |
| availableHeight: availableHeightProp, | |
| extraParts, | |
| }) => { | |
| const keyMatchers = useKeyMatchers(); | |
| const uiState = useContext(UIStateContext); | |
| const availableHeight = | |
| availableHeightProp ?? | |
| (uiState?.constrainHeight !== false | |
| ? uiState?.availableTerminalHeight | |
| : undefined); | |
| const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState); | |
| const { answers, isEditingCustomOption, submitted } = state; | |
| const reviewTabIndex = questions.length; | |
| const tabCount = | |
| questions.length > 1 ? questions.length + 1 : questions.length; | |
| const { currentIndex, goToNextTab, goToPrevTab } = useTabbedNavigation({ | |
| tabCount, | |
| isActive: !submitted && questions.length > 1, | |
| enableArrowNavigation: false, // We'll handle arrows via textBuffer callbacks or manually | |
| enableTabKey: false, // We'll handle tab manually to match existing behavior | |
| }); | |
| const currentQuestionIndex = currentIndex; | |
| const handleEditingCustomOption = useCallback((isEditing: boolean) => { | |
| dispatch({ type: 'SET_EDITING_CUSTOM', payload: { isEditing } }); | |
| }, []); | |
| useEffect(() => { | |
| onActiveTextInputChange?.(isEditingCustomOption); | |
| return () => { | |
| onActiveTextInputChange?.(false); | |
| }; | |
| }, [isEditingCustomOption, onActiveTextInputChange]); | |
| const handleCancel = useCallback( | |
| (key: Key) => { | |
| if (submitted) return false; | |
| if (keyMatchers[Command.ESCAPE](key)) { | |
| onCancel(); | |
| return true; | |
| } else if (keyMatchers[Command.QUIT](key)) { | |
| if (!isEditingCustomOption) { | |
| onCancel(); | |
| } | |
| // Return false to let ctrl-C bubble up to AppContainer for exit flow | |
| return false; | |
| } | |
| return false; | |
| }, | |
| [onCancel, submitted, isEditingCustomOption, keyMatchers], | |
| ); | |
| useKeypress(handleCancel, { | |
| isActive: !submitted, | |
| }); | |
| const isOnReviewTab = currentQuestionIndex === reviewTabIndex; | |
| const handleNavigation = useCallback( | |
| (key: Key) => { | |
| if (submitted || questions.length <= 1) return false; | |
| const isNextKey = keyMatchers[Command.DIALOG_NEXT](key); | |
| const isPrevKey = keyMatchers[Command.DIALOG_PREV](key); | |
| const isRight = keyMatchers[Command.MOVE_RIGHT](key); | |
| const isLeft = keyMatchers[Command.MOVE_LEFT](key); | |
| // Tab keys always trigger navigation. | |
| // Arrows trigger navigation if NOT in a text input OR if the input bubbles the event (already at edge). | |
| const shouldGoNext = isNextKey || isRight; | |
| const shouldGoPrev = isPrevKey || isLeft; | |
| if (shouldGoNext) { | |
| goToNextTab(); | |
| return true; | |
| } else if (shouldGoPrev) { | |
| goToPrevTab(); | |
| return true; | |
| } | |
| return false; | |
| }, | |
| [questions.length, submitted, goToNextTab, goToPrevTab, keyMatchers], | |
| ); | |
| useKeypress(handleNavigation, { | |
| isActive: questions.length > 1 && !submitted, | |
| }); | |
| useEffect(() => { | |
| if (submitted) { | |
| onSubmit(answers); | |
| } | |
| }, [submitted, answers, onSubmit]); | |
| const handleAnswer = useCallback( | |
| (answer: string) => { | |
| if (submitted) return; | |
| if (questions.length > 1) { | |
| dispatch({ | |
| type: 'SET_ANSWER', | |
| payload: { | |
| index: currentQuestionIndex, | |
| answer, | |
| }, | |
| }); | |
| goToNextTab(); | |
| } else { | |
| dispatch({ | |
| type: 'SET_ANSWER', | |
| payload: { | |
| index: currentQuestionIndex, | |
| answer, | |
| submit: true, | |
| }, | |
| }); | |
| } | |
| }, | |
| [currentQuestionIndex, questions, submitted, goToNextTab], | |
| ); | |
| const handleReviewSubmit = useCallback(() => { | |
| if (submitted) return; | |
| dispatch({ type: 'SUBMIT' }); | |
| }, [submitted]); | |
| const handleSelectionChange = useCallback( | |
| (answer: string) => { | |
| if (submitted) return; | |
| dispatch({ | |
| type: 'SET_ANSWER', | |
| payload: { | |
| index: currentQuestionIndex, | |
| answer, | |
| }, | |
| }); | |
| }, | |
| [submitted, currentQuestionIndex], | |
| ); | |
| const answeredIndices = useMemo( | |
| () => new Set(Object.keys(answers).map(Number)), | |
| [answers], | |
| ); | |
| const currentQuestion = questions[currentQuestionIndex]; | |
| const effectiveQuestion = useMemo(() => { | |
| if (currentQuestion?.type === 'yesno') { | |
| return { | |
| ...currentQuestion, | |
| options: [ | |
| { label: 'Yes', description: '' }, | |
| { label: 'No', description: '' }, | |
| ], | |
| multiSelect: false, | |
| }; | |
| } | |
| return currentQuestion; | |
| }, [currentQuestion]); | |
| const tabs = useMemo((): Tab[] => { | |
| const questionTabs: Tab[] = questions.map((q, i) => ({ | |
| key: String(i), | |
| header: q.header, | |
| })); | |
| if (questions.length > 1) { | |
| questionTabs.push({ | |
| key: 'review', | |
| header: 'Review', | |
| isSpecial: true, | |
| }); | |
| } | |
| return questionTabs; | |
| }, [questions]); | |
| const progressHeader = | |
| questions.length > 1 ? ( | |
| <TabHeader | |
| tabs={tabs} | |
| currentIndex={currentQuestionIndex} | |
| completedIndices={answeredIndices} | |
| /> | |
| ) : null; | |
| if (isOnReviewTab) { | |
| return ( | |
| <Box aria-label="Review your answers"> | |
| <ReviewView | |
| questions={questions} | |
| answers={answers} | |
| onSubmit={handleReviewSubmit} | |
| progressHeader={progressHeader} | |
| extraParts={extraParts} | |
| /> | |
| </Box> | |
| ); | |
| } | |
| if (!currentQuestion) return null; | |
| const keyboardHints = ( | |
| <DialogFooter | |
| primaryAction={ | |
| currentQuestion.type === 'text' || isEditingCustomOption | |
| ? 'Enter to submit' | |
| : 'Enter to select' | |
| } | |
| navigationActions={ | |
| questions.length > 1 | |
| ? currentQuestion.type === 'text' || isEditingCustomOption | |
| ? `${formatCommand(Command.DIALOG_NEXT)}/${formatCommand(Command.DIALOG_PREV)} to switch questions` | |
| : '←/→ to switch questions' | |
| : currentQuestion.type === 'text' || isEditingCustomOption | |
| ? undefined | |
| : '↑/↓ to navigate' | |
| } | |
| extraParts={extraParts} | |
| /> | |
| ); | |
| const questionView = | |
| currentQuestion.type === 'text' ? ( | |
| <TextQuestionView | |
| key={currentQuestionIndex} | |
| question={currentQuestion} | |
| onAnswer={handleAnswer} | |
| onSelectionChange={handleSelectionChange} | |
| onEditingCustomOption={handleEditingCustomOption} | |
| availableWidth={width} | |
| availableHeight={availableHeight} | |
| initialAnswer={answers[currentQuestionIndex]} | |
| progressHeader={progressHeader} | |
| keyboardHints={keyboardHints} | |
| /> | |
| ) : ( | |
| <ChoiceQuestionView | |
| key={currentQuestionIndex} | |
| question={effectiveQuestion} | |
| onAnswer={handleAnswer} | |
| onSelectionChange={handleSelectionChange} | |
| onEditingCustomOption={handleEditingCustomOption} | |
| availableWidth={width} | |
| availableHeight={availableHeight} | |
| initialAnswer={answers[currentQuestionIndex]} | |
| progressHeader={progressHeader} | |
| keyboardHints={keyboardHints} | |
| /> | |
| ); | |
| return ( | |
| <Box | |
| flexDirection="column" | |
| width={width} | |
| aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`} | |
| > | |
| {questionView} | |
| </Box> | |
| ); | |
| }; | |