Redesign settings dialog with curated list and view-switching UI

This commit is contained in:
tanzhenxin 2026-01-18 21:56:33 +08:00
parent 28f6c161da
commit c87197d420
20 changed files with 627 additions and 724 deletions

View file

@ -54,7 +54,7 @@ export function ApprovalModeDialog({
}: ApprovalModeDialogProps): React.JSX.Element {
// Start with User scope by default
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.Workspace,
SettingScope.User,
);
// Track the currently highlighted approval mode
@ -90,19 +90,17 @@ export function ApprovalModeDialog({
setSelectedScope(scope);
}, []);
const handleScopeSelect = useCallback(
(scope: SettingScope) => {
onSelect(highlightedMode, scope);
},
[onSelect, highlightedMode],
);
const handleScopeSelect = useCallback((scope: SettingScope) => {
setSelectedScope(scope);
setMode('mode');
}, []);
const [focusSection, setFocusSection] = useState<'mode' | 'scope'>('mode');
const [mode, setMode] = useState<'mode' | 'scope'>('mode');
useKeypress(
(key) => {
if (key.name === 'tab') {
setFocusSection((prev) => (prev === 'mode' ? 'scope' : 'mode'));
setMode((prev) => (prev === 'mode' ? 'scope' : 'mode'));
}
if (key.name === 'escape') {
onSelect(undefined, selectedScope);
@ -127,59 +125,56 @@ export function ApprovalModeDialog({
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="row"
flexDirection="column"
padding={1}
width="100%"
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
{/* Approval Mode Selection */}
<Text bold={focusSection === 'mode'} wrap="truncate">
{focusSection === 'mode' ? '> ' : ' '}
{t('Approval Mode')}{' '}
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
</Text>
<Box height={1} />
<RadioButtonSelect
items={modeItems}
initialIndex={safeInitialModeIndex}
onSelect={handleModeSelect}
onHighlight={handleModeHighlight}
isFocused={focusSection === 'mode'}
maxItemsToShow={10}
showScrollArrows={false}
showNumbers={focusSection === 'mode'}
/>
<Box height={1} />
{/* Scope Selection */}
<Box marginTop={1}>
<ScopeSelector
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
initialScope={selectedScope}
/>
</Box>
<Box height={1} />
{/* Warning when workspace setting will override user setting */}
{showWorkspacePriorityWarning && (
<>
<Text color={theme.status.warning} wrap="wrap">
{' '}
{t(
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
)}
{mode === 'mode' ? (
<Box flexDirection="column" flexGrow={1}>
{/* Approval Mode Selection */}
<Text bold={mode === 'mode'} wrap="truncate">
{mode === 'mode' ? '> ' : ' '}
{t('Approval Mode')}{' '}
<Text color={theme.text.secondary}>
{otherScopeModifiedMessage}
</Text>
<Box height={1} />
</>
)}
<Text color={theme.text.secondary}>
{t('(Use Enter to select, Tab to change focus)')}
</Text>
<Box height={1} />
<RadioButtonSelect
items={modeItems}
initialIndex={safeInitialModeIndex}
onSelect={handleModeSelect}
onHighlight={handleModeHighlight}
isFocused={mode === 'mode'}
maxItemsToShow={10}
showScrollArrows={false}
showNumbers={mode === 'mode'}
/>
{/* Warning when workspace setting will override user setting */}
{showWorkspacePriorityWarning && (
<Box marginTop={1}>
<Text color={theme.status.warning} wrap="wrap">
{' '}
{t(
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
)}
</Text>
</Box>
)}
</Box>
) : (
<ScopeSelector
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={mode === 'scope'}
initialScope={selectedScope}
/>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary} wrap="truncate">
{mode === 'mode'
? t('(Use Enter to select, Tab to configure scope)')
: t('(Use Enter to apply scope, Tab to go back)')}
</Text>
</Box>
</Box>

View file

@ -152,12 +152,38 @@ export const DialogManager = ({
</Box>
);
}
if (uiState.isEditorDialogOpen) {
return (
<Box flexDirection="column">
{uiState.editorError && (
<Box marginBottom={1}>
<Text color={theme.status.error}>{uiState.editorError}</Text>
</Box>
)}
<EditorSettingsDialog
onSelect={uiActions.handleEditorSelect}
settings={settings}
onExit={uiActions.exitEditorDialog}
/>
</Box>
);
}
if (uiState.isSettingsDialogOpen) {
return (
<Box flexDirection="column">
<SettingsDialog
settings={settings}
onSelect={() => uiActions.closeSettingsDialog()}
onSelect={(settingName) => {
if (settingName === 'ui.theme') {
uiActions.openThemeDialog();
return;
}
if (settingName === 'general.preferredEditor') {
uiActions.openEditorDialog();
return;
}
uiActions.closeSettingsDialog();
}}
onRestartRequest={() => process.exit(0)}
availableTerminalHeight={terminalHeight - staticExtraHeight}
config={config}
@ -237,22 +263,6 @@ export const DialogManager = ({
);
}
}
if (uiState.isEditorDialogOpen) {
return (
<Box flexDirection="column">
{uiState.editorError && (
<Box marginBottom={1}>
<Text color={theme.status.error}>{uiState.editorError}</Text>
</Box>
)}
<EditorSettingsDialog
onSelect={uiActions.handleEditorSelect}
settings={settings}
onExit={uiActions.exitEditorDialog}
/>
</Box>
);
}
if (uiState.isPermissionsDialogOpen) {
return (
<PermissionsModifyTrustDialog

View file

@ -14,6 +14,7 @@ import {
type EditorDisplay,
} from '../editors/editorSettingsManager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { ScopeSelector } from './shared/ScopeSelector.js';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import type { EditorType } from '@qwen-code/qwen-code-core';
@ -35,13 +36,12 @@ export function EditorSettingsDialog({
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
'editor',
);
const [mode, setMode] = useState<'editor' | 'scope'>('editor');
useKeypress(
(key) => {
if (key.name === 'tab') {
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
setMode((prev) => (prev === 'editor' ? 'scope' : 'editor'));
}
if (key.name === 'escape') {
onExit();
@ -65,23 +65,6 @@ export function EditorSettingsDialog({
editorIndex = 0;
}
const scopeItems = [
{
get label() {
return t('User Settings');
},
value: SettingScope.User,
key: SettingScope.User,
},
{
get label() {
return t('Workspace Settings');
},
value: SettingScope.Workspace,
key: SettingScope.Workspace,
},
];
const handleEditorSelect = (editorType: EditorType | 'not_set') => {
if (editorType === 'not_set') {
onSelect(undefined, selectedScope);
@ -92,7 +75,11 @@ export function EditorSettingsDialog({
const handleScopeSelect = (scope: SettingScope) => {
setSelectedScope(scope);
setFocusedSection('editor');
setMode('editor');
};
const handleScopeHighlight = (scope: SettingScope) => {
setSelectedScope(scope);
};
let otherScopeModifiedMessage = '';
@ -131,54 +118,59 @@ export function EditorSettingsDialog({
width="100%"
>
<Box flexDirection="column" width="45%" paddingRight={2}>
<Text bold={focusedSection === 'editor'}>
{focusedSection === 'editor' ? '> ' : ' '}Select Editor{' '}
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
</Text>
<RadioButtonSelect
items={editorItems.map((item) => ({
label: item.name,
value: item.type,
disabled: item.disabled,
key: item.type,
}))}
initialIndex={editorIndex}
onSelect={handleEditorSelect}
isFocused={focusedSection === 'editor'}
key={selectedScope}
/>
<Box marginTop={1} flexDirection="column">
<Text bold={focusedSection === 'scope'}>
{focusedSection === 'scope' ? '> ' : ' '}
{t('Apply To')}
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0}
{mode === 'editor' ? (
<Box flexDirection="column">
<Text bold={mode === 'editor'} wrap="truncate">
{mode === 'editor' ? '> ' : ' '}
{t('Select Editor')}{' '}
<Text color={theme.text.secondary}>
{otherScopeModifiedMessage}
</Text>
</Text>
<Box height={1} />
<RadioButtonSelect
items={editorItems.map((item) => ({
label: item.name,
value: item.type,
disabled: item.disabled,
key: item.type,
}))}
initialIndex={editorIndex}
onSelect={handleEditorSelect}
isFocused={mode === 'editor'}
key={selectedScope}
/>
</Box>
) : (
<ScopeSelector
onSelect={handleScopeSelect}
isFocused={focusedSection === 'scope'}
onHighlight={handleScopeHighlight}
isFocused={mode === 'scope'}
initialScope={selectedScope}
/>
</Box>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
(Use Enter to select, Tab to change focus)
<Text color={theme.text.secondary} wrap="truncate">
{mode === 'editor'
? t('(Use Enter to select, Tab to configure scope)')
: t('(Use Enter to apply scope, Tab to go back)')}
</Text>
</Box>
</Box>
<Box flexDirection="column" width="55%" paddingLeft={2}>
<Text bold color={theme.text.primary}>
Editor Preference
{t('Editor Preference')}
</Text>
<Box flexDirection="column" gap={1} marginTop={1}>
<Text color={theme.text.secondary}>
These editors are currently supported. Please note that some editors
cannot be used in sandbox mode.
{t(
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.',
)}
</Text>
<Text color={theme.text.secondary}>
Your preferred editor is:{' '}
{t('Your preferred editor is:')}{' '}
<Text
color={
mergedEditorName === 'None'

View file

@ -28,12 +28,12 @@ import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js';
import {
getSettingsSchema,
type SettingDefinition,
type SettingsSchemaType,
} from '../../config/settingsSchema.js';
getDialogSettingKeys,
getSettingDefinition,
saveModifiedSettings,
TEST_ONLY,
} from '../../utils/settingsUtils.js';
// Mock the VimModeContext
const mockToggleVimEnabled = vi.fn();
@ -210,8 +210,9 @@ describe('SettingsDialog', () => {
const output = lastFrame();
expect(output).toContain('Settings');
expect(output).toContain('Apply To');
expect(output).toContain('Use Enter to select, Tab to change focus');
// Scope selector is now in a separate view (Tab to switch)
expect(output).not.toContain('Apply To');
expect(output).toContain('(Use Enter to select, Tab to configure scope)');
});
it('should accept availableTerminalHeight prop without errors', () => {
@ -231,7 +232,7 @@ describe('SettingsDialog', () => {
const output = lastFrame();
// Should still render properly with the height prop
expect(output).toContain('Settings');
expect(output).toContain('Use Enter to select');
expect(output).toContain('Enter to select');
});
it('should show settings list with default values', () => {
@ -281,7 +282,7 @@ describe('SettingsDialog', () => {
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
});
expect(lastFrame()).toContain('● Disable Auto Update');
expect(lastFrame()).toContain('● Language');
// The active index should have changed (tested indirectly through behavior)
unmount();
@ -342,7 +343,14 @@ describe('SettingsDialog', () => {
await wait();
expect(lastFrame()).toContain('● Vision Model Preview');
const lastKey = getDialogSettingKeys().at(-1);
expect(lastKey).toBeDefined();
const lastLabel = lastKey
? (getSettingDefinition(lastKey)?.label ?? lastKey)
: '';
expect(lastFrame()).toContain(`${lastLabel}`);
unmount();
});
@ -362,17 +370,21 @@ describe('SettingsDialog', () => {
const { stdin, unmount, lastFrame } = render(component);
// Wait for initial render and verify we're on Vim Mode (first setting)
// Wait for initial render and verify we're on Tool Approval Mode (first setting)
await waitFor(() => {
expect(lastFrame()).toContain('● Vim Mode');
expect(lastFrame()).toContain('● Tool Approval Mode');
});
// Navigate to Disable Auto Update setting and verify we're there
// Navigate to Vim Mode setting (third setting - a boolean) and verify we're there
act(() => {
stdin.write(TerminalKeys.DOWN_ARROW as string);
stdin.write(TerminalKeys.DOWN_ARROW as string); // -> Language
});
await wait();
act(() => {
stdin.write(TerminalKeys.DOWN_ARROW as string); // -> Vim Mode
});
await waitFor(() => {
expect(lastFrame()).toContain('● Disable Auto Update');
expect(lastFrame()).toContain('● Vim Mode');
});
// Toggle the setting
@ -392,10 +404,10 @@ describe('SettingsDialog', () => {
});
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['general.disableAutoUpdate']),
new Set<string>(['general.vimMode']),
{
general: {
disableAutoUpdate: true,
vimMode: true,
},
},
expect.any(LoadedSettings),
@ -406,51 +418,10 @@ describe('SettingsDialog', () => {
});
describe('enum values', () => {
enum StringEnum {
FOO = 'foo',
BAR = 'bar',
BAZ = 'baz',
}
const SETTING: SettingDefinition = {
type: 'enum',
label: 'Theme',
options: [
{
label: 'Foo',
value: StringEnum.FOO,
},
{
label: 'Bar',
value: StringEnum.BAR,
},
{
label: 'Baz',
value: StringEnum.BAZ,
},
],
category: 'UI',
requiresRestart: false,
default: StringEnum.BAR,
description: 'The color theme for the UI.',
showInDialog: true,
};
const FAKE_SCHEMA: SettingsSchemaType = {
ui: {
showInDialog: false,
properties: {
theme: {
...SETTING,
},
},
},
} as unknown as SettingsSchemaType;
it('toggles enum values with the enter key', async () => {
vi.mocked(saveModifiedSettings).mockClear();
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
// Use real schema - first setting "Tool Approval Mode" is an enum
const settings = createMockSettings();
const onSelect = vi.fn();
const component = (
@ -459,24 +430,30 @@ describe('SettingsDialog', () => {
</KeypressProvider>
);
const { stdin, unmount } = render(component);
const { stdin, unmount, lastFrame } = render(component);
// Press Enter to toggle current setting
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
// Verify we're on Tool Approval Mode (first setting, an enum)
await waitFor(() => {
expect(lastFrame()).toContain('● Tool Approval Mode');
});
// Press Enter to cycle the enum value
act(() => {
stdin.write(TerminalKeys.ENTER as string);
});
await wait();
await waitFor(() => {
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
});
// Tool Approval Mode cycles through enum values
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['ui.theme']),
{
ui: {
theme: StringEnum.BAZ,
},
},
new Set<string>(['tools.approvalMode']),
expect.objectContaining({
tools: expect.objectContaining({
approvalMode: expect.any(String),
}),
}),
expect.any(LoadedSettings),
SettingScope.User,
);
@ -486,10 +463,10 @@ describe('SettingsDialog', () => {
it('loops back when reaching the end of an enum', async () => {
vi.mocked(saveModifiedSettings).mockClear();
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
// Use Tool Approval Mode set to YOLO (last value) to test looping back to first
const settings = createMockSettings({
ui: {
theme: StringEnum.BAZ,
tools: {
approvalMode: 'yolo', // Last enum value
},
});
const onSelect = vi.fn();
@ -499,24 +476,30 @@ describe('SettingsDialog', () => {
</KeypressProvider>
);
const { stdin, unmount } = render(component);
const { stdin, unmount, lastFrame } = render(component);
// Press Enter to toggle current setting
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
// Verify we're on Tool Approval Mode (first setting)
await waitFor(() => {
expect(lastFrame()).toContain('● Tool Approval Mode');
});
// Press Enter to cycle - should loop back to first value (Plan)
act(() => {
stdin.write(TerminalKeys.ENTER as string);
});
await wait();
await waitFor(() => {
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
});
// Should loop back to first enum value (Plan)
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
new Set<string>(['ui.theme']),
{
ui: {
theme: StringEnum.FOO,
},
},
new Set<string>(['tools.approvalMode']),
expect.objectContaining({
tools: expect.objectContaining({
approvalMode: 'plan', // First enum value after YOLO
}),
}),
expect.any(LoadedSettings),
SettingScope.User,
);
@ -599,12 +582,12 @@ describe('SettingsDialog', () => {
expect(lastFrame()).toContain('Vim Mode');
});
// The UI should show the settings section is active and scope section is inactive
expect(lastFrame()).toContain('● Vim Mode'); // Settings section active
expect(lastFrame()).toContain(' Apply To'); // Scope section inactive
// The UI should show settings mode is active (scope is in separate view)
expect(lastFrame()).toContain('● Tool Approval Mode'); // Settings section active
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view
// This test validates the initial state - scope selection behavior
// is complex due to keypress handling, so we focus on state validation
// This test validates the initial state - scope selection is now
// accessed via Tab key, not shown alongside settings
unmount();
});
@ -668,12 +651,12 @@ describe('SettingsDialog', () => {
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Hide Window Title');
expect(lastFrame()).toContain('Vim Mode');
});
// Verify the dialog is rendered properly
// Verify the dialog is rendered properly (scope is in separate view)
expect(lastFrame()).toContain('Settings');
expect(lastFrame()).toContain('Apply To');
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view
// This test validates rendering - escape key behavior depends on complex
// keypress handling that's difficult to test reliably in this environment
@ -1021,12 +1004,12 @@ describe('SettingsDialog', () => {
expect(lastFrame()).toContain('Vim Mode');
});
// Verify initial state: settings section active, scope section inactive
expect(lastFrame()).toContain('● Vim Mode'); // Settings section active
expect(lastFrame()).toContain(' Apply To'); // Scope section inactive
// Verify initial state: settings mode active (scope is in separate view)
expect(lastFrame()).toContain('● Tool Approval Mode'); // Settings mode active
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view
// This test validates the rendered UI structure for tab navigation
// Actual tab behavior testing is complex due to keypress handling
// Tab now switches between settings view and scope view
unmount();
});
@ -1083,17 +1066,16 @@ describe('SettingsDialog', () => {
expect(lastFrame()).toContain('Vim Mode');
});
// Verify the complete UI is rendered with all necessary sections
// Verify the complete UI is rendered (scope is in separate view)
expect(lastFrame()).toContain('Settings'); // Title
expect(lastFrame()).toContain('● Vim Mode'); // Active setting
expect(lastFrame()).toContain('Apply To'); // Scope section
expect(lastFrame()).toContain('User Settings'); // Scope options (no numbers when settings focused)
expect(lastFrame()).toContain('● Tool Approval Mode'); // Active setting
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view (Tab to access)
expect(lastFrame()).toContain(
'(Use Enter to select, Tab to change focus)',
'(Use Enter to select, Tab to configure scope)',
); // Help text
// This test validates the complete UI structure is available for user workflow
// Individual interactions are tested in focused unit tests
// Scope selection is now accessed via Tab key (view switching like ThemeDialog)
unmount();
});

View file

@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { LoadedSettings, Settings } from '../../config/settings.js';
@ -57,10 +58,8 @@ export function SettingsDialog({
// Get vim mode context to sync vim mode changes
const { vimEnabled, toggleVimEnabled } = useVimMode();
// Focus state: 'settings' or 'scope'
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
'settings',
);
// Mode state: 'settings' or 'scope' (view switching like ThemeDialog)
const [mode, setMode] = useState<'settings' | 'scope'>('settings');
// Scope selector state (User by default)
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
@ -105,7 +104,9 @@ export function SettingsDialog({
updated = setPendingSettingValue(key, value, updated);
} else if (
(def?.type === 'number' && typeof value === 'number') ||
(def?.type === 'string' && typeof value === 'string')
(def?.type === 'string' && typeof value === 'string') ||
(def?.type === 'enum' &&
(typeof value === 'string' || typeof value === 'number'))
) {
updated = setPendingSettingValueAny(key, value, updated);
}
@ -156,10 +157,6 @@ export function SettingsDialog({
);
}
setPendingSettings((prev) =>
setPendingSettingValue(key, newValue as boolean, prev),
);
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const immediateSettingsObject = setPendingSettingValueAny(
@ -381,15 +378,13 @@ export function SettingsDialog({
const handleScopeSelect = (scope: SettingScope) => {
handleScopeHighlight(scope);
setFocusSection('settings');
setMode('settings');
};
// Height constraint calculations similar to ThemeDialog
const DIALOG_PADDING = 2;
const SETTINGS_TITLE_HEIGHT = 2; // "Settings" title + spacing
const SCROLL_ARROWS_HEIGHT = 2; // Up and down arrows
const SPACING_HEIGHT = 1; // Space between settings list and scope
const SCOPE_SELECTION_HEIGHT = 4; // Apply To section height
const BOTTOM_HELP_TEXT_HEIGHT = 1; // Help text
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
@ -397,71 +392,28 @@ export function SettingsDialog({
availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
currentAvailableTerminalHeight -= 2; // Top and bottom borders
// Start with basic fixed height (without scope selection)
let totalFixedHeight =
// Calculate fixed height (scope selection is now in a separate view, not included here)
const totalFixedHeight =
DIALOG_PADDING +
SETTINGS_TITLE_HEIGHT +
SCROLL_ARROWS_HEIGHT +
SPACING_HEIGHT +
BOTTOM_HELP_TEXT_HEIGHT +
RESTART_PROMPT_HEIGHT;
// Calculate how much space we have for settings
let availableHeightForSettings = Math.max(
const availableHeightForSettings = Math.max(
1,
currentAvailableTerminalHeight - totalFixedHeight,
);
// Each setting item takes 2 lines (the setting row + spacing)
let maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
// Decide whether to show scope selection based on remaining space
let showScopeSelection = true;
// If we have limited height, prioritize showing more settings over scope selection
if (availableTerminalHeight && availableTerminalHeight < 25) {
// For very limited height, hide scope selection to show more settings
const totalWithScope = totalFixedHeight + SCOPE_SELECTION_HEIGHT;
const availableWithScope = Math.max(
1,
currentAvailableTerminalHeight - totalWithScope,
);
const maxItemsWithScope = Math.max(1, Math.floor(availableWithScope / 2));
// If hiding scope selection allows us to show significantly more settings, do it
if (maxVisibleItems > maxItemsWithScope + 1) {
showScopeSelection = false;
} else {
// Otherwise include scope selection and recalculate
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
availableHeightForSettings = Math.max(
1,
currentAvailableTerminalHeight - totalFixedHeight,
);
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
}
} else {
// For normal height, include scope selection
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
availableHeightForSettings = Math.max(
1,
currentAvailableTerminalHeight - totalFixedHeight,
);
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
}
// Each setting item takes 1 line
const maxVisibleItems = Math.max(1, availableHeightForSettings);
// Use the calculated maxVisibleItems or fall back to the original maxItemsToShow
const effectiveMaxItemsToShow = availableTerminalHeight
? Math.min(maxVisibleItems, items.length)
: maxItemsToShow;
// Ensure focus stays on settings when scope selection is hidden
React.useEffect(() => {
if (!showScopeSelection && focusSection === 'scope') {
setFocusSection('settings');
}
}, [showScopeSelection, focusSection]);
// Scroll logic for settings
const visibleItems = items.slice(
scrollOffset,
@ -474,10 +426,10 @@ export function SettingsDialog({
useKeypress(
(key) => {
const { name, ctrl } = key;
if (name === 'tab' && showScopeSelection) {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
if (name === 'tab') {
setMode((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
if (mode === 'settings') {
// If editing, capture input and control keys
if (editingKey) {
const definition = getSettingDefinition(editingKey);
@ -599,6 +551,18 @@ export function SettingsDialog({
}
} else if (name === 'return' || name === 'space') {
const currentItem = items[activeSettingIndex];
if (currentItem?.value === 'ui.theme') {
if (name === 'return') {
onSelect('ui.theme', selectedScope);
}
return;
}
if (currentItem?.value === 'general.preferredEditor') {
if (name === 'return') {
onSelect('general.preferredEditor', selectedScope);
}
return;
}
if (
currentItem?.type === 'number' ||
currentItem?.type === 'string'
@ -775,97 +739,95 @@ export function SettingsDialog({
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="row"
flexDirection="column"
padding={1}
width="100%"
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
<Text bold={focusSection === 'settings'} wrap="truncate">
{focusSection === 'settings' ? '> ' : ' '}
{t('Settings')}
</Text>
<Box height={1} />
{showScrollUp && <Text color={theme.text.secondary}></Text>}
{visibleItems.map((item, idx) => {
const isActive =
focusSection === 'settings' &&
activeSettingIndex === idx + scrollOffset;
{mode === 'settings' ? (
<Box flexDirection="column" flexGrow={1}>
<Text bold={mode === 'settings'} wrap="truncate">
{mode === 'settings' ? '> ' : ' '}
{t('Settings')}
</Text>
<Box height={1} />
{showScrollUp && <Text color={theme.text.secondary}></Text>}
{visibleItems.map((item, idx) => {
const isActive =
mode === 'settings' && activeSettingIndex === idx + scrollOffset;
const scopeSettings = settings.forScope(selectedScope).settings;
const mergedSettings = settings.merged;
const scopeSettings = settings.forScope(selectedScope).settings;
const mergedSettings = settings.merged;
let displayValue: string;
if (editingKey === item.value) {
// Show edit buffer with advanced cursor highlighting
if (cursorVisible && editCursorPos < cpLen(editBuffer)) {
// Cursor is in the middle or at start of text
const beforeCursor = cpSlice(editBuffer, 0, editCursorPos);
const atCursor = cpSlice(
editBuffer,
editCursorPos,
editCursorPos + 1,
let displayValue: string;
if (editingKey === item.value) {
// Show edit buffer with advanced cursor highlighting
if (cursorVisible && editCursorPos < cpLen(editBuffer)) {
// Cursor is in the middle or at start of text
const beforeCursor = cpSlice(editBuffer, 0, editCursorPos);
const atCursor = cpSlice(
editBuffer,
editCursorPos,
editCursorPos + 1,
);
const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
displayValue =
beforeCursor + chalk.inverse(atCursor) + afterCursor;
} else if (cursorVisible && editCursorPos >= cpLen(editBuffer)) {
// Cursor is at the end - show inverted space
displayValue = editBuffer + chalk.inverse(' ');
} else {
// Cursor not visible
displayValue = editBuffer;
}
} else if (item.type === 'number' || item.type === 'string') {
// For numbers/strings, get the actual current value from pending settings
const path = item.value.split('.');
const currentValue = getNestedValue(pendingSettings, path);
const defaultValue = getDefaultValue(item.value);
if (currentValue !== undefined && currentValue !== null) {
displayValue = String(currentValue);
} else {
displayValue =
defaultValue !== undefined && defaultValue !== null
? String(defaultValue)
: '';
}
// Add * if value differs from default OR if currently being modified
const isModified = modifiedSettings.has(item.value);
const effectiveCurrentValue =
currentValue !== undefined && currentValue !== null
? currentValue
: defaultValue;
const isDifferentFromDefault =
effectiveCurrentValue !== defaultValue;
if (isDifferentFromDefault || isModified) {
displayValue += '*';
}
} else {
// For booleans and other types, use existing logic
displayValue = getDisplayValue(
item.value,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
displayValue =
beforeCursor + chalk.inverse(atCursor) + afterCursor;
} else if (cursorVisible && editCursorPos >= cpLen(editBuffer)) {
// Cursor is at the end - show inverted space
displayValue = editBuffer + chalk.inverse(' ');
} else {
// Cursor not visible
displayValue = editBuffer;
}
} else if (item.type === 'number' || item.type === 'string') {
// For numbers/strings, get the actual current value from pending settings
const path = item.value.split('.');
const currentValue = getNestedValue(pendingSettings, path);
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
const defaultValue = getDefaultValue(item.value);
if (currentValue !== undefined && currentValue !== null) {
displayValue = String(currentValue);
} else {
displayValue =
defaultValue !== undefined && defaultValue !== null
? String(defaultValue)
: '';
}
// Add * if value differs from default OR if currently being modified
const isModified = modifiedSettings.has(item.value);
const effectiveCurrentValue =
currentValue !== undefined && currentValue !== null
? currentValue
: defaultValue;
const isDifferentFromDefault =
effectiveCurrentValue !== defaultValue;
if (isDifferentFromDefault || isModified) {
displayValue += '*';
}
} else {
// For booleans and other types, use existing logic
displayValue = getDisplayValue(
// Generate scope message for this setting
const scopeMessage = getScopeMessageForSetting(
item.value,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
selectedScope,
settings,
);
}
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
// Generate scope message for this setting
const scopeMessage = getScopeMessageForSetting(
item.value,
selectedScope,
settings,
);
return (
<React.Fragment key={item.value}>
<Box flexDirection="row" alignItems="center">
return (
<Box key={item.value} flexDirection="row" alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text
color={
@ -898,40 +860,32 @@ export function SettingsDialog({
{displayValue}
</Text>
</Box>
<Box height={1} />
</React.Fragment>
);
})}
{showScrollDown && <Text color={theme.text.secondary}></Text>}
<Box height={1} />
{/* Scope Selection - conditionally visible based on height constraints */}
{showScopeSelection && (
<Box marginTop={1}>
<ScopeSelector
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
initialScope={selectedScope}
/>
</Box>
)}
<Box height={1} />
<Text color={theme.text.secondary}>
{t('(Use Enter to select{{tabText}})', {
tabText: showScopeSelection ? t(', Tab to change focus') : '',
);
})}
{showScrollDown && <Text color={theme.text.secondary}></Text>}
</Box>
) : (
<ScopeSelector
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={mode === 'scope'}
initialScope={selectedScope}
/>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary} wrap="truncate">
{mode === 'settings'
? t('(Use Enter to select, Tab to configure scope)')
: t('(Use Enter to apply scope, Tab to go back)')}
</Text>
{showRestartPrompt && (
<Text color={theme.status.warning}>
{t(
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
)}
</Text>
)}
</Box>
{showRestartPrompt && (
<Text color={theme.status.warning}>
{t(
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
)}
</Text>
)}
</Box>
);
}

View file

@ -278,7 +278,7 @@ def fibonacci(n):
<Text color={theme.text.secondary} wrap="truncate">
{mode === 'theme'
? t('(Use Enter to select, Tab to configure scope)')
: t('(Use Enter to apply scope, Tab to select theme)')}
: t('(Use Enter to apply scope, Tab to go back)')}
</Text>
</Box>
</Box>

View file

@ -6,30 +6,17 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false │
│ │
│ Disable Auto Update false │
│ │
│ Debug Keystroke Logging false │
│ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ ▼ │
│ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ (Use Enter to select, Tab to configure scope) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@ -40,30 +27,17 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false │
│ │
│ Disable Auto Update false │
│ │
│ Debug Keystroke Logging false │
│ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ ▼ │
│ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ (Use Enter to select, Tab to configure scope) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@ -74,30 +48,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode true* │
│ │
│ Disable Auto Update false │
│ │
│ Debug Keystroke Logging false │
│ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Vim Mode true* │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code true* │
│ ▼ │
│ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ (Use Enter to select, Tab to configure scope) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@ -108,30 +69,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false* │
│ │
│ Disable Auto Update false* │
│ │
│ Debug Keystroke Logging false* │
│ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false* │
│ │
│ Show Status in Title false │
│ │
│ Vim Mode false* │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ Preferred Editor │
│ Auto-connect to IDE false* │
│ Show Line Numbers in Code false* │
│ ▼ │
│ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ (Use Enter to select, Tab to configure scope) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@ -142,30 +90,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode (Modified in System) false │
│ │
│ Disable Auto Update (Modified in System) false │
│ │
│ Debug Keystroke Logging false │
│ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Vim Mode (Modified in System) false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ ▼ │
│ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ (Use Enter to select, Tab to configure scope) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@ -176,30 +111,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode (Modified in Workspace) false │
│ │
│ Disable Auto Update false │
│ │
│ Debug Keystroke Logging (Modified in Workspace) false │
│ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Vim Mode (Modified in Workspace) false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ ▼ │
│ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ (Use Enter to select, Tab to configure scope) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@ -210,30 +132,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false │
│ │
│ Disable Auto Update false │
│ │
│ Debug Keystroke Logging false │
│ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ ▼ │
│ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ (Use Enter to select, Tab to configure scope) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@ -244,30 +153,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false* │
│ │
│ Disable Auto Update true* │
│ │
│ Debug Keystroke Logging false │
│ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false* │
│ │
│ Show Status in Title false │
│ │
│ Vim Mode false* │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ ▼ │
│ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ (Use Enter to select, Tab to configure scope) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@ -278,30 +174,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode false │
│ │
│ Disable Auto Update false │
│ │
│ Debug Keystroke Logging false │
│ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ Show Line Numbers in Code false │
│ ▼ │
│ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ (Use Enter to select, Tab to configure scope) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@ -312,30 +195,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ > Settings │
│ │
│ ▲ │
│ ● Vim Mode true* │
│ │
│ Disable Auto Update true* │
│ │
│ Debug Keystroke Logging true* │
│ │
│ ● Tool Approval Mode Default │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title true* │
│ │
│ Show Status in Title false │
│ │
│ Vim Mode true* │
│ Interactive Shell (PTY) false │
│ Theme Qwen Dark │
│ Preferred Editor │
│ Auto-connect to IDE true* │
│ Show Line Numbers in Code true* │
│ ▼ │
│ │
│ │
│ Apply To │
│ ● User Settings │
│ Workspace Settings │
│ │
│ (Use Enter to select, Tab to change focus) │
│ (Use Enter to select, Tab to configure scope) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View file

@ -4,10 +4,11 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Apply To │
│ │
│ ● 1. User Settings │
│ 2. Workspace Settings │
│ │
│ (Use Enter to apply scope, Tab to select theme)
│ (Use Enter to apply scope, Tab to go back)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View file

@ -45,6 +45,7 @@ export function ScopeSelector({
{isFocused ? '> ' : ' '}
{t('Apply To')}
</Text>
<Box height={1} />
<RadioButtonSelect
items={scopeItems}
initialIndex={safeInitialIndex}