mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
Redesign settings dialog with curated list and view-switching UI
This commit is contained in:
parent
28f6c161da
commit
c87197d420
20 changed files with 627 additions and 724 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue