Files
gemini-cli/packages/cli/src/ui/components/AskUserDialog.tsx
2026-03-05 23:02:53 -05:00

1206 lines
34 KiB
TypeScript

/**
* @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 } from 'ink';
import { theme } from '../semantic-colors.js';
import 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 { keyMatchers, Command } from '../keyMatchers.js';
import { checkExhaustive } from '@google/gemini-cli-core';
import { TextInput } from './shared/TextInput.js';
import { useTextBuffer } 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';
/** 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;
}
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 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="Tab/Shift+Tab 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 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?.(textValue);
lastTextValueRef.current = textValue;
}
}, [textValue, onSelectionChange]);
// 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],
);
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';
index: number;
}
interface ChoiceQuestionState {
selectedIndices: Set<number>;
isCustomOptionSelected: boolean;
isCustomOptionFocused: boolean;
}
type ChoiceQuestionAction =
| { type: 'TOGGLE_INDEX'; payload: { index: number; multiSelect: boolean } }
| {
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_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 isAlternateBuffer = useAlternateBuffer();
const numOptions =
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
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()) {
answers.push(customOption.trim());
}
return answers.join(', ');
},
[questionOptions],
);
// 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,
],
);
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 };
},
);
// Only add custom option for choice type, not yesno
if (question.type !== 'yesno') {
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, question.type, 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 === '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(customOptionText.trim());
}
}
}
},
[
question.multiSelect,
selectedIndices,
isCustomOptionSelected,
customOptionText,
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;
const maxQuestionHeight =
question.unconstrainedHeight && listHeight
? // When unconstrained, give the question a majority of the vertical space (e.g., 70%).
// The options list will take the remaining space and scroll if necessary.
// This is more robust than calculating based on `selectionItems.length`,
// which can incorrectly shrink the question if there are many options.
Math.max(5, Math.floor(listHeight * 0.7))
: 15;
const questionHeight =
listHeight && !isAlternateBuffer
? Math.min(maxQuestionHeight, Math.max(1, listHeight - DIALOG_PADDING))
: undefined;
const maxItemsToShow =
listHeight && questionHeight
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
: selectionItems.length;
return (
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={TITLE_MARGIN}>
<MaxSizedBox
maxHeight={questionHeight}
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 =
selectedIndices.has(optionItem.index) ||
(optionItem.type === 'other' && isCustomOptionSelected);
const showCheck =
question.multiSelect &&
(optionItem.type === 'option' || optionItem.type === 'other');
// Render inline text input for custom option
if (optionItem.type === 'other') {
const placeholder = question.placeholder || 'Enter a custom value';
return (
<Box flexDirection="row">
{showCheck && (
<Text
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? 'x' : ' '}]
</Text>
)}
<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 && (
<Text
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? 'x' : ' '}]
</Text>
)}
<Text color={labelColor} bold={optionItem.type === 'done'}>
{' '}
{optionItem.label}
</Text>
{isChecked && !question.multiSelect && (
<Text color={theme.status.success}> </Text>
)}
</Box>
{optionItem.description && (
<Text color={theme.text.secondary} wrap="wrap">
{' '}
<RenderInline
text={optionItem.description}
defaultColor={theme.text.secondary}
/>
</Text>
)}
</Box>
);
}}
/>
{keyboardHints}
</Box>
);
};
export const AskUserDialog: React.FC<AskUserDialogProps> = ({
questions,
onSubmit,
onCancel,
onActiveTextInputChange,
width,
availableHeight: availableHeightProp,
extraParts,
}) => {
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],
);
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],
);
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
? 'Tab/Shift+Tab 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>
);
};