From b211f30d95870edfa0798e15b074969c5bf5a3e7 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Tue, 17 Mar 2026 15:08:45 -0400 Subject: [PATCH 1/2] fix(cli): override j/k navigation in settings dialog to fix search input conflict (#22800) --- .../src/ui/components/SettingsDialog.test.tsx | 33 +++++++++++++++++-- .../cli/src/ui/components/SettingsDialog.tsx | 20 +++++++++++ .../components/shared/BaseSettingsDialog.tsx | 9 +++-- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index be99dfcc26..4a2fd6a854 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -52,6 +52,8 @@ enum TerminalKeys { RIGHT_ARROW = '\u001B[C', ESCAPE = '\u001B', BACKSPACE = '\u0008', + CTRL_P = '\u0010', + CTRL_N = '\u000E', } vi.mock('../../config/settingsSchema.js', async (importOriginal) => { @@ -357,9 +359,9 @@ describe('SettingsDialog', () => { up: TerminalKeys.UP_ARROW, }, { - name: 'vim keys (j/k)', - down: 'j', - up: 'k', + name: 'emacs keys (Ctrl+P/N)', + down: TerminalKeys.CTRL_N, + up: TerminalKeys.CTRL_P, }, ])('should navigate with $name', async ({ down, up }) => { const settings = createMockSettings(); @@ -397,6 +399,31 @@ describe('SettingsDialog', () => { unmount(); }); + it('should allow j and k characters to be typed in search without triggering navigation', async () => { + const settings = createMockSettings(); + const onSelect = vi.fn(); + const { lastFrame, stdin, waitUntilReady, unmount } = renderDialog( + settings, + onSelect, + ); + await waitUntilReady(); + + // Enter 'j' and 'k' in search + await act(async () => stdin.write('j')); + await waitUntilReady(); + await act(async () => stdin.write('k')); + await waitUntilReady(); + + await waitFor(() => { + const frame = lastFrame(); + // The search box should contain 'jk' + expect(frame).toContain('jk'); + // Since 'jk' doesn't match any setting labels, it should say "No matches found." + expect(frame).toContain('No matches found.'); + }); + unmount(); + }); + it('wraps around when at the top of the list', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 82965bda71..994bde6ed3 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -43,6 +43,8 @@ import { BaseSettingsDialog, type SettingsDialogItem, } from './shared/BaseSettingsDialog.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; +import { Command, KeyBinding } from '../key/keyBindings.js'; interface FzfResult { item: string; @@ -60,6 +62,11 @@ interface SettingsDialogProps { const MAX_ITEMS_TO_SHOW = 8; +const KEY_UP = new KeyBinding('up'); +const KEY_CTRL_P = new KeyBinding('ctrl+p'); +const KEY_DOWN = new KeyBinding('down'); +const KEY_CTRL_N = new KeyBinding('ctrl+n'); + // Create a snapshot of the initial per-scope state of Restart Required Settings // This creates a nested map of the form // restartRequiredSetting -> Map { scopeName -> value } @@ -336,6 +343,18 @@ export function SettingsDialog({ onSelect(undefined, selectedScope as SettingScope); }, [onSelect, selectedScope]); + const globalKeyMatchers = useKeyMatchers(); + const settingsKeyMatchers = useMemo( + () => ({ + ...globalKeyMatchers, + [Command.DIALOG_NAVIGATION_UP]: (key: Key) => + KEY_UP.matches(key) || KEY_CTRL_P.matches(key), + [Command.DIALOG_NAVIGATION_DOWN]: (key: Key) => + KEY_DOWN.matches(key) || KEY_CTRL_N.matches(key), + }), + [globalKeyMatchers], + ); + // Custom key handler for restart key const handleKeyPress = useCallback( (key: Key, _currentItem: SettingsDialogItem | undefined): boolean => { @@ -371,6 +390,7 @@ export function SettingsDialog({ onItemClear={handleItemClear} onClose={handleClose} onKeyPress={handleKeyPress} + keyMatchers={settingsKeyMatchers} footer={ showRestartPrompt ? { diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index d96646e8a5..804633fe15 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -19,7 +19,7 @@ import { TextInput } from './TextInput.js'; import type { TextBuffer } from './text-buffer.js'; import { cpSlice, cpLen, cpIndexToOffset } from '../../utils/textUtils.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; -import { Command } from '../../key/keyMatchers.js'; +import { Command, type KeyMatchers } from '../../key/keyMatchers.js'; import { useSettingsNavigation } from '../../hooks/useSettingsNavigation.js'; import { useInlineEditBuffer } from '../../hooks/useInlineEditBuffer.js'; import { formatCommand } from '../../key/keybindingUtils.js'; @@ -103,6 +103,9 @@ export interface BaseSettingsDialogProps { currentItem: SettingsDialogItem | undefined, ) => boolean; + /** Optional override for key matchers used for navigation. */ + keyMatchers?: KeyMatchers; + /** Available terminal height for dynamic windowing */ availableHeight?: number; @@ -134,10 +137,12 @@ export function BaseSettingsDialog({ onItemClear, onClose, onKeyPress, + keyMatchers: customKeyMatchers, availableHeight, footer, }: BaseSettingsDialogProps): React.JSX.Element { - const keyMatchers = useKeyMatchers(); + const globalKeyMatchers = useKeyMatchers(); + const keyMatchers = customKeyMatchers ?? globalKeyMatchers; // Calculate effective max items and scope visibility based on terminal height const { effectiveMaxItemsToShow, finalShowScopeSelector } = useMemo(() => { const initialShowScope = showScopeSelector; 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 2/2] 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 +" +`;