fix(cli): auto-submit on number key press in AskUserQuestionDialog (#3407)

Fixes #500.

Number keys in AskUserQuestionDialog previously only moved the highlight
cursor without submitting, inconsistent with RadioButtonSelect and the
standard tool approval dialog. Users pressed a number, saw the option
highlight, and assumed it was selected, but the dialog was still waiting
for Enter.

- For single-select predefined options, pressing a number key now
  auto-submits immediately.
- Multi-select, "Other" custom input, and the Submit tab remain
  highlight-only (unchanged).
- Extracted a shared selectAndAdvance helper to deduplicate the
  select-and-submit/advance logic across 4 code paths (number key,
  Enter, multi-select submit, custom input submit).
- Removed redundant isFocused guard inside the useKeypress callback;
  it is already handled via the isActive parameter.

Tests cover all four behavioral branches: single-select auto-submits,
multi-select does not, "Other" custom input does not, and the Submit
tab does not.
This commit is contained in:
jinye 2026-04-18 10:03:32 +08:00 committed by GitHub
parent ed6f9e056e
commit 699cb05206
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 135 additions and 65 deletions

View file

@ -173,6 +173,50 @@ describe('<AskUserQuestionDialog />', () => {
);
unmount();
});
it('auto-submits when pressing a number key for a predefined option', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails();
const { stdin, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
// Press '2' to select the second option (Blue) — should auto-submit
stdin.write('2');
await wait();
expect(onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
{ answers: { 0: 'Blue' } },
);
unmount();
});
it('does not auto-submit when pressing number key for "Other" custom input', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails();
const { stdin, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
// Press '4' to select the "Other" option (index 3, after 3 predefined options)
stdin.write('4');
await wait();
// Should NOT auto-submit — just highlight "Other" for text input
expect(onConfirm).not.toHaveBeenCalled();
unmount();
});
it('cancels with Escape', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails();
@ -194,6 +238,28 @@ describe('<AskUserQuestionDialog />', () => {
});
describe('multi-select interaction', () => {
it('does not auto-submit when pressing number key in multi-select mode', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails({
questions: [createSingleQuestion({ multiSelect: true })],
});
const { stdin, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
// Press '2' — should only move highlight, not submit
stdin.write('2');
await wait();
expect(onConfirm).not.toHaveBeenCalled();
unmount();
});
it('toggles options with Space', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails({
@ -219,6 +285,40 @@ describe('<AskUserQuestionDialog />', () => {
});
describe('multiple questions', () => {
it.skipIf(process.platform === 'win32')(
'does not auto-submit when pressing number key on Submit tab',
async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails({
questions: [
createSingleQuestion({ header: 'Q1' }),
createSingleQuestion({ header: 'Q2' }),
],
});
const { stdin, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
// Navigate to Submit tab
stdin.write('\u001B[C'); // Right
await wait();
stdin.write('\u001B[C'); // Right
await wait();
// Press '1' on Submit tab — should only highlight, not submit
stdin.write('1');
await wait();
expect(onConfirm).not.toHaveBeenCalled();
unmount();
},
);
it.skipIf(process.platform === 'win32')(
'shows unanswered questions as (not answered) in Submit tab',
async () => {

View file

@ -102,6 +102,22 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
await onConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
};
// Select a value for the current question, then submit (single question) or advance to the next tab (multi-question).
const selectAndAdvance = (value: string) => {
setSelectedOptions((prev) => ({ ...prev, [currentQuestionIndex]: value }));
if (!hasMultipleQuestions) {
void onConfirm(ToolConfirmationOutcome.ProceedOnce, {
answers: { [currentQuestionIndex]: value },
});
} else if (currentQuestionIndex < totalTabs - 1) {
setTimeout(() => {
setCurrentQuestionIndex((prev) => Math.min(prev + 1, totalTabs - 1));
setSelectedIndex(0);
}, 150);
}
};
const handleMultiSelectSubmit = () => {
if (!currentQuestion) return;
const selections = [...(multiSelectedOptions[currentQuestionIndex] ?? [])];
@ -111,22 +127,7 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
}
if (selections.length === 0) return;
const value = selections.join(', ');
const updated = { ...selectedOptions, [currentQuestionIndex]: value };
setSelectedOptions(updated);
if (!hasMultipleQuestions) {
void onConfirm(ToolConfirmationOutcome.ProceedOnce, {
answers: { [currentQuestionIndex]: value },
});
} else {
if (currentQuestionIndex < totalTabs - 1) {
setTimeout(() => {
setCurrentQuestionIndex((prev) => Math.min(prev + 1, totalTabs - 1));
setSelectedIndex(0);
}, 150);
}
}
selectAndAdvance(selections.join(', '));
};
const handleCustomInputSubmit = () => {
@ -143,37 +144,13 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
}
if (!trimmedValue) return;
const updated = {
...selectedOptions,
[currentQuestionIndex]: trimmedValue,
};
setSelectedOptions(updated);
// If single question, submit immediately
if (!hasMultipleQuestions) {
void onConfirm(ToolConfirmationOutcome.ProceedOnce, {
answers: {
[currentQuestionIndex]: trimmedValue,
},
});
} else {
// Auto-advance to next tab
if (currentQuestionIndex < totalTabs - 1) {
setTimeout(() => {
setCurrentQuestionIndex((prev) => Math.min(prev + 1, totalTabs - 1));
setSelectedIndex(0);
}, 150);
}
}
selectAndAdvance(trimmedValue);
};
// Handle navigation and selection
useKeypress(
(key) => {
if (!isFocused) return;
// When custom input is focused, still allow up/down navigation, tab switch and escape
// When custom input is focused, still allow up/down navigation and escape
if (isCustomInputSelected) {
if (key.name === 'up') {
setSelectedIndex(Math.max(0, selectedIndex - 1));
@ -221,7 +198,21 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
// Number key selection
const numKey = parseInt(input || '', 10);
if (!isNaN(numKey) && numKey >= 1 && numKey <= totalOptions) {
setSelectedIndex(numKey - 1);
const targetIndex = numKey - 1;
setSelectedIndex(targetIndex);
// For single-select, auto-submit when selecting a predefined option (not "Other")
if (
!isMultiSelect &&
!isSubmitTab &&
currentQuestion &&
targetIndex < currentQuestion.options.length
) {
const option = currentQuestion.options[targetIndex];
if (option) {
selectAndAdvance(option.label);
}
}
return;
}
@ -272,28 +263,7 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
if (currentQuestion && selectedIndex < currentQuestion.options.length) {
const option = currentQuestion.options[selectedIndex];
if (option) {
const updated = {
...selectedOptions,
[currentQuestionIndex]: option.label,
};
setSelectedOptions(updated);
// If single question, submit immediately
if (!hasMultipleQuestions) {
void onConfirm(ToolConfirmationOutcome.ProceedOnce, {
answers: { [currentQuestionIndex]: option.label },
});
} else {
// Auto-advance to next tab after selection
if (currentQuestionIndex < totalTabs - 1) {
setTimeout(() => {
setCurrentQuestionIndex((prev) =>
Math.min(prev + 1, totalTabs - 1),
);
setSelectedIndex(0);
}, 150);
}
}
selectAndAdvance(option.label);
}
}
return;