mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-28 22:14:52 -07:00
refactor(cli): keyboard handling and AskUserDialog (#17414)
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
useReducer,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
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';
|
||||
@@ -25,40 +25,30 @@ import { checkExhaustive } from '../../utils/checks.js';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { useTextBuffer } from './shared/text-buffer.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { cpLen } from '../utils/textUtils.js';
|
||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
|
||||
interface AskUserDialogState {
|
||||
currentQuestionIndex: number;
|
||||
answers: { [key: string]: string };
|
||||
isEditingCustomOption: boolean;
|
||||
cursorEdge: { left: boolean; right: boolean };
|
||||
submitted: boolean;
|
||||
}
|
||||
|
||||
type AskUserDialogAction =
|
||||
| {
|
||||
type: 'NEXT_QUESTION';
|
||||
payload: { maxIndex: number };
|
||||
}
|
||||
| { type: 'PREV_QUESTION' }
|
||||
| {
|
||||
type: 'SET_ANSWER';
|
||||
payload: {
|
||||
index?: number;
|
||||
index: number;
|
||||
answer: string;
|
||||
autoAdvance?: boolean;
|
||||
maxIndex?: number;
|
||||
};
|
||||
}
|
||||
| { type: 'SET_EDITING_CUSTOM'; payload: { isEditing: boolean } }
|
||||
| { type: 'SET_CURSOR_EDGE'; payload: { left: boolean; right: boolean } }
|
||||
| { type: 'SUBMIT' };
|
||||
|
||||
const initialState: AskUserDialogState = {
|
||||
currentQuestionIndex: 0,
|
||||
answers: {},
|
||||
isEditingCustomOption: false,
|
||||
cursorEdge: { left: true, right: true },
|
||||
submitted: false,
|
||||
};
|
||||
|
||||
@@ -71,56 +61,22 @@ function askUserDialogReducerLogic(
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'NEXT_QUESTION': {
|
||||
const { maxIndex } = action.payload;
|
||||
if (state.currentQuestionIndex < maxIndex) {
|
||||
return {
|
||||
...state,
|
||||
currentQuestionIndex: state.currentQuestionIndex + 1,
|
||||
isEditingCustomOption: false,
|
||||
cursorEdge: { left: true, right: true },
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case 'PREV_QUESTION': {
|
||||
if (state.currentQuestionIndex > 0) {
|
||||
return {
|
||||
...state,
|
||||
currentQuestionIndex: state.currentQuestionIndex - 1,
|
||||
isEditingCustomOption: false,
|
||||
cursorEdge: { left: true, right: true },
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case 'SET_ANSWER': {
|
||||
const { index, answer, autoAdvance, maxIndex } = action.payload;
|
||||
const targetIndex = index ?? state.currentQuestionIndex;
|
||||
const { index, answer } = action.payload;
|
||||
const hasAnswer =
|
||||
answer !== undefined && answer !== null && answer.trim() !== '';
|
||||
const newAnswers = { ...state.answers };
|
||||
|
||||
if (hasAnswer) {
|
||||
newAnswers[targetIndex] = answer;
|
||||
newAnswers[index] = answer;
|
||||
} else {
|
||||
delete newAnswers[targetIndex];
|
||||
delete newAnswers[index];
|
||||
}
|
||||
|
||||
const newState = {
|
||||
return {
|
||||
...state,
|
||||
answers: newAnswers,
|
||||
};
|
||||
|
||||
if (autoAdvance && typeof maxIndex === 'number') {
|
||||
if (newState.currentQuestionIndex < maxIndex) {
|
||||
newState.currentQuestionIndex += 1;
|
||||
newState.isEditingCustomOption = false;
|
||||
newState.cursorEdge = { left: true, right: true };
|
||||
}
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
case 'SET_EDITING_CUSTOM': {
|
||||
if (state.isEditingCustomOption === action.payload.isEditing) {
|
||||
@@ -131,16 +87,6 @@ function askUserDialogReducerLogic(
|
||||
isEditingCustomOption: action.payload.isEditing,
|
||||
};
|
||||
}
|
||||
case 'SET_CURSOR_EDGE': {
|
||||
const { left, right } = action.payload;
|
||||
if (state.cursorEdge.left === left && state.cursorEdge.right === right) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
cursorEdge: { left, right },
|
||||
};
|
||||
}
|
||||
case 'SUBMIT': {
|
||||
return {
|
||||
...state,
|
||||
@@ -198,7 +144,9 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.RETURN](key)) {
|
||||
onSubmit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
@@ -235,11 +183,10 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
|
||||
</Text>
|
||||
</Box>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to submit"
|
||||
navigationActions="Tab/Shift+Tab to edit answers"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -251,7 +198,7 @@ interface TextQuestionViewProps {
|
||||
onAnswer: (answer: string) => void;
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
onCursorEdgeChange?: (edge: { left: boolean; right: boolean }) => void;
|
||||
availableWidth: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -262,18 +209,19 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
onAnswer,
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
onCursorEdgeChange,
|
||||
availableWidth,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = uiState?.terminalWidth ?? stdout?.columns ?? 80;
|
||||
const prefix = '> ';
|
||||
const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor
|
||||
const bufferWidth =
|
||||
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: initialAnswer,
|
||||
viewport: { width: terminalWidth - 10, height: 1 },
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
isValidPath: () => false,
|
||||
});
|
||||
@@ -289,32 +237,19 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
}
|
||||
}, [textValue, onSelectionChange]);
|
||||
|
||||
// Sync cursor edge state with parent - only when it actually changes
|
||||
const lastEdgeRef = useRef<{ left: boolean; right: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
const isLeft = buffer.cursor[1] === 0;
|
||||
const isRight = buffer.cursor[1] === cpLen(buffer.lines[0] || '');
|
||||
if (
|
||||
!lastEdgeRef.current ||
|
||||
isLeft !== lastEdgeRef.current.left ||
|
||||
isRight !== lastEdgeRef.current.right
|
||||
) {
|
||||
onCursorEdgeChange?.({ left: isLeft, right: isRight });
|
||||
lastEdgeRef.current = { left: isLeft, right: isRight };
|
||||
}
|
||||
}, [buffer.cursor, buffer.lines, onCursorEdgeChange]);
|
||||
|
||||
// Handle Ctrl+C to clear all text
|
||||
const handleExtraKeys = useCallback(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
buffer.setText('');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[buffer],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true });
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(val: string) => {
|
||||
@@ -445,7 +380,7 @@ interface ChoiceQuestionViewProps {
|
||||
onAnswer: (answer: string) => void;
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
onCursorEdgeChange?: (edge: { left: boolean; right: boolean }) => void;
|
||||
availableWidth: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -456,14 +391,33 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
onAnswer,
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
onCursorEdgeChange,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = uiState?.terminalWidth ?? stdout?.columns ?? 80;
|
||||
const terminalWidth = uiState?.terminalWidth ?? 80;
|
||||
const availableWidth = terminalWidth;
|
||||
|
||||
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 outerBoxPadding = 4; // border (2) + paddingX (2)
|
||||
|
||||
const horizontalPadding =
|
||||
outerBoxPadding +
|
||||
radioWidth +
|
||||
numberWidth +
|
||||
checkboxWidth +
|
||||
checkmarkWidth +
|
||||
cursorPadding;
|
||||
|
||||
const bufferWidth = availableWidth - horizontalPadding;
|
||||
|
||||
const questionOptions = useMemo(
|
||||
() => question.options ?? [],
|
||||
@@ -537,29 +491,13 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
|
||||
const customBuffer = useTextBuffer({
|
||||
initialText: initialCustomText,
|
||||
viewport: { width: terminalWidth - 20, height: 1 },
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
isValidPath: () => false,
|
||||
});
|
||||
|
||||
const customOptionText = customBuffer.text;
|
||||
|
||||
// Sync cursor edge state with parent - only when it actually changes
|
||||
const lastEdgeRef = useRef<{ left: boolean; right: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
const isLeft = customBuffer.cursor[1] === 0;
|
||||
const isRight =
|
||||
customBuffer.cursor[1] === cpLen(customBuffer.lines[0] || '');
|
||||
if (
|
||||
!lastEdgeRef.current ||
|
||||
isLeft !== lastEdgeRef.current.left ||
|
||||
isRight !== lastEdgeRef.current.right
|
||||
) {
|
||||
onCursorEdgeChange?.({ left: isLeft, right: isRight });
|
||||
lastEdgeRef.current = { left: isLeft, right: isRight };
|
||||
}
|
||||
}, [customBuffer.cursor, customBuffer.lines, onCursorEdgeChange]);
|
||||
|
||||
// Helper to build answer string from selections
|
||||
const buildAnswerString = useCallback(
|
||||
(
|
||||
@@ -607,31 +545,51 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
// If focusing custom option, handle Ctrl+C
|
||||
if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) {
|
||||
customBuffer.setText('');
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Type-to-jump: if a printable character is typed and not focused, jump to custom
|
||||
// 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.sequence.length === 1 &&
|
||||
!key.ctrl &&
|
||||
!key.alt &&
|
||||
key.sequence.charCodeAt(0) >= 32;
|
||||
(key.sequence.length > 1 || key.sequence.charCodeAt(0) >= 32);
|
||||
|
||||
const isNumber = /^[0-9]$/.test(key.sequence);
|
||||
|
||||
if (isPrintable && !isCustomOptionFocused && !isNumber) {
|
||||
if (isPrintable && !isCustomOptionFocused) {
|
||||
dispatch({ type: 'SET_CUSTOM_FOCUSED', payload: { focused: true } });
|
||||
onEditingCustomOption?.(true);
|
||||
// We can't easily inject the first key into useTextBuffer's internal state
|
||||
// but TextInput will handle subsequent keys once it's focused.
|
||||
// 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],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true });
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
|
||||
const selectionItems = useMemo((): Array<SelectionListItem<OptionItem>> => {
|
||||
const list: Array<SelectionListItem<OptionItem>> = questionOptions.map(
|
||||
@@ -841,11 +799,6 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A dialog component for asking the user a series of questions.
|
||||
* Supports multiple question types (text, choice, yes/no, multi-select),
|
||||
* navigation between questions, and a final review step.
|
||||
*/
|
||||
export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
questions,
|
||||
onSubmit,
|
||||
@@ -853,30 +806,29 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onActiveTextInputChange,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
|
||||
const {
|
||||
currentQuestionIndex,
|
||||
answers,
|
||||
isEditingCustomOption,
|
||||
cursorEdge,
|
||||
submitted,
|
||||
} = state;
|
||||
const { answers, isEditingCustomOption, submitted } = state;
|
||||
|
||||
// Use refs for synchronous checks to prevent race conditions in handleCancel
|
||||
const isEditingCustomOptionRef = useRef(false);
|
||||
isEditingCustomOptionRef.current = isEditingCustomOption;
|
||||
const uiState = useContext(UIStateContext);
|
||||
const terminalWidth = uiState?.terminalWidth ?? 80;
|
||||
const availableWidth = terminalWidth;
|
||||
|
||||
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 } });
|
||||
}, []);
|
||||
|
||||
const handleCursorEdgeChange = useCallback(
|
||||
(edge: { left: boolean; right: boolean }) => {
|
||||
dispatch({ type: 'SET_CURSOR_EDGE', payload: edge });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Sync isEditingCustomOption state with parent for global keypress handling
|
||||
useEffect(() => {
|
||||
onActiveTextInputChange?.(isEditingCustomOption);
|
||||
return () => {
|
||||
@@ -884,70 +836,58 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
};
|
||||
}, [isEditingCustomOption, onActiveTextInputChange]);
|
||||
|
||||
// Handle Escape or Ctrl+C to cancel (but not Ctrl+C when editing custom option)
|
||||
const handleCancel = useCallback(
|
||||
(key: Key) => {
|
||||
if (submitted) return;
|
||||
if (submitted) return false;
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onCancel();
|
||||
} else if (
|
||||
keyMatchers[Command.QUIT](key) &&
|
||||
!isEditingCustomOptionRef.current
|
||||
) {
|
||||
return true;
|
||||
} else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[onCancel, submitted],
|
||||
[onCancel, submitted, isEditingCustomOption],
|
||||
);
|
||||
|
||||
useKeypress(handleCancel, {
|
||||
isActive: !submitted,
|
||||
});
|
||||
|
||||
// Review tab is at index questions.length (after all questions)
|
||||
const reviewTabIndex = questions.length;
|
||||
const isOnReviewTab = currentQuestionIndex === reviewTabIndex;
|
||||
|
||||
// Bidirectional navigation between questions using custom useKeypress for consistency
|
||||
const handleNavigation = useCallback(
|
||||
(key: Key) => {
|
||||
if (submitted) return;
|
||||
if (submitted || questions.length <= 1) return false;
|
||||
|
||||
const isTab = key.name === 'tab';
|
||||
const isShiftTab = isTab && key.shift;
|
||||
const isPlainTab = isTab && !key.shift;
|
||||
const isNextKey = keyMatchers[Command.DIALOG_NEXT](key);
|
||||
const isPrevKey = keyMatchers[Command.DIALOG_PREV](key);
|
||||
|
||||
const isRight = key.name === 'right' && !key.ctrl && !key.alt;
|
||||
const isLeft = key.name === 'left' && !key.ctrl && !key.alt;
|
||||
const isRight = keyMatchers[Command.MOVE_RIGHT](key);
|
||||
const isLeft = keyMatchers[Command.MOVE_LEFT](key);
|
||||
|
||||
// Tab always works. Arrows work if NOT editing OR if at the corresponding edge.
|
||||
const shouldGoNext =
|
||||
isPlainTab || (isRight && (!isEditingCustomOption || cursorEdge.right));
|
||||
const shouldGoPrev =
|
||||
isShiftTab || (isLeft && (!isEditingCustomOption || cursorEdge.left));
|
||||
// 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) {
|
||||
// Allow navigation up to Review tab for multi-question flows
|
||||
const maxIndex =
|
||||
questions.length > 1 ? reviewTabIndex : questions.length - 1;
|
||||
dispatch({
|
||||
type: 'NEXT_QUESTION',
|
||||
payload: { maxIndex },
|
||||
});
|
||||
goToNextTab();
|
||||
return true;
|
||||
} else if (shouldGoPrev) {
|
||||
dispatch({
|
||||
type: 'PREV_QUESTION',
|
||||
});
|
||||
goToPrevTab();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[isEditingCustomOption, cursorEdge, questions, reviewTabIndex, submitted],
|
||||
[questions.length, submitted, goToNextTab, goToPrevTab],
|
||||
);
|
||||
|
||||
useKeypress(handleNavigation, {
|
||||
isActive: questions.length > 1 && !submitted,
|
||||
});
|
||||
|
||||
// Effect to trigger submission when state.submitted becomes true
|
||||
useEffect(() => {
|
||||
if (submitted) {
|
||||
onSubmit(answers);
|
||||
@@ -958,24 +898,23 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
(answer: string) => {
|
||||
if (submitted) return;
|
||||
|
||||
const reviewTabIndex = questions.length;
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
autoAdvance: questions.length > 1,
|
||||
maxIndex: reviewTabIndex,
|
||||
},
|
||||
});
|
||||
|
||||
if (questions.length === 1) {
|
||||
if (questions.length > 1) {
|
||||
goToNextTab();
|
||||
} else {
|
||||
dispatch({ type: 'SUBMIT' });
|
||||
}
|
||||
},
|
||||
[questions.length, submitted],
|
||||
[currentQuestionIndex, questions.length, submitted, goToNextTab],
|
||||
);
|
||||
|
||||
// Submit from Review tab
|
||||
const handleReviewSubmit = useCallback(() => {
|
||||
if (submitted) return;
|
||||
dispatch({ type: 'SUBMIT' });
|
||||
@@ -987,12 +926,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
autoAdvance: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
[submitted],
|
||||
[submitted, currentQuestionIndex],
|
||||
);
|
||||
|
||||
const answeredIndices = useMemo(
|
||||
@@ -1002,7 +941,6 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
|
||||
// For yesno type, generate Yes/No options and force single-select
|
||||
const effectiveQuestion = useMemo(() => {
|
||||
if (currentQuestion?.type === 'yesno') {
|
||||
return {
|
||||
@@ -1017,13 +955,11 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
return currentQuestion;
|
||||
}, [currentQuestion]);
|
||||
|
||||
// Build tabs array for TabHeader
|
||||
const tabs = useMemo((): Tab[] => {
|
||||
const questionTabs: Tab[] = questions.map((q, i) => ({
|
||||
key: String(i),
|
||||
header: q.header,
|
||||
}));
|
||||
// Add review tab when there are multiple questions
|
||||
if (questions.length > 1) {
|
||||
questionTabs.push({
|
||||
key: 'review',
|
||||
@@ -1043,63 +979,74 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// Render Review tab when on it
|
||||
if (isOnReviewTab) {
|
||||
return (
|
||||
<ReviewView
|
||||
questions={questions}
|
||||
answers={answers}
|
||||
onSubmit={handleReviewSubmit}
|
||||
progressHeader={progressHeader}
|
||||
/>
|
||||
<Box aria-label="Review your answers">
|
||||
<ReviewView
|
||||
questions={questions}
|
||||
answers={answers}
|
||||
onSubmit={handleReviewSubmit}
|
||||
progressHeader={progressHeader}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Safeguard for invalid question index
|
||||
if (!currentQuestion) return null;
|
||||
|
||||
const keyboardHints = (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentQuestion.type === 'text' || isEditingCustomOption
|
||||
? questions.length > 1
|
||||
? 'Enter to submit · Tab/Shift+Tab to switch questions · Esc to cancel'
|
||||
: 'Enter to submit · Esc to cancel'
|
||||
: questions.length > 1
|
||||
? 'Enter to select · ←/→ to switch questions · Esc to cancel'
|
||||
: 'Enter to select · ↑/↓ to navigate · Esc to cancel'}
|
||||
</Text>
|
||||
</Box>
|
||||
<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'
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Render text-type or choice-type question view
|
||||
if (currentQuestion.type === 'text') {
|
||||
return (
|
||||
const questionView =
|
||||
currentQuestion.type === 'text' ? (
|
||||
<TextQuestionView
|
||||
key={currentQuestionIndex}
|
||||
question={currentQuestion}
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
onCursorEdgeChange={handleCursorEdgeChange}
|
||||
availableWidth={availableWidth}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
/>
|
||||
) : (
|
||||
<ChoiceQuestionView
|
||||
key={currentQuestionIndex}
|
||||
question={effectiveQuestion}
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
availableWidth={availableWidth}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChoiceQuestionView
|
||||
key={currentQuestionIndex}
|
||||
question={effectiveQuestion}
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
onCursorEdgeChange={handleCursorEdgeChange}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
/>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={availableWidth}
|
||||
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
|
||||
>
|
||||
{questionView}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user