From 77a874cf65262e3aecb4b3d8544dc1806b3a4d80 Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:17:34 -0400 Subject: [PATCH] feat(plan): add 'All the above' option to multi-select AskUser questions (#22365) Co-authored-by: jacob314 --- docs/tools/ask-user.md | 3 +- .../src/ui/components/AskUserDialog.test.tsx | 61 +++++++++++++++++++ .../cli/src/ui/components/AskUserDialog.tsx | 51 ++++++++++++++-- .../__snapshots__/AskUserDialog.test.tsx.snap | 16 +++++ 4 files changed, 126 insertions(+), 5 deletions(-) diff --git a/docs/tools/ask-user.md b/docs/tools/ask-user.md index 8c086acdba..14770b4c99 100644 --- a/docs/tools/ask-user.md +++ b/docs/tools/ask-user.md @@ -25,7 +25,8 @@ confirmation. - `label` (string, required): Display text (1-5 words). - `description` (string, required): Brief explanation. - `multiSelect` (boolean, optional): For `'choice'` type, allows selecting - multiple options. + multiple options. Automatically adds an "All the above" option if there + are multiple standard options. - `placeholder` (string, optional): Hint text for input fields. - **Behavior:** diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 0857306ea8..0469bec373 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -87,6 +87,31 @@ describe('AskUserDialog', () => { writeKey(stdin, '\r'); // Toggle TS writeKey(stdin, '\x1b[B'); // Down writeKey(stdin, '\r'); // Toggle ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\x1b[B'); // Down to Other + writeKey(stdin, '\x1b[B'); // Down to Done + writeKey(stdin, '\r'); // Done + }, + expectedSubmit: { '0': 'TypeScript, ESLint' }, + }, + { + name: 'All of the above', + questions: [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [ + { label: 'TypeScript', description: '' }, + { label: 'ESLint', description: '' }, + ], + multiSelect: true, + }, + ] as Question[], + actions: (stdin: { write: (data: string) => void }) => { + writeKey(stdin, '\x1b[B'); // Down to ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\r'); // Toggle All of the above writeKey(stdin, '\x1b[B'); // Down to Other writeKey(stdin, '\x1b[B'); // Down to Done writeKey(stdin, '\r'); // Done @@ -131,6 +156,42 @@ describe('AskUserDialog', () => { }); }); + it('verifies "All of the above" visual state with snapshot', async () => { + const questions = [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [ + { label: 'TypeScript', description: '' }, + { label: 'ESLint', description: '' }, + ], + multiSelect: true, + }, + ] as Question[]; + + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 120 }, + ); + + // Navigate to "All of the above" and toggle it + writeKey(stdin, '\x1b[B'); // Down to ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\r'); // Toggle All of the above + + await waitFor(async () => { + await waitUntilReady(); + // Verify visual state (checkmarks on all options) + expect(lastFrame()).toMatchSnapshot(); + }); + }); + it('handles custom option in single select with inline typing', async () => { const onSubmit = vi.fn(); const { stdin, lastFrame, waitUntilReady } = renderWithProviders( diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index eec633b7de..b1d23885e6 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -395,7 +395,7 @@ interface OptionItem { key: string; label: string; description: string; - type: 'option' | 'other' | 'done'; + type: 'option' | 'other' | 'done' | 'all'; index: number; } @@ -407,6 +407,7 @@ interface ChoiceQuestionState { 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 }; @@ -419,6 +420,25 @@ function choiceQuestionReducer( 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(); + 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 : []); @@ -703,6 +723,18 @@ const ChoiceQuestionView: React.FC = ({ }, ); + // 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 }); + } + // Only add custom option for choice type, not yesno if (question.type !== 'yesno') { const otherItem: OptionItem = { @@ -755,6 +787,11 @@ const ChoiceQuestionView: React.FC = ({ 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( @@ -783,6 +820,7 @@ const ChoiceQuestionView: React.FC = ({ }, [ question.multiSelect, + questionOptions.length, selectedIndices, isCustomOptionSelected, customOptionText, @@ -857,11 +895,16 @@ const ChoiceQuestionView: React.FC = ({ renderItem={(item, context) => { const optionItem = item.value; const isChecked = - selectedIndices.has(optionItem.index) || - (optionItem.type === 'other' && isCustomOptionSelected); + (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 === 'option' || + optionItem.type === 'other' || + optionItem.type === 'all'); // Render inline text input for custom option if (optionItem.type === 'other') { diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap index 06f509f1f6..30caf0fb40 100644 --- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -201,3 +201,19 @@ README → (not answered) Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel " `; + +exports[`AskUserDialog > verifies "All of the above" visual state with snapshot 1`] = ` +"Which features? +(Select all that apply) + + 1. [x] TypeScript + 2. [x] ESLint +● 3. [x] All of the above + Select all options + 4. [ ] Enter a custom value + Done + Finish selection + +Enter to select · ↑/↓ to navigate · Esc to cancel +" +`;