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/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/__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
+"
+`;
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;